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
+104 -49
View File
@@ -1,41 +1,17 @@
from __future__ import annotations
import yaml
import json
import time
import datetime
from os import error, name
from typing import Callable, Dict, List, Any
from dataclasses import dataclass, field
from abc import ABC, abstractmethod
from enum import Enum, StrEnum
class Status(StrEnum):
GOOD = "GOOD"
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)
from playbook.models import Status, StepLog
from playbook.action_registry import ActionFn, ActionRegistry
from playbook.premade_steps_registry import registry
class StepIF(ABC):
@@ -54,6 +30,19 @@ class StepIF(ABC):
def post(self) -> StepLog:
""" 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:
""" Run the step. """
start_time: float = datetime.datetime.now().timestamp()
@@ -61,7 +50,7 @@ class StepIF(ABC):
substeps: List[StepLog] = []
status: Status = Status.GOOD
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
substeps.append(log)
@@ -86,14 +75,15 @@ class StepIF(ABC):
class CustomStep(StepIF):
""" Pre made play to backup directories. """
""" Setup custom steps. """
def __init__(
self,
name: str,
pre_fn: Callable[[Dict[str, Any]], StepLog],
play_fn: Callable[[Dict[str, Any]], StepLog],
post_fn: Callable[[Dict[str, Any]], StepLog],
pre_fn: ActionFn,
play_fn: ActionFn,
post_fn: ActionFn,
context: Dict[str, Any]
):
self.name: str = name
@@ -101,16 +91,28 @@ class CustomStep(StepIF):
self.play_fn = play_fn
self.post_fn = post_fn
self.__context: Dict[str, Any] = {}
self.__context = context
def pre(self) -> StepLog:
return self.pre_fn(self.__context)
return self.pre_fn(self.__context, self.name)
def play(self) -> StepLog:
return self.play_fn(self.__context)
return self.play_fn(self.__context, self.name)
def post(self):
return self.post_fn(self.__context)
def post(self) -> StepLog:
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
@@ -123,19 +125,23 @@ class Play(object):
def __init__(self, name : str):
self.name: str = name
self.__steps: List[StepEntry] = []
self.__registries: List[ActionRegistry] = [registry]
def add_step(self, name: str, stepRunner : StepIF):
self.__steps.append(StepEntry(name=name, step=stepRunner))
def view_playbook(self) -> str:
steps: List[str] = []
steps_info = []
for s in self.__steps:
steps.append(s.name)
steps_info.append({
"name": s.name,
"actions": s.step.get_action_names()
})
data = {
"name": self.name,
"number_of_steps": len(steps),
"steps": steps,
"number_of_steps": len(steps_info),
"steps": steps_info,
}
return json.dumps(data)
@@ -170,14 +176,63 @@ class Play(object):
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):
def __init__(self, name: str):
self.name = name
self.__playbook: List[StepIF] = []
raise ValueError(f"no function with name '{fn_name}' was found")
def __build_custom_step(self, name, actions, ctx) -> CustomStep:
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
def run(cls):
pass
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
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 unittest
from Service import Service
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)