Added the built int functions registry

Still lots to do tho
This commit is contained in:
2026-07-25 15:06:41 +01:00
parent a76374e128
commit 026ab23a89
11 changed files with 308 additions and 110 deletions
+18
View File
@@ -0,0 +1,18 @@
playbook_name: "test_playbook"
steps:
- name: "backup dir1"
actions:
pre: "directory_exists"
play: "copy_directory"
post: "verify_copy"
context:
source_dir: "./dir1"
destiny_dir: "./dir2"
- name: "backup dir2"
actions:
pre: "directory_exists"
play: "copy_directory"
post: "verify_copy"
context:
source_dir: "./dir1"
destiny_dir: "./dir2"
+104 -49
View File
@@ -1,41 +1,17 @@
from __future__ import annotations from __future__ import annotations
import yaml
import json import json
import time
import datetime import datetime
from os import error, name from os import error, name
from typing import Callable, Dict, List, Any from typing import Callable, Dict, List, Any
from dataclasses import dataclass, field from dataclasses import dataclass, field
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from enum import Enum, StrEnum
from playbook.models import Status, StepLog
class Status(StrEnum): from playbook.action_registry import ActionFn, ActionRegistry
GOOD = "GOOD" from playbook.premade_steps_registry import registry
BAD = "BAD"
@dataclass
class StepLog(object):
step_name: str
status: Status
duration_sec: int
msg: str
error: List[str] = field(default_factory=list)
substeps: List[StepLog] = field(default_factory=list)
@property
def failed(self) -> bool:
""" Checks if the step failed """
return len(self.error) > 0 or self.status == Status.BAD
# # NOTE: maybe shoulnd't have used an enum
# class StepLogEncoder(json.JSONEncoder):
# """ JSON Encoder for Status Enums. """
# def default(self, o):
# if isinstance(o, Status):
# return o.name
# return super().default(o)
class StepIF(ABC): class StepIF(ABC):
@@ -54,6 +30,19 @@ class StepIF(ABC):
def post(self) -> StepLog: def post(self) -> StepLog:
""" Run at the end to validate the actions made. """ """ Run at the end to validate the actions made. """
@abstractmethod
def get_action_names(self) -> Dict[str, str]:
""" Return a mapping of action roles (pre, play, post) to function names. """
pass
def __timed_run(self, op) -> StepLog:
"""Function that sets the duration_sec field on the log."""
start_time: int = time.perf_counter_ns()
log: StepLog = op()
log.duration_sec = time.perf_counter_ns() - start_time
return log
def run(self, name: str) -> StepLog: def run(self, name: str) -> StepLog:
""" Run the step. """ """ Run the step. """
start_time: float = datetime.datetime.now().timestamp() start_time: float = datetime.datetime.now().timestamp()
@@ -61,7 +50,7 @@ class StepIF(ABC):
substeps: List[StepLog] = [] substeps: List[StepLog] = []
status: Status = Status.GOOD status: Status = Status.GOOD
for op in [self.pre, self.play, self.post]: for op in [self.pre, self.play, self.post]:
log: StepLog = op() log: StepLog = self.__timed_run(op)
status = Status.BAD if log.failed else Status.GOOD status = Status.BAD if log.failed else Status.GOOD
substeps.append(log) substeps.append(log)
@@ -86,14 +75,15 @@ class StepIF(ABC):
class CustomStep(StepIF): class CustomStep(StepIF):
""" Pre made play to backup directories. """ """ Setup custom steps. """
def __init__( def __init__(
self, self,
name: str, name: str,
pre_fn: Callable[[Dict[str, Any]], StepLog], pre_fn: ActionFn,
play_fn: Callable[[Dict[str, Any]], StepLog], play_fn: ActionFn,
post_fn: Callable[[Dict[str, Any]], StepLog], post_fn: ActionFn,
context: Dict[str, Any]
): ):
self.name: str = name self.name: str = name
@@ -101,16 +91,28 @@ class CustomStep(StepIF):
self.play_fn = play_fn self.play_fn = play_fn
self.post_fn = post_fn self.post_fn = post_fn
self.__context: Dict[str, Any] = {} self.__context = context
def pre(self) -> StepLog: def pre(self) -> StepLog:
return self.pre_fn(self.__context) return self.pre_fn(self.__context, self.name)
def play(self) -> StepLog: def play(self) -> StepLog:
return self.play_fn(self.__context) return self.play_fn(self.__context, self.name)
def post(self): def post(self) -> StepLog:
return self.post_fn(self.__context) return self.post_fn(self.__context, self.name)
def get_action_names(self) -> Dict[str, str]:
return {
"pre": self.pre_fn.__name__,
"play": self.pre_fn.__name__,
"post": self.pre_fn.__name__,
}
class PlaybookError(Exception):
""" Base exception for Playbook parsing and execution failures. """
pass
@dataclass @dataclass
@@ -123,19 +125,23 @@ class Play(object):
def __init__(self, name : str): def __init__(self, name : str):
self.name: str = name self.name: str = name
self.__steps: List[StepEntry] = [] self.__steps: List[StepEntry] = []
self.__registries: List[ActionRegistry] = [registry]
def add_step(self, name: str, stepRunner : StepIF): def add_step(self, name: str, stepRunner : StepIF):
self.__steps.append(StepEntry(name=name, step=stepRunner)) self.__steps.append(StepEntry(name=name, step=stepRunner))
def view_playbook(self) -> str: def view_playbook(self) -> str:
steps: List[str] = [] steps_info = []
for s in self.__steps: for s in self.__steps:
steps.append(s.name) steps_info.append({
"name": s.name,
"actions": s.step.get_action_names()
})
data = { data = {
"name": self.name, "name": self.name,
"number_of_steps": len(steps), "number_of_steps": len(steps_info),
"steps": steps, "steps": steps_info,
} }
return json.dumps(data) return json.dumps(data)
@@ -170,14 +176,63 @@ class Play(object):
return playLog return playLog
def __get_function_from_registry(self, fn_name) -> ActionFn:
for reg in self.__registries:
try:
return reg.get(fn_name, "*")
except ValueError:
continue
class Service(object): raise ValueError(f"no function with name '{fn_name}' was found")
def __init__(self, name: str):
self.name = name def __build_custom_step(self, name, actions, ctx) -> CustomStep:
self.__playbook: List[StepIF] = [] try:
pre_fn = self.__get_function_from_registry(actions["pre"])
play_fn = self.__get_function_from_registry(actions["play"])
post_fn = self.__get_function_from_registry(actions["post"])
except KeyError as e:
raise PlaybookError(f"Step '{name}' is missing required action field: {e}") from e
return CustomStep(name=name, pre_fn=pre_fn, play_fn=play_fn, post_fn=post_fn, context=ctx)
@classmethod @classmethod
def run(cls): def from_yaml(cls, fp: str) -> Play:
pass """ 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
return cls.__from_yaml_str(file_contents)
@classmethod
def __from_yaml_str(cls, yaml_str: str) -> Play:
data = None
try:
data = yaml.safe_load(yaml_str)
except Exception as e:
raise e
playbook = cls(data["playbook_name"])
for idx, step_cfg in enumerate(data.get("steps", [])):
step_name = step_cfg.get("name", f"step_{idx}")
actions = step_cfg.get("actions", {})
context = step_cfg.get("actions", {})
if not isinstance(actions, dict) or len(actions) != 3:
raise PlaybookError(
f"Step '{step_name}' (index {idx}) must define an 'actions' map "
"containing 'pre', 'play' and 'post'"
)
try:
customStep: CustomStep = playbook.__build_custom_step(step_name, actions, context)
playbook.add_step(step_cfg["name"], customStep)
except Exception as e:
raise PlaybookError(f"Error configuring step '{step_name}': {e}") from e
return playbook
+46
View File
@@ -0,0 +1,46 @@
from dataclasses import dataclass
from playbook.models import StepLog
from typing import Callable, Dict, Any
ActionFn = Callable[[Dict[str, Any], str], StepLog]
@dataclass
class Action(object):
name: str
fn: ActionFn
ver: str
class ActionRegistry:
def __init__(self, name: str):
self.name: str = name
self.__actions = {}
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
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()))
if ver not in self.__actions[name]:
raise ValueError(f"Version '{ver}' is not registered for function '{name}'")
return versions_dict[ver]
@@ -0,0 +1,13 @@
import os
import unittest
from a import ActionRegistry
CDIR = os.path.dirname(__file__)
class TestActionRegistry(unittest.TestCase):
def test_creating_action_registry(self):
reg: ActionRegistry = ActionRegistry("test_registry")
self.assertEqual(reg.name, "test_registry")
+26
View File
@@ -0,0 +1,26 @@
from dataclasses import dataclass, field
from enum import Enum, StrEnum
from typing import Callable, Dict, List, Any
class Status(StrEnum):
GOOD = "GOOD"
BAD = "BAD"
@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
duration_sec: int = 0
msg: str = ""
error: List[str] = field(default_factory=list)
substeps: List[StepLog] = field(default_factory=list)
@property
def failed(self) -> bool:
""" Checks if the step failed """
return len(self.error) > 0 or self.status == Status.BAD
-40
View File
@@ -1,40 +0,0 @@
from Service import StepIF, StepLog, Status
class DirectoryStep(StepIF):
""" Pre made play to backup directories. """
def __init__(self, source : str, destiny : str):
self.name: str = f"backup {source} -> {destiny}"
self.source: str = source
self.destiny: str = destiny
def pre(self) -> StepLog:
return StepLog(
step_name = "pre " + self.name,
status = Status.GOOD,
duration_sec=0,
msg="",
error= [],
substeps=[]
)
def play(self) -> StepLog:
return StepLog(
step_name = "run " + self.name,
status = Status.GOOD,
duration_sec=0,
msg="",
error=[],
substeps=[]
)
def post(self):
return StepLog(
step_name = "post " + self.name,
status = Status.GOOD,
duration_sec=0,
msg="",
error=[],
substeps=[]
)
+38
View File
@@ -0,0 +1,38 @@
# 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.register(name="directory_exists", version="stat1.0")
def directory_exists(ctx, name) -> StepLog:
log: StepLog = StepLog(
step_name=name,
status=Status.GOOD,
msg="source directory exists",
)
return log
@registry.register(name="copy_directory", version="cp1.0")
def copy_directory(ctx, name) -> StepLog:
log: StepLog = StepLog(
step_name=name,
status=Status.GOOD,
msg="directory was copied/movied/borged/rsynced",
)
return log
@registry.register(name="verify_copy", version="verify1.0")
def verify_copy(ctx, name) -> StepLog:
log: StepLog = StepLog(
step_name=name,
status=Status.GOOD,
msg="copied/movied/borged/rsynced exists and is valid",
)
return log
+1 -10
View File
@@ -1,14 +1,5 @@
import os import os
import unittest import unittest
from Service import Service
CDIR = os.path.dirname(__file__) CDIR = os.path.dirname(__file__)
class TestService(unittest.TestCase):
def test_service_init(self):
name: str = "jellyfin"
newService: Service = Service(name)
self.assertEqual(newService.name, name)
+3 -1
View File
@@ -4,7 +4,9 @@ version = "0.1.0"
description = "Utility to run tasks such as backups" description = "Utility to run tasks such as backups"
readme = "README.md" readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [] dependencies = [
"pyaml>=26.7.0",
]
[tool.pyright] [tool.pyright]
venvPath = "." venvPath = "."
+7 -10
View File
@@ -34,19 +34,16 @@ def post(ctx):
def main(): def main():
newPlay: Play = Play("backup dirs") newPlay: Play
try:
newPlay.add_step( newPlay = Play.from_yaml("./playbook.yaml")
"backup dirs", except Exception as e:
CustomStep( print(f"Failed to load playbook: {e}")
"custom step", exit(1)
pre, play, post,
)
)
print(newPlay.view_playbook()) print(newPlay.view_playbook())
print(json.dumps(asdict(newPlay.play()), indent=2)) # print(json.dumps(asdict(newPlay.play()), indent=2))
if __name__ == "__main__": if __name__ == "__main__":
Generated
+52
View File
@@ -0,0 +1,52 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "pyaml"
version = "26.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/15/6a/acfdf17de0d6947b419da8696e02b781b18de2cf49e0472298b50e1f0711/pyaml-26.7.0.tar.gz", hash = "sha256:11cda3a796efc6dbce0d56836be56cfd26289dad07bcd78e9904086729929c93", size = 30669, upload-time = "2026-07-04T00:34:38.532Z" }
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 = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "taskrunner"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "pyaml" },
]
[package.metadata]
requires-dist = [{ name = "pyaml", specifier = ">=26.7.0" }]