Added the built int functions registry
Still lots to do tho
This commit is contained in:
+104
-49
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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=[]
|
||||
)
|
||||
@@ -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,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)
|
||||
|
||||
Reference in New Issue
Block a user