Migrating the classes to pydantic
main / Explore-Gitea-Actions (push) Failing after 14s

This commit is contained in:
2026-08-03 23:37:25 +01:00
parent a68d7b1d59
commit 8d73d5ded7
15 changed files with 814 additions and 479 deletions
+180 -327
View File
@@ -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()
+39 -30
View File
@@ -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
+38
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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")
+1
View File
@@ -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",
]
+4 -5
View File
@@ -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"})
+13 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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__":
Generated
+88
View File
@@ -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"