This commit is contained in:
+180
-327
@@ -1,355 +1,208 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import yaml
|
||||
import json
|
||||
import importlib.util
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from dataclasses import MISSING, asdict, dataclass
|
||||
from dataclasses import dataclass
|
||||
from abc import ABC, abstractmethod
|
||||
from pydantic import BaseModel
|
||||
|
||||
from playbook.models import ActModel, PlaybookModel, timed_run
|
||||
from playbook.logging_models import Status, StepLogModel
|
||||
from playbook.action_registry import ActionFn
|
||||
|
||||
from playbook.models import Status, StepLog, timed_run
|
||||
from playbook.action_registry import ActionFn, ActionRegistry
|
||||
from playbook.premade_steps_registry import registry
|
||||
|
||||
|
||||
class StepIF(ABC):
|
||||
@abstractmethod
|
||||
def run(self, ctx: Dict[str, Any]) -> StepLog:
|
||||
def run(self, ctx: dict[str, object]) -> StepLogModel:
|
||||
"""Perform the actions."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_action_names(self) -> List[str]:
|
||||
def get_action_names(self) -> list[str]:
|
||||
""" Return a mapping of action roles (pre, play, post) to function names. """
|
||||
pass
|
||||
|
||||
|
||||
class CustomStep(StepIF):
|
||||
""" Setup custom steps. """
|
||||
|
||||
def __init__(self, name: str, actions: List[ActionFn], context: Dict[str, Any]):
|
||||
self.name: str = name
|
||||
self.__actions = actions
|
||||
self.__context = context
|
||||
|
||||
def get_action_names(self) -> List[str]:
|
||||
return [action.__name__ for action in self.__actions]
|
||||
|
||||
# NOTE: refactor this method to be more readable
|
||||
def run(self, ctx) -> StepLog:
|
||||
""" Run the step. """
|
||||
substeps: List[StepLog] = []
|
||||
status: Status = Status.GOOD
|
||||
|
||||
# NOTE:
|
||||
# Use this to move context from the previous step to the next step
|
||||
# This is wrong, we are moving away from having a list of steps here to a single
|
||||
# step for each operation
|
||||
prev_step_ctx: Dict[str, Any] = {}
|
||||
|
||||
for op in self.__actions:
|
||||
try:
|
||||
log: StepLog = timed_run(op, ctx | prev_step_ctx | self.__context, self.name)
|
||||
except Exception as e:
|
||||
log: StepLog = StepLog.fail(self.name, [{"status": "failed", "output": str(e)}])
|
||||
|
||||
status = Status.BAD if log.failed else Status.GOOD
|
||||
prev_step_ctx = log.pipe_ctx
|
||||
|
||||
substeps.append(log)
|
||||
|
||||
if status == Status.BAD:
|
||||
break
|
||||
|
||||
errors = []
|
||||
msg: str = ""
|
||||
if status == Status.BAD:
|
||||
# Note, use this to set the standar error output/formatting
|
||||
# errors = [{"status": "failed", "output": ""}]
|
||||
errors = []
|
||||
else:
|
||||
msg = "success"
|
||||
|
||||
return StepLog(
|
||||
step_name=self.name, status=status,
|
||||
msg=msg, error=errors, substeps=substeps, pipe_ctx=prev_step_ctx
|
||||
)
|
||||
# class PlaybookError(Exception):
|
||||
# """ Base exception for Playbook parsing and execution failures. """
|
||||
# pass
|
||||
|
||||
|
||||
class PlaybookError(Exception):
|
||||
""" Base exception for Playbook parsing and execution failures. """
|
||||
pass
|
||||
# @dataclass
|
||||
# class StepEntry(object):
|
||||
# name: str
|
||||
# step: StepIF
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepEntry(object):
|
||||
name: str
|
||||
step: StepIF
|
||||
# class Play(object):
|
||||
# def __init__(self, name: str, registries: dict[str, ActionRegistry]):
|
||||
# self.name: str = name
|
||||
# self.log_dir: str = ""
|
||||
# self._context: dict[str, object] = {}
|
||||
# self._acts: dict[str, list[CustomStep]] = {}
|
||||
# self._steps: list[StepEntry] = []
|
||||
# self._registries: dict[str, ActionRegistry] = {
|
||||
# registry.name: registry} | registries
|
||||
#
|
||||
#
|
||||
# def add_step(self, name: str, stepRunner: StepIF):
|
||||
# self._steps.append(StepEntry(name=name, step=stepRunner))
|
||||
#
|
||||
# def view_playbook(self) -> str:
|
||||
# acts_info = {}
|
||||
# for act_name, step_list in self._acts.items():
|
||||
# steps_info = []
|
||||
# for s in step_list:
|
||||
# steps_info.append({
|
||||
# "name": s.name,
|
||||
# "actions": s.get_action_names()
|
||||
# })
|
||||
#
|
||||
# new_act = {
|
||||
# "name": act_name,
|
||||
# "steps": steps_info
|
||||
# }
|
||||
#
|
||||
# acts_info[act_name] = new_act
|
||||
#
|
||||
# registry_data = []
|
||||
# for reg in self._registries.values():
|
||||
# registry_data.append(reg.manifest())
|
||||
#
|
||||
# data = {
|
||||
# "name": self.name,
|
||||
# "registries": registry_data,
|
||||
# "number_of_acts": len(acts_info),
|
||||
# "acts": acts_info,
|
||||
# }
|
||||
#
|
||||
# return json.dumps(data)
|
||||
#
|
||||
# def play(self) -> StepLogModel:
|
||||
# log: StepLogModel = timed_run(self._play_act)
|
||||
#
|
||||
# if self.log_dir != "":
|
||||
# log.log_file_path = os.path.join(
|
||||
# self.log_dir, self.log_file_name(log.start_date_timestamp)
|
||||
# )
|
||||
# self._write_log(log)
|
||||
#
|
||||
# return log
|
||||
#
|
||||
# def log_file_name(self, start_date_ns: int) -> str:
|
||||
# """ Format to ISO 8601. """
|
||||
# seconds = start_date_ns // 1_000_000_000
|
||||
# ms = (start_date_ns % 1_000_000_000) // 1_000_000
|
||||
#
|
||||
# dt = datetime.fromtimestamp(seconds, tz=timezone.utc).astimezone()
|
||||
#
|
||||
# return dt.strftime(f"{self.name}_%Y-%m-%d_%H-%M-%S.{ms:03d}.log")
|
||||
#
|
||||
# def _write_log(self, log: StepLogModel):
|
||||
# try:
|
||||
# with open(log.log_file_path, "w") as file:
|
||||
# json.dump(asdict(log), file)
|
||||
# except Exception as e:
|
||||
# raise PlaybookError(f"failed to create log file {e}") from e
|
||||
# return ""
|
||||
#
|
||||
# def _play_act(self) -> StepLogModel:
|
||||
# playLog: StepLogModel = StepLogModel(
|
||||
# step_name=self.name,
|
||||
# status=Status.GOOD,
|
||||
# duration_sec=0,
|
||||
# msg="",
|
||||
# error=[],
|
||||
# substeps=[],
|
||||
# )
|
||||
#
|
||||
# for a in self._acts:
|
||||
# log: StepLogModel = self._play_steps(self._acts[a])
|
||||
#
|
||||
# if log.failed:
|
||||
# playLog.error.append({
|
||||
# "status": "failed",
|
||||
# "output": f"failed in step: {log.step_name}"
|
||||
# })
|
||||
# playLog.status = Status.BAD
|
||||
# break
|
||||
#
|
||||
# if playLog.status == Status.GOOD:
|
||||
# playLog.msg = "success"
|
||||
#
|
||||
# return playLog
|
||||
#
|
||||
# def _play_steps(self, steps: list[CustomStep]) -> StepLogModel:
|
||||
# playLog: StepLogModel = StepLogModel(
|
||||
# step_name=self.name,
|
||||
# status=Status.GOOD,
|
||||
# duration_sec=0,
|
||||
# msg="",
|
||||
# error=[],
|
||||
# substeps=[],
|
||||
# )
|
||||
#
|
||||
# prev_ctx: dict[str, object] = {}
|
||||
# for step in steps:
|
||||
# log: StepLogModel = timed_run(step.run, self._context | prev_ctx)
|
||||
# prev_ctx = log.pipe_ctx
|
||||
#
|
||||
# playLog.substeps.append(log)
|
||||
#
|
||||
# if log.failed:
|
||||
# playLog.error.append({
|
||||
# "status": "failed",
|
||||
# "output": f"failed in step: {step.name}"
|
||||
# })
|
||||
# playLog.status = Status.BAD
|
||||
# break
|
||||
#
|
||||
# if playLog.status == Status.GOOD:
|
||||
# playLog.msg = "success"
|
||||
#
|
||||
# return playLog
|
||||
#
|
||||
# def _get_function_from_registry(self, fn_name) -> ActionFn:
|
||||
# for reg in self._registries.values():
|
||||
# try:
|
||||
# return reg.get(fn_name, "*")
|
||||
# except ValueError:
|
||||
# continue
|
||||
#
|
||||
# raise ValueError(f"no function with name '{fn_name}' was found")
|
||||
#
|
||||
# def _build_custom_step(self, name, actions, ctx) -> CustomStep:
|
||||
# functionlist: list[ActionFn] = []
|
||||
# for f in actions:
|
||||
# try:
|
||||
# fn = self._get_function_from_registry(actions[f])
|
||||
# functionlist.append(fn)
|
||||
# except KeyError as e:
|
||||
# raise PlaybookError(
|
||||
# f"Step '{name}' is missing required action field: {e}") from e
|
||||
#
|
||||
# return CustomStep(name, functionlist, ctx)
|
||||
|
||||
|
||||
class Play(object):
|
||||
def __init__(self, name : str, registries: Dict[str, ActionRegistry]):
|
||||
self.name: str = name
|
||||
self.log_dir: str = ""
|
||||
self._context: Dict[str, Any] = {}
|
||||
self._acts: Dict[str, List[CustomStep]] = {}
|
||||
self._steps: List[StepEntry] = []
|
||||
self._registries: Dict[str, ActionRegistry] = {registry.name: registry} | registries
|
||||
class Playbook():
|
||||
model: PlaybookModel
|
||||
|
||||
# NOTE: this should move to the ActionRegistry thing probably
|
||||
@staticmethod
|
||||
def _load_registry_file(file_path: str) -> List[ActionRegistry]:
|
||||
abs_path = os.path.abspath(file_path)
|
||||
module_name = os.path.splitext(os.path.basename(abs_path))[0]
|
||||
def __init__(self, model: PlaybookModel):
|
||||
self.model = model
|
||||
|
||||
spec = importlib.util.spec_from_file_location(module_name, abs_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"could not create spec for registry file: {file_path}")
|
||||
@property
|
||||
def acts(self) -> list[ActModel]:
|
||||
return self.model.acts
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
discovered_registries = [
|
||||
obj for obj in vars(module).values()
|
||||
if isinstance(obj, ActionRegistry)
|
||||
]
|
||||
|
||||
if not discovered_registries:
|
||||
raise PlaybookError(f"No ActionRegistry instances were found in '{file_path}'")
|
||||
|
||||
return discovered_registries
|
||||
|
||||
def add_step(self, name: str, stepRunner : StepIF):
|
||||
self._steps.append(StepEntry(name=name, step=stepRunner))
|
||||
@classmethod
|
||||
def from_yaml(cls, fp: str) -> Playbook:
|
||||
try:
|
||||
new_playboook = PlaybookModel.from_yaml_file(fp)
|
||||
return Playbook(new_playboook)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
def view_playbook(self) -> str:
|
||||
acts_info = {}
|
||||
for act_name, step_list in self._acts.items():
|
||||
steps_info = []
|
||||
for s in step_list:
|
||||
steps_info.append({
|
||||
"name": s.name,
|
||||
"actions": s.get_action_names()
|
||||
})
|
||||
return self.model.model_dump_json()
|
||||
|
||||
new_act = {
|
||||
"name": act_name,
|
||||
"steps": steps_info
|
||||
}
|
||||
|
||||
acts_info[act_name] = new_act
|
||||
|
||||
|
||||
|
||||
registry_data = []
|
||||
for reg in self._registries.values():
|
||||
registry_data.append(reg.manifest())
|
||||
|
||||
data = {
|
||||
"name": self.name,
|
||||
"registries": registry_data,
|
||||
"number_of_acts": len(acts_info),
|
||||
"acts": acts_info,
|
||||
}
|
||||
|
||||
return json.dumps(data)
|
||||
|
||||
def play(self) -> StepLog:
|
||||
log: StepLog = timed_run(self._play_act)
|
||||
|
||||
if self.log_dir != "":
|
||||
log.log_file_path = os.path.join(
|
||||
self.log_dir, self.log_file_name(log.start_date_timestamp)
|
||||
)
|
||||
self._write_log(log)
|
||||
|
||||
return log
|
||||
|
||||
def log_file_name(self, start_date_ns: int) -> str:
|
||||
""" Format to ISO 8601. """
|
||||
seconds = start_date_ns // 1_000_000_000
|
||||
ms = (start_date_ns % 1_000_000_000) // 1_000_000
|
||||
|
||||
dt = datetime.fromtimestamp(seconds, tz=timezone.utc).astimezone()
|
||||
|
||||
return dt.strftime(f"{self.name}_%Y-%m-%d_%H-%M-%S.{ms:03d}.log")
|
||||
|
||||
def _write_log(self, log: StepLog):
|
||||
try:
|
||||
with open(log.log_file_path, "w") as file:
|
||||
json.dump(asdict(log), file)
|
||||
except Exception as e:
|
||||
raise PlaybookError(f"failed to create log file {e}") from e
|
||||
return ""
|
||||
|
||||
def _play_act(self) -> StepLog:
|
||||
playLog: StepLog = StepLog(
|
||||
step_name=self.name,
|
||||
status=Status.GOOD,
|
||||
duration_sec=0,
|
||||
msg="",
|
||||
error=[],
|
||||
substeps=[],
|
||||
)
|
||||
|
||||
for a in self._acts:
|
||||
log: StepLog = self._play_steps(self._acts[a])
|
||||
|
||||
if log.failed:
|
||||
playLog.error.append({
|
||||
"status": "failed",
|
||||
"output": f"failed in step: {log.step_name}"
|
||||
})
|
||||
playLog.status = Status.BAD
|
||||
break
|
||||
|
||||
if playLog.status == Status.GOOD:
|
||||
playLog.msg = "success"
|
||||
|
||||
return playLog
|
||||
|
||||
def _play_steps(self, steps: List[CustomStep]) -> StepLog:
|
||||
playLog: StepLog = StepLog(
|
||||
step_name=self.name,
|
||||
status=Status.GOOD,
|
||||
duration_sec=0,
|
||||
msg="",
|
||||
error=[],
|
||||
substeps=[],
|
||||
)
|
||||
|
||||
prev_ctx: Dict[str, Any] = {}
|
||||
for step in steps:
|
||||
log: StepLog = timed_run(step.run, self._context | prev_ctx)
|
||||
prev_ctx = log.pipe_ctx
|
||||
|
||||
playLog.substeps.append(log)
|
||||
|
||||
if log.failed:
|
||||
playLog.error.append({
|
||||
"status": "failed",
|
||||
"output": f"failed in step: {step.name}"
|
||||
})
|
||||
playLog.status = Status.BAD
|
||||
break
|
||||
|
||||
if playLog.status == Status.GOOD:
|
||||
playLog.msg = "success"
|
||||
|
||||
return playLog
|
||||
|
||||
def _get_function_from_registry(self, fn_name) -> ActionFn:
|
||||
for reg in self._registries.values():
|
||||
try:
|
||||
return reg.get(fn_name, "*")
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
raise ValueError(f"no function with name '{fn_name}' was found")
|
||||
|
||||
def _build_custom_step(self, name, actions, ctx) -> CustomStep:
|
||||
functionList: List[ActionFn] = []
|
||||
for f in actions:
|
||||
try:
|
||||
fn = self._get_function_from_registry(actions[f])
|
||||
functionList.append(fn)
|
||||
except KeyError as e:
|
||||
raise PlaybookError(f"Step '{name}' is missing required action field: {e}") from e
|
||||
|
||||
return CustomStep(name, functionList, ctx)
|
||||
|
||||
# NOTE: Move to factory
|
||||
@classmethod
|
||||
def from_yaml(cls, fp: str) -> Play:
|
||||
""" Loads the playbook from a given YAML file. """
|
||||
try:
|
||||
with open(fp, "r") as f:
|
||||
file_contents = f.read()
|
||||
except FileNotFoundError as e:
|
||||
raise PlaybookError(f"Playbook file not found: {fp}") from e
|
||||
except Exception as e:
|
||||
raise PlaybookError(f"Failed to read file {fp}: {e}") from e
|
||||
|
||||
yaml_dir = os.path.dirname(os.path.abspath(fp))
|
||||
return cls.__from_yaml_str(file_contents, base_dir=yaml_dir)
|
||||
|
||||
# NOTE: Move to factory
|
||||
@classmethod
|
||||
def __from_yaml_str_load_registries(cls, dir, data) -> Dict[str, ActionRegistry]:
|
||||
custom_registries: Dict[str, ActionRegistry] = {}
|
||||
registry_paths = data.get("registries", {})
|
||||
|
||||
custom_registries: Dict[str, ActionRegistry] = {}
|
||||
for reg_path in registry_paths:
|
||||
full_path = os.path.join(dir, reg_path) if not os.path.isabs(reg_path) else reg_path
|
||||
try:
|
||||
regs = cls._load_registry_file(full_path)
|
||||
for new_reg in regs:
|
||||
custom_registries[new_reg.name] = new_reg
|
||||
except Exception as e:
|
||||
raise PlaybookError(f"Error loading registry '{reg_path}': {e}") from e
|
||||
|
||||
return custom_registries
|
||||
|
||||
# NOTE: yes, I know, I have to refactor this
|
||||
def __from_yaml_str_load_acts(self, data):
|
||||
for idx, act in enumerate(data.get("acts", [])):
|
||||
act_name = act.get("name", f"step_{idx}")
|
||||
steps = act.get("steps", [])
|
||||
|
||||
self._acts[act_name] = []
|
||||
try:
|
||||
self.__from_yaml_str_load_steps(act_name, steps)
|
||||
except Exception as e:
|
||||
raise PlaybookError(f"error loading steps for act {act_name}") from e
|
||||
|
||||
|
||||
# NOTE: Move to factory
|
||||
def __from_yaml_str_load_steps(self, key, data):
|
||||
for step_cfg in data:
|
||||
step_name = step_cfg.get("name", f"")
|
||||
actions = step_cfg.get("actions", {})
|
||||
context = step_cfg.get("context", {})
|
||||
|
||||
if not isinstance(actions, dict) or len(actions) <= 0:
|
||||
raise PlaybookError(
|
||||
f"Step '{step_name}' (index ) must define at least one action "
|
||||
)
|
||||
|
||||
try:
|
||||
customStep: CustomStep = self._build_custom_step(step_name, actions, context)
|
||||
self._acts[key].append(customStep)
|
||||
except Exception as e:
|
||||
raise PlaybookError(f"Error configuring step '{step_name}': {e}") from e
|
||||
|
||||
# NOTE: Move to factory
|
||||
@classmethod
|
||||
def __from_yaml_str(cls, yaml_str: str, base_dir: str = ".") -> Play:
|
||||
data = None
|
||||
try:
|
||||
data = yaml.safe_load(yaml_str)
|
||||
except Exception as e:
|
||||
raise PlaybookError(f"YAML Syntax error: {e}") from e
|
||||
|
||||
if not isinstance(data, dict) or "playbook_name" not in data:
|
||||
raise PlaybookError("YAML must contain a top-level 'playbook_name' field.")
|
||||
|
||||
try:
|
||||
custom_registries = cls.__from_yaml_str_load_registries(base_dir, data)
|
||||
except Exception as e:
|
||||
raise PlaybookError(f"registries load error: {e}") from e
|
||||
|
||||
playbook = cls(data["playbook_name"], registries=custom_registries)
|
||||
try:
|
||||
playbook.__from_yaml_str_load_acts(data)
|
||||
except Exception as e:
|
||||
raise PlaybookError(f"steps load error: {e}") from e
|
||||
|
||||
playbook._context = data.get("global_context", {})
|
||||
playbook.log_dir = data.get("log_dir", "")
|
||||
|
||||
return playbook
|
||||
def run_play(self):
|
||||
return self.model.run()
|
||||
|
||||
@@ -1,49 +1,58 @@
|
||||
from dataclasses import dataclass
|
||||
from playbook.models import StepLog, ActionFn
|
||||
from typing import Callable, Dict, Any
|
||||
import os
|
||||
import sys
|
||||
import importlib.util
|
||||
from playbook.logging_models import StepLogModel
|
||||
from typing import Callable
|
||||
from pydantic import BaseModel, PrivateAttr
|
||||
|
||||
|
||||
@dataclass
|
||||
class Action(object):
|
||||
ActionFn = Callable[[dict[str, object], str], StepLogModel]
|
||||
|
||||
|
||||
class Action(BaseModel):
|
||||
name: str
|
||||
fn: ActionFn
|
||||
ver: str
|
||||
|
||||
|
||||
class ActionRegistry:
|
||||
def __init__(self, name: str):
|
||||
self.name: str = name
|
||||
self.__actions = {}
|
||||
class ActionRegistry(BaseModel):
|
||||
name: str
|
||||
_actions: dict[str, Action] = PrivateAttr(default_factory=dict)
|
||||
|
||||
def register(self, name: str, version: str):
|
||||
"""Decorator to register python functions with a name and version."""
|
||||
def decorator(function: ActionFn):
|
||||
if name not in self.__actions:
|
||||
self.__actions[name] = {}
|
||||
|
||||
self.__actions[name][version] = function
|
||||
self._actions[name] = Action(name=name, fn=function, ver=version)
|
||||
return function
|
||||
|
||||
return decorator
|
||||
|
||||
def get(self, name: str, ver: str) -> ActionFn:
|
||||
if name not in self.__actions:
|
||||
raise ValueError(f"Action '{name}' is not registered in registry '{self.name}'")
|
||||
def get(self, name: str, ver: str = '*') -> ActionFn:
|
||||
if name not in self._actions:
|
||||
raise ValueError(
|
||||
f"Action '{name}' is not registered in registry '{self.name}'")
|
||||
|
||||
versions_dict = self.__actions[name]
|
||||
if not versions_dict:
|
||||
raise ValueError(f"No registered versions found for function '{name}'")
|
||||
return self._actions[name].fn
|
||||
|
||||
if ver == "*":
|
||||
return next(iter(versions_dict.values()))
|
||||
@staticmethod
|
||||
def load_registries_from_file(file_path: str) -> list[ActionRegistry]:
|
||||
abs_path = os.path.abspath(file_path)
|
||||
module_name = os.path.splitext(os.path.basename(abs_path))[0]
|
||||
|
||||
if ver not in self.__actions[name]:
|
||||
raise ValueError(f"Version '{ver}' is not registered for function '{name}'")
|
||||
spec = importlib.util.spec_from_file_location(module_name, abs_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(
|
||||
f"could not create spec for registry file: {file_path}")
|
||||
|
||||
return versions_dict[ver]
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
def manifest(self):
|
||||
functions = {}
|
||||
for fn_name, versions_dict in self.__actions.items():
|
||||
functions[fn_name] = list(versions_dict.keys())
|
||||
return {"registry_name": self.name, "functions": functions}
|
||||
discovered_registries = [
|
||||
obj for obj in vars(module).values()
|
||||
if isinstance(obj, ActionRegistry)
|
||||
]
|
||||
|
||||
if not discovered_registries:
|
||||
raise ValueError(f"No ActionRegistry instances were found in '{file_path}'")
|
||||
|
||||
return discovered_registries
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from enum import StrEnum
|
||||
from dataclasses import field
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Status(StrEnum):
|
||||
GOOD = "GOOD"
|
||||
BAD = "BAD"
|
||||
|
||||
|
||||
class LogTiming(BaseModel):
|
||||
start_date_timestamp: int = 0
|
||||
start_date: str = ""
|
||||
end_date: str = ""
|
||||
duration_sec: float = 0.0
|
||||
|
||||
|
||||
class StepLogModel(BaseModel):
|
||||
name: str
|
||||
timing: LogTiming = field(default=LogTiming())
|
||||
status: Status
|
||||
error: str = ""
|
||||
msg: str = ""
|
||||
pipe_ctx: dict[str, object] = field(default_factory=dict)
|
||||
log_file_path: str = "" # If set we have logged this particular step to a file
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
""" Checks if the step failed """
|
||||
return len(self.error) > 0 or self.status == Status.BAD
|
||||
|
||||
@classmethod
|
||||
def ok(cls, name: str, msg: str = "success", pipe_ctx: dict[str, object] = {}) -> StepLogModel:
|
||||
return cls(name=name, status=Status.GOOD, msg=msg, pipe_ctx=pipe_ctx)
|
||||
|
||||
@classmethod
|
||||
def fail(cls, name: str, err: str) -> StepLogModel:
|
||||
return cls(name=name, status=Status.BAD, error=err)
|
||||
+109
-43
@@ -1,59 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import yaml
|
||||
import time
|
||||
import functools
|
||||
from enum import StrEnum
|
||||
from typing import List, Dict, Any, Callable
|
||||
from typing import Callable
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import dataclass, field
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from playbook.action_registry import ActionRegistry, ActionFn
|
||||
from playbook.logging_models import StepLogModel
|
||||
|
||||
|
||||
class Status(StrEnum):
|
||||
GOOD = "GOOD"
|
||||
BAD = "BAD"
|
||||
class StepModel(BaseModel):
|
||||
name: str
|
||||
_actions: list[ActionFn]
|
||||
_context: dict[str, object]
|
||||
|
||||
def get_action_names(self) -> list[str]:
|
||||
return [action.__name__ for action in self._actions]
|
||||
|
||||
# NOTE: refactor this method to be more readable
|
||||
def run(self, ctx) -> StepLogModel:
|
||||
""" Run the step. """
|
||||
return StepLogModel.ok(self.name, msg="success")
|
||||
# substeps: list[StepLogModel] = []
|
||||
# status: Status = Status.GOOD
|
||||
#
|
||||
# # NOTE:
|
||||
# # Use this to move context from the previous step to the next step
|
||||
# # This is wrong, we are moving away from having a list of steps here to a single
|
||||
# # step for each operation
|
||||
# prev_step_ctx: dict[str, object] = {}
|
||||
#
|
||||
# for op in self.__actions:
|
||||
# try:
|
||||
# log: StepLogModel = timed_run(
|
||||
# op, ctx | prev_step_ctx | self.__context, self.name)
|
||||
# except Exception as e:
|
||||
# log: StepLogModel = StepLogModel.fail(
|
||||
# self.name, [{"status": "failed", "output": str(e)}])
|
||||
#
|
||||
# status = Status.BAD if log.failed else Status.GOOD
|
||||
# prev_step_ctx = log.pipe_ctx
|
||||
#
|
||||
# substeps.append(log)
|
||||
#
|
||||
# if status == Status.BAD:
|
||||
# break
|
||||
#
|
||||
# errors = []
|
||||
# msg: str = ""
|
||||
# if status == Status.BAD:
|
||||
# # Note, use this to set the standar error output/formatting
|
||||
# # errors = [{"status": "failed", "output": ""}]
|
||||
# errors = []
|
||||
# else:
|
||||
# msg = "success"
|
||||
#
|
||||
# return StepLogModel(
|
||||
# step_name=self.name, status=status,
|
||||
# msg=msg, error=errors, substeps=substeps, pipe_ctx=prev_step_ctx
|
||||
# )
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepLog(object):
|
||||
step_name: str
|
||||
status: Status = Status.BAD # By default set it to bad so the it fails by default
|
||||
# The user needs to be explicit
|
||||
cmd: str = ""
|
||||
start_date_timestamp: int = 0
|
||||
start_date: str = ""
|
||||
end_date: str = ""
|
||||
duration_sec: float = 0.0
|
||||
human_readable_duration: str = ""
|
||||
msg: str = ""
|
||||
error: List[Dict[Any, Any]] = field(default_factory=list)
|
||||
substeps: List[StepLog] = field(default_factory=list)
|
||||
log_file_path: str = ""
|
||||
pipe_ctx: Dict[str, Any] = field(default_factory=dict)
|
||||
class ActModel(BaseModel):
|
||||
name: str
|
||||
steps: list[StepModel]
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
""" Checks if the step failed """
|
||||
return len(self.error) > 0 or self.status == Status.BAD
|
||||
# NOTE: this shouldnt return a steplogmodel but an actlogmodel or something like that
|
||||
def run(self, ctx) -> StepLogModel:
|
||||
return StepLogModel.ok(self.name, msg="success")
|
||||
|
||||
|
||||
class PlaybookModel(BaseModel):
|
||||
playbook_name: str
|
||||
log_dir: str
|
||||
registries: list[ActionRegistry]
|
||||
global_context: dict[str, str]
|
||||
acts: list[ActModel]
|
||||
|
||||
@field_validator("registries", mode="before")
|
||||
@classmethod
|
||||
def parse_registries(cls, v: object) -> list[ActionRegistry]:
|
||||
result = []
|
||||
if isinstance(v, list):
|
||||
for item in v:
|
||||
if isinstance(item, str):
|
||||
try:
|
||||
registries: list[ActionRegistry] = \
|
||||
ActionRegistry.load_registries_from_file(item)
|
||||
result.extend(registries)
|
||||
except Exception as e:
|
||||
raise e
|
||||
else:
|
||||
raise ValueError(f"Invalid registry type: {type(item)}")
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def ok(cls, name: str, msg: str = "success", pipe_ctx: Dict[str, Any] = {}) -> StepLog:
|
||||
return cls(step_name=name, status=Status.GOOD, msg=msg, pipe_ctx=pipe_ctx)
|
||||
def from_yaml_file(cls, fp: str):
|
||||
with open(fp, "rb") as f:
|
||||
data = yaml.safe_load(f)
|
||||
return cls(**data)
|
||||
|
||||
@classmethod
|
||||
def fail(cls, name: str, errors: List[Any]) -> StepLog:
|
||||
return cls(step_name=name, status=Status.BAD, error=errors)
|
||||
def _run(self) -> StepLogModel:
|
||||
for act in self.acts:
|
||||
act.run(self.global_context)
|
||||
|
||||
return StepLogModel.ok(name="somasjd", msg="ajshdsajhd")
|
||||
|
||||
def run(self) -> StepLogModel:
|
||||
return timed_run(self._run)
|
||||
|
||||
|
||||
class ContextChecker:
|
||||
"""Safely extract values with error messages."""
|
||||
def __init__(self, ctx: Dict[str, Any]):
|
||||
|
||||
def __init__(self, ctx: dict[str, object]):
|
||||
self._ctx = ctx
|
||||
|
||||
@classmethod
|
||||
def requires(cls, *args):
|
||||
def decorator(func: ActionFn):
|
||||
@functools.wraps(func)
|
||||
def wrapper(ctx: Dict[str, Any], name: str) -> StepLog:
|
||||
def wrapper(ctx: dict[str, object], name: str) -> StepLogModel:
|
||||
if all(key in ctx for key in args):
|
||||
return func(ctx, name)
|
||||
else:
|
||||
@@ -73,24 +142,21 @@ def human_readable_date(time: int) -> str:
|
||||
return dt.strftime(f"%Y-%m-%dT%H:%M:%S.{nanos:09d}%:z")
|
||||
|
||||
|
||||
def timed_run(op: Callable[..., StepLog], *args: Any, **kwargs: Any) -> StepLog:
|
||||
def timed_run(op: Callable[..., StepLogModel], *args: object, **kwargs: object) -> StepLogModel:
|
||||
start_date: int = time.time_ns()
|
||||
start_time: int = time.perf_counter_ns()
|
||||
|
||||
try:
|
||||
log: StepLog = op(*args, **kwargs)
|
||||
log: StepLogModel = op(*args, **kwargs)
|
||||
finally:
|
||||
delta = time.perf_counter_ns() - start_time
|
||||
|
||||
# NOTE: we could simplyfy this by having a marshalling method
|
||||
if isinstance(log, StepLog):
|
||||
if isinstance(log, StepLogModel):
|
||||
end_date: int = time.time_ns()
|
||||
log.duration_sec = delta / 1_000_000_000.0
|
||||
log.start_date = human_readable_date(start_date)
|
||||
log.start_date_timestamp = start_date
|
||||
log.end_date = human_readable_date(end_date)
|
||||
log.timing.duration_sec = delta / 1_000_000_000.0
|
||||
log.timing.start_date = human_readable_date(start_date)
|
||||
log.timing.start_date_timestamp = start_date
|
||||
log.timing.end_date = human_readable_date(end_date)
|
||||
|
||||
return log
|
||||
|
||||
|
||||
ActionFn = Callable[[Dict[str, Any], str], StepLog]
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# In this file we will only put premade steps
|
||||
|
||||
from playbook.models import StepLog, Status
|
||||
from playbook.action_registry import ActionRegistry
|
||||
|
||||
|
||||
# Module wide registry with built in functions
|
||||
registry: ActionRegistry = ActionRegistry("testrunner_registry")
|
||||
registry: ActionRegistry = ActionRegistry(name="testrunner_registry")
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from playbook import ActionRegistrar, ActionRegistrarYAMLReader
|
||||
from playbook.action_registry import ActionRegistry
|
||||
|
||||
|
||||
CDIR = os.path.dirname(__file__)
|
||||
TEST_DATA = os.path.join(CDIR, "test_data")
|
||||
|
||||
|
||||
class TestActionRegistrar(unittest.TestCase):
|
||||
@patch.object(ActionRegistrarYAMLReader, '_load_registries_from_file')
|
||||
def test_reading_action_registry(self, mock_load):
|
||||
mock_reg1 = ActionRegistry("docker_reg")
|
||||
mock_reg2 = ActionRegistry("restic_reg")
|
||||
mock_load.side_effect = [[mock_reg1], [mock_reg2]]
|
||||
|
||||
yaml_data = {
|
||||
"registries": ["docker.py", "restic.py"]
|
||||
}
|
||||
|
||||
expected_size = 2
|
||||
expected_manifest = {
|
||||
"registrar_manifest": [
|
||||
{"registry_name": "docker_reg", "functions": {}},
|
||||
{"registry_name": "restic_reg", "functions": {}}
|
||||
]
|
||||
}
|
||||
|
||||
reg: ActionRegistrar = ActionRegistrarYAMLReader.read(yaml_data)
|
||||
|
||||
self.assertEqual(expected_manifest, reg.manifest())
|
||||
self.assertEqual(expected_size, reg.size())
|
||||
|
||||
pass
|
||||
|
||||
# def test_creating_action_registry(self):
|
||||
# yaml_data = {
|
||||
# "registries": ["docker.py", "restic.py"]
|
||||
# }
|
||||
#
|
||||
# expected_size = 2
|
||||
# expected_manifest = {
|
||||
# "registrar_manifest": []
|
||||
# }
|
||||
#
|
||||
# reg: ActionRegistrar = ActionRegistrarYAMLReader.read(
|
||||
# yaml_data, base_dir=os.path.join(TEST_DATA, "registries")
|
||||
# )
|
||||
#
|
||||
# self.assertEqual(expected_manifest, reg.manifest())
|
||||
# self.assertEqual(expected_size, reg.size())
|
||||
@@ -0,0 +1,65 @@
|
||||
from os import error
|
||||
|
||||
from yaml import dump
|
||||
|
||||
from playbook import models
|
||||
from playbook.action_registry import ActionRegistry
|
||||
from playbook.models import StepLog, ContextChecker
|
||||
|
||||
import sys
|
||||
import json
|
||||
import docker
|
||||
import subprocess
|
||||
from typing import List, Dict, Any, Tuple
|
||||
# from docker.models.containers import Container
|
||||
|
||||
|
||||
dockeractions = ActionRegistry("docker_actions_reg")
|
||||
|
||||
|
||||
@dockeractions.register(name="get_service_container_name", version="")
|
||||
@ContextChecker.requires("service_name")
|
||||
def get_service_container_name(ctx, name) -> StepLog:
|
||||
client = docker.from_env()
|
||||
|
||||
filters: Dict[str, Any]= {
|
||||
"label": [f"com.docker.compose.service={ctx["service_name"]}"]
|
||||
}
|
||||
containers = client.containers.list(filters=filters, all=True)
|
||||
|
||||
if not containers:
|
||||
return StepLog.fail(name, [{
|
||||
"status": "failed",
|
||||
"output": f"no container found for service {ctx["service_name"]}"}
|
||||
])
|
||||
|
||||
new_data = {"container": containers[0].id}
|
||||
return StepLog.ok(name, f"found container {containers[0].id}", pipe_ctx=new_data)
|
||||
|
||||
|
||||
@dockeractions.register(name="dump_container_pg_db", version="")
|
||||
@ContextChecker.requires("container", "dump_path", "db_user", "database")
|
||||
def dump_container_pg_db(ctx, name) -> StepLog:
|
||||
client = docker.from_env()
|
||||
container = client.containers.get(ctx["container"])
|
||||
|
||||
res = container.exec_run(
|
||||
cmd=f"pg_dump -U {ctx["db_user"]} {ctx["database"]}",
|
||||
stream=False, # change to True to stream directly to host file (need to check)
|
||||
demux=True,
|
||||
)
|
||||
stdout, stderr = res.output
|
||||
|
||||
if stderr:
|
||||
if not isinstance(stderr, bytes):
|
||||
return StepLog.fail(name, [{"status": "failed", "output": "stderr is not bytes"}])
|
||||
if res.exit_code != 0:
|
||||
error_msg = stderr.decode("utf-8") if stderr else "dump failed with no output"
|
||||
return StepLog.fail(name, [{"status": "failed", "output": error_msg}])
|
||||
|
||||
if not isinstance(stdout, bytes):
|
||||
return StepLog.fail(name, [{"status": "failed", "output": "stdout is not bytes"}])
|
||||
with open(ctx["dump_path"], "wb") as f:
|
||||
f.write(stdout)
|
||||
|
||||
return StepLog.ok(name, "database dump successful")
|
||||
@@ -0,0 +1,167 @@
|
||||
# restic actions registry
|
||||
from playbook.action_registry import ActionRegistry
|
||||
from playbook.models import ContextChecker, StepLog
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
from typing import List, Dict, Any, Tuple
|
||||
|
||||
|
||||
# NOTE:
|
||||
# most restic run commands seem to be the same pattern of:
|
||||
# restic command -> parse output -> grab errors
|
||||
# we can create a class for this I reckon
|
||||
|
||||
|
||||
restic = ActionRegistry("restic_actions_reg")
|
||||
|
||||
|
||||
def parse_output(line: str) -> List[Dict[str, Any]]:
|
||||
errors = []
|
||||
for line in line.split('\n'):
|
||||
if line.startswith("{"): # } treesitter is borked lmao
|
||||
data = json.loads(line)
|
||||
errors.append(data)
|
||||
return errors
|
||||
|
||||
|
||||
def find_restic_errors(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
errors = []
|
||||
for s in data:
|
||||
if s["message_type"] == "exit_error":
|
||||
errors.append(s)
|
||||
return errors
|
||||
|
||||
|
||||
def run_restic_command(cmd: List[str]) -> Tuple[int, List[Dict[str, Any]]]:
|
||||
"""
|
||||
Executes a restic command, streams output live to terminal,
|
||||
and returns (returncode, parsed_json_objects).
|
||||
"""
|
||||
parsed_json = []
|
||||
|
||||
# Start process with combined stdout/stderr pipe
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
# NOTE: is this what we want?
|
||||
assert process.stdout is not None
|
||||
|
||||
# Read line-by-line in real-time
|
||||
for line in iter(process.stdout.readline, ''):
|
||||
# Write live output directly to terminal screen
|
||||
sys.stdout.write(line)
|
||||
sys.stdout.flush()
|
||||
|
||||
# Parse and capture valid JSON objects on the fly
|
||||
clean_line = line.strip()
|
||||
if clean_line.startswith('{'): # }
|
||||
try:
|
||||
data = json.loads(clean_line)
|
||||
parsed_json.append(data)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
process.stdout.close()
|
||||
returncode = process.wait()
|
||||
return returncode, parsed_json
|
||||
|
||||
|
||||
@restic.register(name="check_restic_environment", version="")
|
||||
def check_restic_environment(ctx, name: str = "") -> StepLog:
|
||||
for v in ["passwordFile", "repoPath", "sourcePath", "serviceTag"]:
|
||||
if not ctx.get(v):
|
||||
return StepLog.fail(name, [f"{v} variable is empty"])
|
||||
|
||||
return StepLog.ok(name, "environment configuration seems to be valid")
|
||||
|
||||
|
||||
@restic.register(name="create_restic_repo", version="")
|
||||
def create_restic_repo(ctx, name: str = "") -> StepLog:
|
||||
cmd = ["restic", "init", "--json", "-r", ctx["repoPath"],
|
||||
"--password-file", ctx["passwordFile"]]
|
||||
|
||||
returncode, json_output = run_restic_command(cmd)
|
||||
|
||||
errors: List[Dict[str, Any]] = find_restic_errors(json_output)
|
||||
|
||||
# Handle error messages in JSON output
|
||||
if len(errors) > 0:
|
||||
return StepLog.fail(name, errors)
|
||||
|
||||
# Make sure the return code is good too
|
||||
if returncode != 0:
|
||||
return StepLog.fail(name, [{"status": "failed", "output": json_output}])
|
||||
|
||||
return StepLog.ok(name, "repo create succesfully")
|
||||
|
||||
|
||||
@restic.register(name="create_repo_if_not_exists", version="")
|
||||
def create_repo_if_not_exists(ctx, name: str = "") -> StepLog:
|
||||
log: StepLog = check_if_repo_exists(ctx, "")
|
||||
|
||||
msg: str = ""
|
||||
if log.failed:
|
||||
# Repo doesn't exist is error code 10
|
||||
if log.error[0]["code"] != 10:
|
||||
return StepLog.fail(name, [{"status": "failed", "output": log.error}])
|
||||
|
||||
# Create repo
|
||||
repoCreateLog: StepLog = create_restic_repo(ctx, name + f".{create_restic_repo.__name__}")
|
||||
if repoCreateLog.failed:
|
||||
return StepLog.fail(name, [{"status": "failed", "output": repoCreateLog.error}])
|
||||
msg = "repo created"
|
||||
else:
|
||||
msg = "repo already exists"
|
||||
|
||||
|
||||
return StepLog.ok(name, msg)
|
||||
|
||||
|
||||
@restic.register(name="check_if_repo_exists", version="")
|
||||
def check_if_repo_exists(ctx, name: str = "") -> StepLog:
|
||||
cmd = ["restic", "-r", ctx["repoPath"], "cat", "config", "--json",
|
||||
"--password-file", ctx["passwordFile"]]
|
||||
returncode, json_output = run_restic_command(cmd)
|
||||
|
||||
errors: List[Dict[str, Any]] = find_restic_errors(json_output)
|
||||
|
||||
# Handle error messages in JSON output
|
||||
if len(errors) > 0:
|
||||
return StepLog.fail(name, errors)
|
||||
|
||||
# Make sure the return code is good too
|
||||
if returncode != 0:
|
||||
return StepLog.fail(name, [{"status": "failed", "output": json_output}])
|
||||
|
||||
return StepLog.ok(name, "repo seems to exist")
|
||||
|
||||
|
||||
@restic.register(name="backup_data_to_restic_repo", version="")
|
||||
@ContextChecker.requires("source_path", "passwordFile")
|
||||
def backup_data_to_restic_repo(ctx, name) -> StepLog:
|
||||
cmd = ["restic", "backup", ctx["source_path"], "--json", "--quiet",
|
||||
"-r", ctx["repoPath"], "--password-file", ctx["passwordFile"]]
|
||||
if ctx["tags"]:
|
||||
cmd.extend(["--tag", ','.join(ctx["tags"])])
|
||||
|
||||
returncode, json_output = run_restic_command(cmd)
|
||||
print(json_output, file=sys.stderr)
|
||||
errors: List[Dict[str, Any]] = find_restic_errors(json_output)
|
||||
|
||||
# Handle error messages in JSON output
|
||||
if len(errors) > 0:
|
||||
return StepLog.fail(name, errors)
|
||||
|
||||
# Make sure the return code is good too
|
||||
if returncode != 0:
|
||||
return StepLog.fail(name, [{"status": "failed", "output": errors}])
|
||||
|
||||
return StepLog.ok(name, "backup successful")
|
||||
@@ -7,6 +7,7 @@ requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"docker>=7.2.0",
|
||||
"pyaml>=26.7.0",
|
||||
"pydantic>=2.13.4",
|
||||
"pytest>=9.1.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# restic actions registry
|
||||
from playbook.action_registry import ActionRegistry
|
||||
from playbook.models import StepLog
|
||||
from playbook.logging_models import StepLogModel
|
||||
from playbook.models import ContextChecker
|
||||
|
||||
from registry.docker_actions import dockeractions
|
||||
@@ -9,14 +9,13 @@ import sys
|
||||
import json
|
||||
import docker
|
||||
import subprocess
|
||||
from typing import List, Dict, Any, Tuple
|
||||
|
||||
|
||||
dbpgactions = ActionRegistry("dbpg_actions_reg")
|
||||
dbpgactions = ActionRegistry(name="dbpg_actions_reg")
|
||||
|
||||
|
||||
@dbpgactions.register(name="dump_pg_database", version="")
|
||||
@ContextChecker.requires("database", "compose")
|
||||
def dump_pg_database_from_container(ctx, name) -> StepLog:
|
||||
def dump_pg_database_from_container(ctx, name) -> StepLogModel:
|
||||
print(ctx, file=sys.stderr)
|
||||
return StepLog.ok(name, "", pipe_ctx={"new_data": "coolData"})
|
||||
return StepLogModel.ok(name, "", pipe_ctx={"new_data": "coolData"})
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
# directory actions registry
|
||||
from playbook.action_registry import ActionRegistry
|
||||
from playbook.models import StepLog
|
||||
from playbook.logging_models import StepLogModel
|
||||
|
||||
import os
|
||||
|
||||
|
||||
diract = ActionRegistry("directory_actions_reg")
|
||||
diract = ActionRegistry(name="directory_actions_reg")
|
||||
|
||||
|
||||
@diract.register(name="directory_exists", version="stat1.0")
|
||||
def directory_exists(ctx, name) -> StepLog:
|
||||
def directory_exists(ctx, name) -> StepLogModel:
|
||||
if not os.path.exists(ctx["repoPath"]):
|
||||
return StepLog.fail(name, [
|
||||
return StepLogModel.fail(name, [
|
||||
{"status": "failed", "output": f"dir '{ctx["repoPath"]}' doesn't exist"}]
|
||||
)
|
||||
return StepLog.ok(name, "directory exists")
|
||||
return StepLogModel.ok(name, "directory exists")
|
||||
|
||||
|
||||
@diract.register(name="create_dir", version="stat1.0")
|
||||
def create_dir(ctx, name) -> StepLog:
|
||||
def create_dir(ctx, name) -> StepLogModel:
|
||||
try:
|
||||
os.makedirs(ctx["repoPath"])
|
||||
except Exception as e:
|
||||
return StepLog.fail(name, [{"status": "failed", "output": str(e)}])
|
||||
return StepLogModel.fail(name, [{"status": "failed", "output": str(e)}])
|
||||
|
||||
return StepLog.ok(name, "dir create successfully")
|
||||
return StepLogModel.ok(name, "dir create successfully")
|
||||
|
||||
|
||||
@diract.register(name="create_dir_if_not_exists", version="stat1.0")
|
||||
def create_dir_if_not_exists(ctx, name) -> StepLog:
|
||||
log: StepLog = directory_exists(ctx, name + f".{directory_exists.__name__}")
|
||||
def create_dir_if_not_exists(ctx, name) -> StepLogModel:
|
||||
log: StepLogModel = directory_exists(ctx, name + f".{directory_exists.__name__}")
|
||||
msg: str = ""
|
||||
if log.failed:
|
||||
dir_creation_log: StepLog = create_dir(ctx, name + f".{create_dir.__name__}")
|
||||
dir_creation_log: StepLogModel = create_dir(ctx, name + f".{create_dir.__name__}")
|
||||
if dir_creation_log.failed:
|
||||
return StepLog.fail(name, [{"status": "failed", "output": dir_creation_log}])
|
||||
return StepLogModel.fail(name, [{"status": "failed", "output": dir_creation_log}])
|
||||
msg = "dir created succesfully"
|
||||
else:
|
||||
msg = "dir already exists"
|
||||
|
||||
return StepLog.ok(name, msg)
|
||||
return StepLogModel.ok(name, msg)
|
||||
|
||||
+12
-13
@@ -4,42 +4,41 @@ from yaml import dump
|
||||
|
||||
from playbook import models
|
||||
from playbook.action_registry import ActionRegistry
|
||||
from playbook.models import StepLog, ContextChecker
|
||||
from playbook.models import ContextChecker
|
||||
from playbook.logging_models import StepLogModel
|
||||
|
||||
import sys
|
||||
import json
|
||||
import docker
|
||||
import subprocess
|
||||
from typing import List, Dict, Any, Tuple
|
||||
# from docker.models.containers import Container
|
||||
|
||||
|
||||
dockeractions = ActionRegistry("docker_actions_reg")
|
||||
dockeractions = ActionRegistry(name="docker_actions_reg")
|
||||
|
||||
|
||||
@dockeractions.register(name="get_service_container_name", version="")
|
||||
@ContextChecker.requires("service_name")
|
||||
def get_service_container_name(ctx, name) -> StepLog:
|
||||
def get_service_container_name(ctx, name) -> StepLogModel:
|
||||
client = docker.from_env()
|
||||
|
||||
filters: Dict[str, Any]= {
|
||||
filters: dict[str, object]= {
|
||||
"label": [f"com.docker.compose.service={ctx["service_name"]}"]
|
||||
}
|
||||
containers = client.containers.list(filters=filters, all=True)
|
||||
|
||||
if not containers:
|
||||
return StepLog.fail(name, [{
|
||||
return StepLogModel.fail(name, [{
|
||||
"status": "failed",
|
||||
"output": f"no container found for service {ctx["service_name"]}"}
|
||||
])
|
||||
|
||||
new_data = {"container": containers[0].id}
|
||||
return StepLog.ok(name, f"found container {containers[0].id}", pipe_ctx=new_data)
|
||||
return StepLogModel.ok(name, f"found container {containers[0].id}", pipe_ctx=new_data)
|
||||
|
||||
|
||||
@dockeractions.register(name="dump_container_pg_db", version="")
|
||||
@ContextChecker.requires("container", "dump_path", "db_user", "database")
|
||||
def dump_container_pg_db(ctx, name) -> StepLog:
|
||||
def dump_container_pg_db(ctx, name) -> StepLogModel:
|
||||
client = docker.from_env()
|
||||
container = client.containers.get(ctx["container"])
|
||||
|
||||
@@ -52,14 +51,14 @@ def dump_container_pg_db(ctx, name) -> StepLog:
|
||||
|
||||
if stderr:
|
||||
if not isinstance(stderr, bytes):
|
||||
return StepLog.fail(name, [{"status": "failed", "output": "stderr is not bytes"}])
|
||||
return StepLogModel.fail(name, [{"status": "failed", "output": "stderr is not bytes"}])
|
||||
if res.exit_code != 0:
|
||||
error_msg = stderr.decode("utf-8") if stderr else "dump failed with no output"
|
||||
return StepLog.fail(name, [{"status": "failed", "output": error_msg}])
|
||||
return StepLogModel.fail(name, [{"status": "failed", "output": error_msg}])
|
||||
|
||||
if not isinstance(stdout, bytes):
|
||||
return StepLog.fail(name, [{"status": "failed", "output": "stdout is not bytes"}])
|
||||
return StepLogModel.fail(name, [{"status": "failed", "output": "stdout is not bytes"}])
|
||||
with open(ctx["dump_path"], "wb") as f:
|
||||
f.write(stdout)
|
||||
|
||||
return StepLog.ok(name, "database dump successful")
|
||||
return StepLogModel.ok(name, "database dump successful")
|
||||
|
||||
+30
-30
@@ -1,11 +1,11 @@
|
||||
# restic actions registry
|
||||
from playbook.action_registry import ActionRegistry
|
||||
from playbook.models import ContextChecker, StepLog
|
||||
from playbook.models import ContextChecker
|
||||
from playbook.logging_models import StepLogModel
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
from typing import List, Dict, Any, Tuple
|
||||
|
||||
|
||||
# NOTE:
|
||||
@@ -14,10 +14,10 @@ from typing import List, Dict, Any, Tuple
|
||||
# we can create a class for this I reckon
|
||||
|
||||
|
||||
restic = ActionRegistry("restic_actions_reg")
|
||||
restic = ActionRegistry(name="restic_actions_reg")
|
||||
|
||||
|
||||
def parse_output(line: str) -> List[Dict[str, Any]]:
|
||||
def parse_output(line: str) -> list[dict[str, object]]:
|
||||
errors = []
|
||||
for line in line.split('\n'):
|
||||
if line.startswith("{"): # } treesitter is borked lmao
|
||||
@@ -26,7 +26,7 @@ def parse_output(line: str) -> List[Dict[str, Any]]:
|
||||
return errors
|
||||
|
||||
|
||||
def find_restic_errors(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def find_restic_errors(data: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
errors = []
|
||||
for s in data:
|
||||
if s["message_type"] == "exit_error":
|
||||
@@ -34,7 +34,7 @@ def find_restic_errors(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
return errors
|
||||
|
||||
|
||||
def run_restic_command(cmd: List[str]) -> Tuple[int, List[Dict[str, Any]]]:
|
||||
def run_restic_command(cmd: list[str]) -> tuple[int, list[dict[str, object]]]:
|
||||
"""
|
||||
Executes a restic command, streams output live to terminal,
|
||||
and returns (returncode, parsed_json_objects).
|
||||
@@ -75,78 +75,78 @@ def run_restic_command(cmd: List[str]) -> Tuple[int, List[Dict[str, Any]]]:
|
||||
|
||||
|
||||
@restic.register(name="check_restic_environment", version="")
|
||||
def check_restic_environment(ctx, name: str = "") -> StepLog:
|
||||
def check_restic_environment(ctx, name: str = "") -> StepLogModel:
|
||||
for v in ["passwordFile", "repoPath", "sourcePath", "serviceTag"]:
|
||||
if not ctx.get(v):
|
||||
return StepLog.fail(name, [f"{v} variable is empty"])
|
||||
return StepLogModel.fail(name, [f"{v} variable is empty"])
|
||||
|
||||
return StepLog.ok(name, "environment configuration seems to be valid")
|
||||
return StepLogModel.ok(name, "environment configuration seems to be valid")
|
||||
|
||||
|
||||
@restic.register(name="create_restic_repo", version="")
|
||||
def create_restic_repo(ctx, name: str = "") -> StepLog:
|
||||
def create_restic_repo(ctx, name: str = "") -> StepLogModel:
|
||||
cmd = ["restic", "init", "--json", "-r", ctx["repoPath"],
|
||||
"--password-file", ctx["passwordFile"]]
|
||||
|
||||
returncode, json_output = run_restic_command(cmd)
|
||||
|
||||
errors: List[Dict[str, Any]] = find_restic_errors(json_output)
|
||||
errors: list[dict[str, object]] = find_restic_errors(json_output)
|
||||
|
||||
# Handle error messages in JSON output
|
||||
if len(errors) > 0:
|
||||
return StepLog.fail(name, errors)
|
||||
return StepLogModel.fail(name, errors)
|
||||
|
||||
# Make sure the return code is good too
|
||||
if returncode != 0:
|
||||
return StepLog.fail(name, [{"status": "failed", "output": json_output}])
|
||||
return StepLogModel.fail(name, [{"status": "failed", "output": json_output}])
|
||||
|
||||
return StepLog.ok(name, "repo create succesfully")
|
||||
return StepLogModel.ok(name, "repo create succesfully")
|
||||
|
||||
|
||||
@restic.register(name="create_repo_if_not_exists", version="")
|
||||
def create_repo_if_not_exists(ctx, name: str = "") -> StepLog:
|
||||
log: StepLog = check_if_repo_exists(ctx, "")
|
||||
def create_repo_if_not_exists(ctx, name: str = "") -> StepLogModel:
|
||||
log: StepLogModel = check_if_repo_exists(ctx, "")
|
||||
|
||||
msg: str = ""
|
||||
if log.failed:
|
||||
# Repo doesn't exist is error code 10
|
||||
if log.error[0]["code"] != 10:
|
||||
return StepLog.fail(name, [{"status": "failed", "output": log.error}])
|
||||
return StepLogModel.fail(name, [{"status": "failed", "output": log.error}])
|
||||
|
||||
# Create repo
|
||||
repoCreateLog: StepLog = create_restic_repo(ctx, name + f".{create_restic_repo.__name__}")
|
||||
repoCreateLog: StepLogModel = create_restic_repo(ctx, name + f".{create_restic_repo.__name__}")
|
||||
if repoCreateLog.failed:
|
||||
return StepLog.fail(name, [{"status": "failed", "output": repoCreateLog.error}])
|
||||
return StepLogModel.fail(name, [{"status": "failed", "output": repoCreateLog.error}])
|
||||
msg = "repo created"
|
||||
else:
|
||||
msg = "repo already exists"
|
||||
|
||||
|
||||
return StepLog.ok(name, msg)
|
||||
return StepLogModel.ok(name, msg)
|
||||
|
||||
|
||||
@restic.register(name="check_if_repo_exists", version="")
|
||||
def check_if_repo_exists(ctx, name: str = "") -> StepLog:
|
||||
def check_if_repo_exists(ctx, name: str = "") -> StepLogModel:
|
||||
cmd = ["restic", "-r", ctx["repoPath"], "cat", "config", "--json",
|
||||
"--password-file", ctx["passwordFile"]]
|
||||
returncode, json_output = run_restic_command(cmd)
|
||||
|
||||
errors: List[Dict[str, Any]] = find_restic_errors(json_output)
|
||||
errors: list[dict[str, object]] = find_restic_errors(json_output)
|
||||
|
||||
# Handle error messages in JSON output
|
||||
if len(errors) > 0:
|
||||
return StepLog.fail(name, errors)
|
||||
return StepLogModel.fail(name, errors)
|
||||
|
||||
# Make sure the return code is good too
|
||||
if returncode != 0:
|
||||
return StepLog.fail(name, [{"status": "failed", "output": json_output}])
|
||||
return StepLogModel.fail(name, [{"status": "failed", "output": json_output}])
|
||||
|
||||
return StepLog.ok(name, "repo seems to exist")
|
||||
return StepLogModel.ok(name, "repo seems to exist")
|
||||
|
||||
|
||||
@restic.register(name="backup_data_to_restic_repo", version="")
|
||||
@ContextChecker.requires("source_path", "passwordFile")
|
||||
def backup_data_to_restic_repo(ctx, name) -> StepLog:
|
||||
def backup_data_to_restic_repo(ctx, name) -> StepLogModel:
|
||||
cmd = ["restic", "backup", ctx["source_path"], "--json", "--quiet",
|
||||
"-r", ctx["repoPath"], "--password-file", ctx["passwordFile"]]
|
||||
if ctx["tags"]:
|
||||
@@ -154,14 +154,14 @@ def backup_data_to_restic_repo(ctx, name) -> StepLog:
|
||||
|
||||
returncode, json_output = run_restic_command(cmd)
|
||||
print(json_output, file=sys.stderr)
|
||||
errors: List[Dict[str, Any]] = find_restic_errors(json_output)
|
||||
errors: list[dict[str, object]] = find_restic_errors(json_output)
|
||||
|
||||
# Handle error messages in JSON output
|
||||
if len(errors) > 0:
|
||||
return StepLog.fail(name, errors)
|
||||
return StepLogModel.fail(name, errors)
|
||||
|
||||
# Make sure the return code is good too
|
||||
if returncode != 0:
|
||||
return StepLog.fail(name, [{"status": "failed", "output": errors}])
|
||||
return StepLogModel.fail(name, [{"status": "failed", "output": errors}])
|
||||
|
||||
return StepLog.ok(name, "backup successful")
|
||||
return StepLogModel.ok(name, "backup successful")
|
||||
|
||||
+8
-10
@@ -1,18 +1,14 @@
|
||||
import json
|
||||
import argparse
|
||||
import traceback
|
||||
from typing import List
|
||||
from playbook import Play
|
||||
from dataclasses import asdict
|
||||
|
||||
from playbook.action_registry import ActionRegistry
|
||||
from playbook import Playbook
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Playbook Taskrunner")
|
||||
|
||||
# Flag for the playbook file
|
||||
parser.add_argument(
|
||||
_ = parser.add_argument(
|
||||
"-p", "--playbook",
|
||||
type=str,
|
||||
required=True,
|
||||
@@ -25,16 +21,18 @@ def parse_args() -> argparse.Namespace:
|
||||
def main():
|
||||
args: argparse.Namespace = parse_args()
|
||||
|
||||
newPlay: Play
|
||||
newPlay: Playbook
|
||||
try:
|
||||
newPlay = Play.from_yaml(args.playbook)
|
||||
newPlay = Playbook.from_yaml(args.playbook)
|
||||
except Exception as e:
|
||||
print(f"Failed to load playbook: {e}")
|
||||
traceback.print_exc()
|
||||
exit(1)
|
||||
|
||||
print(newPlay.view_playbook())
|
||||
# print(json.dumps(asdict(newPlay.play()), indent=2))
|
||||
# print(newPlay.view_playbook())
|
||||
log = newPlay.run_play()
|
||||
print(log.model_dump_json())
|
||||
# print(json.dumps(asdict(newPlay.run_play()), indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,6 +2,15 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.7.22"
|
||||
@@ -117,6 +126,62 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/26/3f05f9e3692853dff663913d5d62fb8df3f5640c8d70b06588c0b51ed953/pyaml-26.7.0-py3-none-any.whl", hash = "sha256:cfa382780c43ae660669b87d394d550a41856ef175f5749f51f753e12d7077ac", size = 27226, upload-time = "2026-07-04T00:34:37.198Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
@@ -203,6 +268,7 @@ source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "docker" },
|
||||
{ name = "pyaml" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
@@ -210,9 +276,31 @@ dependencies = [
|
||||
requires-dist = [
|
||||
{ name = "docker", specifier = ">=7.2.0" },
|
||||
{ name = "pyaml", specifier = ">=26.7.0" },
|
||||
{ name = "pydantic", specifier = ">=2.13.4" },
|
||||
{ name = "pytest", specifier = ">=9.1.1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
|
||||
Reference in New Issue
Block a user