From 026ab23a89f2aa48d3e9cc2e8d8ecb3ef8528087 Mon Sep 17 00:00:00 2001 From: Eduardo Silva Date: Sat, 25 Jul 2026 15:06:41 +0100 Subject: [PATCH] Added the built int functions registry Still lots to do tho --- playbook.yaml | 18 +++ playbook/__init__.py | 153 ++++++++++++------ playbook/action_registry/__init__.py | 46 ++++++ .../unittests/test_action_registry.py | 13 ++ playbook/models.py | 26 +++ playbook/premade_steps.py | 40 ----- playbook/premade_steps_registry.py | 38 +++++ playbook/unittests/test_service.py | 11 +- pyproject.toml | 4 +- taskrunner.py | 17 +- uv.lock | 52 ++++++ 11 files changed, 308 insertions(+), 110 deletions(-) create mode 100644 playbook.yaml create mode 100644 playbook/action_registry/__init__.py create mode 100644 playbook/action_registry/unittests/test_action_registry.py create mode 100644 playbook/models.py delete mode 100644 playbook/premade_steps.py create mode 100644 playbook/premade_steps_registry.py create mode 100644 uv.lock diff --git a/playbook.yaml b/playbook.yaml new file mode 100644 index 0000000..b203f4a --- /dev/null +++ b/playbook.yaml @@ -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" diff --git a/playbook/__init__.py b/playbook/__init__.py index ea01d17..18af492 100644 --- a/playbook/__init__.py +++ b/playbook/__init__.py @@ -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 diff --git a/playbook/action_registry/__init__.py b/playbook/action_registry/__init__.py new file mode 100644 index 0000000..9a43112 --- /dev/null +++ b/playbook/action_registry/__init__.py @@ -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] diff --git a/playbook/action_registry/unittests/test_action_registry.py b/playbook/action_registry/unittests/test_action_registry.py new file mode 100644 index 0000000..5e55854 --- /dev/null +++ b/playbook/action_registry/unittests/test_action_registry.py @@ -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") diff --git a/playbook/models.py b/playbook/models.py new file mode 100644 index 0000000..1fe503b --- /dev/null +++ b/playbook/models.py @@ -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 + + diff --git a/playbook/premade_steps.py b/playbook/premade_steps.py deleted file mode 100644 index 6c858f7..0000000 --- a/playbook/premade_steps.py +++ /dev/null @@ -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=[] - ) diff --git a/playbook/premade_steps_registry.py b/playbook/premade_steps_registry.py new file mode 100644 index 0000000..50e8e23 --- /dev/null +++ b/playbook/premade_steps_registry.py @@ -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 diff --git a/playbook/unittests/test_service.py b/playbook/unittests/test_service.py index 15d610d..64cb521 100644 --- a/playbook/unittests/test_service.py +++ b/playbook/unittests/test_service.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 146e1fc..2e83c3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,9 @@ version = "0.1.0" description = "Utility to run tasks such as backups" readme = "README.md" requires-python = ">=3.14" -dependencies = [] +dependencies = [ + "pyaml>=26.7.0", +] [tool.pyright] venvPath = "." diff --git a/taskrunner.py b/taskrunner.py index 2df4518..283964c 100644 --- a/taskrunner.py +++ b/taskrunner.py @@ -34,19 +34,16 @@ def post(ctx): def main(): - newPlay: Play = Play("backup dirs") - - newPlay.add_step( - "backup dirs", - CustomStep( - "custom step", - pre, play, post, - ) - ) + newPlay: Play + try: + newPlay = Play.from_yaml("./playbook.yaml") + except Exception as e: + print(f"Failed to load playbook: {e}") + exit(1) print(newPlay.view_playbook()) - print(json.dumps(asdict(newPlay.play()), indent=2)) + # print(json.dumps(asdict(newPlay.play()), indent=2)) if __name__ == "__main__": diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..168ab4c --- /dev/null +++ b/uv.lock @@ -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" }]