diff --git a/playbook/__init__.py b/playbook/__init__.py index 4ca3286..ac64849 100644 --- a/playbook/__init__.py +++ b/playbook/__init__.py @@ -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 + + @property + def acts(self) -> list[ActModel]: + return self.model.acts - 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}") - - 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() diff --git a/playbook/action_registry/__init__.py b/playbook/action_registry/__init__.py index 87ac22a..c352992 100644 --- a/playbook/action_registry/__init__.py +++ b/playbook/action_registry/__init__.py @@ -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}'") - - versions_dict = self.__actions[name] - if not versions_dict: - raise ValueError(f"No registered versions found for function '{name}'") - if ver == "*": - return next(iter(versions_dict.values())) + 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}'") - if ver not in self.__actions[name]: - raise ValueError(f"Version '{ver}' is not registered for function '{name}'") + return self._actions[name].fn - return versions_dict[ver] + @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] - 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} + 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}") + + 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 ValueError(f"No ActionRegistry instances were found in '{file_path}'") + + return discovered_registries diff --git a/playbook/logging_models.py b/playbook/logging_models.py new file mode 100644 index 0000000..79ab694 --- /dev/null +++ b/playbook/logging_models.py @@ -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) diff --git a/playbook/models.py b/playbook/models.py index b210b17..30cbd7a 100644 --- a/playbook/models.py +++ b/playbook/models.py @@ -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] diff --git a/playbook/premade_steps_registry.py b/playbook/premade_steps_registry.py index fded029..c06d9dc 100644 --- a/playbook/premade_steps_registry.py +++ b/playbook/premade_steps_registry.py @@ -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") diff --git a/playbook/unittests/test_ActionRegistrar.py b/playbook/unittests/test_ActionRegistrar.py new file mode 100644 index 0000000..d32ed26 --- /dev/null +++ b/playbook/unittests/test_ActionRegistrar.py @@ -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()) diff --git a/playbook/unittests/test_data/registries/docker.py b/playbook/unittests/test_data/registries/docker.py new file mode 100644 index 0000000..7f91c82 --- /dev/null +++ b/playbook/unittests/test_data/registries/docker.py @@ -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") diff --git a/playbook/unittests/test_data/registries/restic.py b/playbook/unittests/test_data/registries/restic.py new file mode 100644 index 0000000..f625729 --- /dev/null +++ b/playbook/unittests/test_data/registries/restic.py @@ -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") diff --git a/pyproject.toml b/pyproject.toml index 182a9af..99c1d98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/registry/dbpg_actions.py b/registry/dbpg_actions.py index 564ae23..402bc84 100644 --- a/registry/dbpg_actions.py +++ b/registry/dbpg_actions.py @@ -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"}) diff --git a/registry/directory_actions.py b/registry/directory_actions.py index d14e5b9..2c9af73 100644 --- a/registry/directory_actions.py +++ b/registry/directory_actions.py @@ -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) diff --git a/registry/docker_actions.py b/registry/docker_actions.py index 7f91c82..0849976 100644 --- a/registry/docker_actions.py +++ b/registry/docker_actions.py @@ -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") diff --git a/registry/restic_actions.py b/registry/restic_actions.py index f625729..6ca9fd8 100644 --- a/registry/restic_actions.py +++ b/registry/restic_actions.py @@ -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") diff --git a/taskrunner.py b/taskrunner.py index f168317..79e8022 100644 --- a/taskrunner.py +++ b/taskrunner.py @@ -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__": diff --git a/uv.lock b/uv.lock index bc53444..566cfb1 100644 --- a/uv.lock +++ b/uv.lock @@ -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"