yaddd-utils 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,163 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # poetry
98
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102
+ #poetry.lock
103
+
104
+ # pdm
105
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106
+ #pdm.lock
107
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108
+ # in version control.
109
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
110
+ .pdm.toml
111
+ .pdm-python
112
+ .pdm-build/
113
+
114
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
115
+ __pypackages__/
116
+
117
+ # Celery stuff
118
+ celerybeat-schedule
119
+ celerybeat.pid
120
+
121
+ # SageMath parsed files
122
+ *.sage.py
123
+
124
+ # Environments
125
+ .env
126
+ .venv
127
+ env/
128
+ venv/
129
+ ENV/
130
+ env.bak/
131
+ venv.bak/
132
+
133
+ # Spyder project settings
134
+ .spyderproject
135
+ .spyproject
136
+
137
+ # Rope project settings
138
+ .ropeproject
139
+
140
+ # mkdocs documentation
141
+ /site
142
+
143
+ # ty
144
+ .ty/
145
+
146
+ # Pyre type checker
147
+ .pyre/
148
+
149
+ # pytype static type analyzer
150
+ .pytype/
151
+
152
+ # Cython debug symbols
153
+ cython_debug/
154
+
155
+ # IDE
156
+ .idea
157
+ .vscode/!launch.json
158
+ .vscode/!tasks.json
159
+
160
+ # OS
161
+ .DS_Store
162
+
163
+ *_old/
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.5
2
+ Name: yaddd-utils
3
+ Version: 0.1.0
4
+ Summary: Utilities for yaddd: scenario inspection and Mermaid diagram generation.
5
+ Project-URL: Homepage, https://github.com/netstuff/yaddd
6
+ Author-email: Yuri Andreev <andreev.jr@gmail.com>
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Internet
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: mermaid-py>=0.7
19
+ Requires-Dist: yaddd>=0.3
20
+ Description-Content-Type: text/markdown
21
+
22
+ # yaddd-utils
23
+
24
+ Utilities for [yaddd](https://github.com/netstuff/yaddd): scenario inspection and Mermaid diagram generation.
25
+
26
+ ## Features
27
+
28
+ - **Scenario inspection** — discover `yaddd` `Scenario` subclasses, their steps, branches and conditions.
29
+ - **Mermaid renderers** — turn inspected scenarios into diagrams:
30
+ - flowchart per scenario;
31
+ - grouped flowchart with subgraphs;
32
+ - sequence diagram of step execution;
33
+ - state diagram for `State` subclasses.
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ pip install yaddd-utils
39
+ ```
40
+
41
+ ## CLI
42
+
43
+ Render a Mermaid flowchart from a Python module containing scenarios:
44
+
45
+ ```bash
46
+ yaddd-mermaid myapp.scenarios --output diagram.mmd
47
+ ```
48
+
49
+ ## API
50
+
51
+ ```python
52
+ from yaddd_utils.inspect import ScenarioAnalyzer
53
+ from yaddd_utils.mermaid import ScenarioRenderer, RenderOptions
54
+
55
+ analyzer = ScenarioAnalyzer()
56
+ model = analyzer.analyze_module("myapp.scenarios")
57
+
58
+ renderer = ScenarioRenderer(RenderOptions(direction="TD"))
59
+ print(renderer.render(model))
60
+ ```
@@ -0,0 +1,39 @@
1
+ # yaddd-utils
2
+
3
+ Utilities for [yaddd](https://github.com/netstuff/yaddd): scenario inspection and Mermaid diagram generation.
4
+
5
+ ## Features
6
+
7
+ - **Scenario inspection** — discover `yaddd` `Scenario` subclasses, their steps, branches and conditions.
8
+ - **Mermaid renderers** — turn inspected scenarios into diagrams:
9
+ - flowchart per scenario;
10
+ - grouped flowchart with subgraphs;
11
+ - sequence diagram of step execution;
12
+ - state diagram for `State` subclasses.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install yaddd-utils
18
+ ```
19
+
20
+ ## CLI
21
+
22
+ Render a Mermaid flowchart from a Python module containing scenarios:
23
+
24
+ ```bash
25
+ yaddd-mermaid myapp.scenarios --output diagram.mmd
26
+ ```
27
+
28
+ ## API
29
+
30
+ ```python
31
+ from yaddd_utils.inspect import ScenarioAnalyzer
32
+ from yaddd_utils.mermaid import ScenarioRenderer, RenderOptions
33
+
34
+ analyzer = ScenarioAnalyzer()
35
+ model = analyzer.analyze_module("myapp.scenarios")
36
+
37
+ renderer = ScenarioRenderer(RenderOptions(direction="TD"))
38
+ print(renderer.render(model))
39
+ ```
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "yaddd-utils"
3
+ description = "Utilities for yaddd: scenario inspection and Mermaid diagram generation."
4
+ authors = [
5
+ {name = "Yuri Andreev", email = "andreev.jr@gmail.com"},
6
+ ]
7
+ license = 'MIT'
8
+ license-files = ['LICENSE']
9
+ classifiers = [
10
+ 'Development Status :: 4 - Beta',
11
+ 'Programming Language :: Python',
12
+ 'Programming Language :: Python :: 3.12',
13
+ 'Programming Language :: Python :: 3.13',
14
+ 'Programming Language :: Python :: 3.14',
15
+ 'Intended Audience :: Developers',
16
+ 'Operating System :: OS Independent',
17
+ 'Topic :: Software Development :: Libraries :: Python Modules',
18
+ 'Topic :: Internet',
19
+ ]
20
+ requires-python = '>=3.12'
21
+ dependencies = [
22
+ "yaddd>=0.3",
23
+ "mermaid-py>=0.7",
24
+ ]
25
+ version = "0.1.0"
26
+ readme = 'README.md'
27
+
28
+ [project.urls]
29
+ Homepage = 'https://github.com/netstuff/yaddd'
30
+
31
+ [project.scripts]
32
+ yaddd-mermaid = "yaddd_utils.mermaid.cli:main"
33
+
34
+ [tool.uv.sources]
35
+ yaddd = { workspace = true }
36
+
37
+ [build-system]
38
+ requires = ["hatchling"]
39
+ build-backend = "hatchling.build"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/yaddd_utils"]
@@ -0,0 +1,6 @@
1
+ """yaddd-utils — utilities for yaddd."""
2
+
3
+ from yaddd_utils import inspect, mermaid
4
+
5
+
6
+ __all__ = ["inspect", "mermaid"]
@@ -0,0 +1,12 @@
1
+ """Inspection helpers for yaddd scenarios and states."""
2
+
3
+ from yaddd_utils.inspect.registry import TargetRegistry
4
+ from yaddd_utils.inspect.scenario import ScenarioAnalyzer, ScenarioInfo, StepInfo
5
+
6
+
7
+ __all__ = [
8
+ "ScenarioAnalyzer",
9
+ "ScenarioInfo",
10
+ "StepInfo",
11
+ "TargetRegistry",
12
+ ]
@@ -0,0 +1,51 @@
1
+ """Registry of inspection targets (modules and scenario classes)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from collections.abc import Iterator
7
+ from dataclasses import dataclass, field
8
+ from types import ModuleType
9
+ from typing import Any
10
+
11
+ from yaddd.application.handlers.scenarios import Scenario
12
+
13
+
14
+ def _empty_modules() -> set[str]:
15
+ return set()
16
+
17
+
18
+ def _empty_classes() -> set[str]:
19
+ return set()
20
+
21
+
22
+ @dataclass(kw_only=True, slots=True)
23
+ class TargetRegistry:
24
+ """Collect modules and scenario classes to be inspected together."""
25
+
26
+ _modules: set[str] = field(default_factory=_empty_modules)
27
+ _classes: set[str] = field(default_factory=_empty_classes)
28
+
29
+ def register_module(self, module: str | ModuleType) -> None:
30
+ """Add a module to the inspection set."""
31
+ name = module if isinstance(module, str) else module.__name__
32
+ self._modules.add(name)
33
+
34
+ def register_class(self, scenario_cls: type[Scenario[Any]]) -> None:
35
+ """Add a scenario class to the inspection set."""
36
+ self._classes.add(scenario_cls.__module__ + ":" + scenario_cls.scenario_name())
37
+
38
+ def modules(self) -> Iterator[str]:
39
+ """Yield all registered module names."""
40
+ yield from sorted(self._modules)
41
+
42
+ def classes(self) -> Iterator[str]:
43
+ """Yield all registered class selectors in ``module:ClassName`` form."""
44
+ yield from sorted(self._classes)
45
+
46
+ def resolve_classes(self) -> Iterator[type[Scenario[Any]]]:
47
+ """Import and yield registered scenario classes."""
48
+ for selector in self._classes:
49
+ module_name, class_name = selector.split(":", 1)
50
+ module = importlib.import_module(module_name)
51
+ yield getattr(module, class_name)
@@ -0,0 +1,97 @@
1
+ """Scenario introspection for yaddd application workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import inspect
7
+ from collections.abc import Sequence
8
+ from dataclasses import dataclass, field
9
+ from types import ModuleType
10
+ from typing import Any, TypeVar
11
+
12
+ from yaddd.application.handlers.scenarios import Scenario, Step
13
+
14
+
15
+ T = TypeVar("T")
16
+
17
+
18
+ def _is_scenario_subclass(obj: Any) -> bool:
19
+ return inspect.isclass(obj) and issubclass(obj, Scenario) and obj is not Scenario
20
+
21
+
22
+ @dataclass(frozen=True, kw_only=True, slots=True)
23
+ class StepInfo:
24
+ """Introspected metadata of a single scenario step."""
25
+
26
+ name: str
27
+ method_name: str
28
+ has_condition: bool = False
29
+ branch_target: str | None = None
30
+ level: str = "info"
31
+
32
+
33
+ @dataclass(frozen=True, kw_only=True, slots=True)
34
+ class ScenarioInfo:
35
+ """Introspected metadata of a yaddd Scenario class."""
36
+
37
+ name: str
38
+ module: str
39
+ steps: Sequence[StepInfo] = ()
40
+ dependencies: Sequence[str] = field(default_factory=tuple)
41
+
42
+
43
+ class ScenarioAnalyzer:
44
+ """Collect structural metadata from yaddd Scenario classes."""
45
+
46
+ def analyze_module(self, module: str | ModuleType) -> list[ScenarioInfo]:
47
+ """Inspect all Scenario subclasses declared in ``module``."""
48
+ if isinstance(module, str):
49
+ module = importlib.import_module(module)
50
+ result: list[ScenarioInfo] = []
51
+ for _name, obj in inspect.getmembers(module, _is_scenario_subclass):
52
+ info = self.analyze_class(obj)
53
+ if info.steps:
54
+ result.append(info)
55
+ return result
56
+
57
+ def analyze_class(self, scenario_cls: type[Scenario[Any]]) -> ScenarioInfo:
58
+ """Inspect a single Scenario subclass."""
59
+ declared_steps: Sequence[Step[Any]] = getattr(scenario_cls, "_declared_steps", ())
60
+ steps = [self._step_info(step) for step in declared_steps]
61
+ return ScenarioInfo(
62
+ name=scenario_cls.scenario_name(),
63
+ module=scenario_cls.__module__,
64
+ steps=steps,
65
+ dependencies=self._constructor_params(scenario_cls),
66
+ )
67
+
68
+ def analyze_object(self, scenario: Scenario[Any]) -> ScenarioInfo:
69
+ """Inspect a Scenario instance (same as its class)."""
70
+ return self.analyze_class(type(scenario))
71
+
72
+ def _step_info(self, step: Step[Any]) -> StepInfo:
73
+ target: str | None = None
74
+ if step.branch_to is not None:
75
+ target = self._branch_target_name(step.branch_to)
76
+ return StepInfo(
77
+ name=step.name,
78
+ method_name=step.method_name,
79
+ has_condition=step.when is not None,
80
+ branch_target=target,
81
+ level=step.level,
82
+ )
83
+
84
+ def _branch_target_name(self, branch_to: Any) -> str | None:
85
+ if inspect.isclass(branch_to) and issubclass(branch_to, Scenario):
86
+ return branch_to.scenario_name()
87
+ if callable(branch_to) and hasattr(branch_to, "__annotations__"):
88
+ return getattr(branch_to, "__name__", None) or repr(branch_to)
89
+ return None
90
+
91
+ def _constructor_params(self, scenario_cls: type[Scenario[Any]]) -> Sequence[str]:
92
+ signature = inspect.signature(scenario_cls.__init__)
93
+ return [
94
+ name
95
+ for name, param in signature.parameters.items()
96
+ if name != "self" and param.default is inspect.Parameter.empty
97
+ ]
@@ -0,0 +1,21 @@
1
+ """Mermaid diagram generation for yaddd scenarios and states."""
2
+
3
+ from yaddd_utils.mermaid._models import GraphModel, ScenarioEdge, ScenarioNode
4
+ from yaddd_utils.mermaid.graph import ScenarioGraphRenderer
5
+ from yaddd_utils.mermaid.renderers import Renderer, RenderOptions
6
+ from yaddd_utils.mermaid.scenario import ScenarioRenderer
7
+ from yaddd_utils.mermaid.sequence import ScenarioSequenceRenderer
8
+ from yaddd_utils.mermaid.state import ScenarioStateRenderer
9
+
10
+
11
+ __all__ = [
12
+ "GraphModel",
13
+ "Renderer",
14
+ "RenderOptions",
15
+ "ScenarioEdge",
16
+ "ScenarioGraphRenderer",
17
+ "ScenarioNode",
18
+ "ScenarioRenderer",
19
+ "ScenarioSequenceRenderer",
20
+ "ScenarioStateRenderer",
21
+ ]
@@ -0,0 +1,48 @@
1
+ """Thin wrapper around ``mermaid-py`` with warning suppression."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ class MermaidAdapterError(Exception):
11
+ """mermaid-py interaction failed."""
12
+
13
+
14
+ def _import_mermaid() -> Any:
15
+ """Import mermaid-py while suppressing its runtime warnings."""
16
+ with warnings.catch_warnings():
17
+ warnings.simplefilter("ignore")
18
+ import mermaid # type: ignore[import-untyped]
19
+
20
+ return mermaid
21
+
22
+
23
+ def make_graph(title: str, script: str) -> Any:
24
+ """Build a mermaid-py Graph object from a diagram script."""
25
+ mermaid = _import_mermaid()
26
+ graph_cls = mermaid.graph.Graph
27
+ return graph_cls(title, script)
28
+
29
+
30
+ def save_image(script: str, output: Path | str, title: str = "yaddd") -> None:
31
+ """Render ``script`` to an image file using mermaid-py.
32
+
33
+ Supported extensions depend on the installed mermaid-py backend
34
+ (``.png``, ``.svg`` and ``.pdf`` are common).
35
+ """
36
+ mermaid = _import_mermaid()
37
+ graph = make_graph(title, script)
38
+ renderer = mermaid.Mermaid(graph)
39
+ path = Path(output)
40
+ suffix = path.suffix.lower()
41
+ if suffix == ".png":
42
+ renderer.to_png(str(path))
43
+ elif suffix == ".svg":
44
+ renderer.to_svg(str(path))
45
+ elif suffix == ".pdf":
46
+ renderer.to_pdf(str(path))
47
+ else:
48
+ raise MermaidAdapterError(f"Unsupported image format: {suffix!r}")
@@ -0,0 +1,55 @@
1
+ """Dataclasses shared by Mermaid renderers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ def _empty_meta() -> dict[str, Any]:
10
+ return {}
11
+
12
+
13
+ def _empty_nodes() -> list[ScenarioNode]:
14
+ return []
15
+
16
+
17
+ def _empty_edges() -> list[ScenarioEdge]:
18
+ return []
19
+
20
+
21
+ @dataclass(frozen=True, kw_only=True, slots=True)
22
+ class ScenarioNode:
23
+ """A node in a scenario diagram."""
24
+
25
+ id: str
26
+ label: str
27
+ scenario: str | None = None
28
+ step: str | None = None
29
+ shape: str = "round"
30
+ meta: dict[str, Any] = field(default_factory=_empty_meta)
31
+
32
+
33
+ @dataclass(frozen=True, kw_only=True, slots=True)
34
+ class ScenarioEdge:
35
+ """A directed edge between two scenario nodes."""
36
+
37
+ source: str
38
+ target: str
39
+ label: str | None = None
40
+ style: str | None = None
41
+
42
+
43
+ @dataclass(kw_only=True, slots=True)
44
+ class GraphModel:
45
+ """Generic graph model produced by renderers."""
46
+
47
+ title: str
48
+ nodes: list[ScenarioNode] = field(default_factory=_empty_nodes)
49
+ edges: list[ScenarioEdge] = field(default_factory=_empty_edges)
50
+
51
+ def add_node(self, node: ScenarioNode) -> None:
52
+ self.nodes.append(node)
53
+
54
+ def add_edge(self, edge: ScenarioEdge) -> None:
55
+ self.edges.append(edge)
@@ -0,0 +1,115 @@
1
+ """Command-line entrypoint for rendering yaddd scenarios as Mermaid diagrams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Protocol, Sequence
9
+
10
+ from yaddd_utils.inspect.scenario import ScenarioAnalyzer, ScenarioInfo
11
+ from yaddd_utils.mermaid._adapter import MermaidAdapterError, save_image
12
+ from yaddd_utils.mermaid.graph import ScenarioGraphRenderer
13
+ from yaddd_utils.mermaid.renderers import Renderer, RenderOptions
14
+ from yaddd_utils.mermaid.scenario import ScenarioRenderer
15
+ from yaddd_utils.mermaid.sequence import ScenarioSequenceRenderer
16
+
17
+
18
+ class _RendererFactory(Protocol):
19
+ def __call__(self, options: RenderOptions | None = None) -> Renderer: ...
20
+
21
+
22
+ _RENDERERS: dict[str, _RendererFactory] = {
23
+ "flowchart": ScenarioRenderer,
24
+ "graph": ScenarioGraphRenderer,
25
+ "sequence": ScenarioSequenceRenderer,
26
+ }
27
+
28
+
29
+ def _build_parser() -> argparse.ArgumentParser:
30
+ parser = argparse.ArgumentParser(
31
+ prog="yaddd-mermaid",
32
+ description="Render yaddd scenarios as Mermaid diagrams.",
33
+ )
34
+ parser.add_argument(
35
+ "modules",
36
+ nargs="+",
37
+ help="Python module paths to inspect (e.g. myapp.scenarios).",
38
+ )
39
+ parser.add_argument(
40
+ "--renderer",
41
+ choices=list(_RENDERERS.keys()),
42
+ default="flowchart",
43
+ help="Diagram type to render (default: flowchart).",
44
+ )
45
+ parser.add_argument(
46
+ "--direction",
47
+ default="TD",
48
+ help="Flowchart direction: TD, LR, RL, BT (default: TD).",
49
+ )
50
+ parser.add_argument(
51
+ "--output",
52
+ "-o",
53
+ type=Path,
54
+ help="Write the diagram to this file instead of stdout.",
55
+ )
56
+ parser.add_argument(
57
+ "--image",
58
+ type=Path,
59
+ help="Also render the diagram to an image (png/svg/pdf) using mermaid-py.",
60
+ )
61
+ parser.add_argument(
62
+ "--show-levels",
63
+ action="store_true",
64
+ help="Include step log levels in node labels.",
65
+ )
66
+ parser.add_argument(
67
+ "--hide-conditions",
68
+ action="store_true",
69
+ help="Do not annotate conditional steps.",
70
+ )
71
+ return parser
72
+
73
+
74
+ def main(argv: Sequence[str] | None = None) -> int:
75
+ """Run the CLI and return an exit code."""
76
+ parser = _build_parser()
77
+ args = parser.parse_args(argv)
78
+
79
+ analyzer = ScenarioAnalyzer()
80
+ scenarios: list[ScenarioInfo] = []
81
+ for module_name in args.modules:
82
+ scenarios.extend(analyzer.analyze_module(module_name))
83
+
84
+ if not scenarios:
85
+ print("No Scenario subclasses found.", file=sys.stderr)
86
+ return 1
87
+
88
+ options = RenderOptions(
89
+ direction=args.direction,
90
+ show_levels=args.show_levels,
91
+ show_conditions=not args.hide_conditions,
92
+ )
93
+ renderer_cls = _RENDERERS[args.renderer]
94
+ renderer = renderer_cls(options)
95
+ diagram = renderer.render_infos(scenarios)
96
+
97
+ if args.output:
98
+ args.output.write_text(diagram, encoding="utf-8")
99
+ print(f"Wrote {args.output}")
100
+ else:
101
+ print(diagram)
102
+
103
+ if args.image:
104
+ try:
105
+ save_image(diagram, args.image)
106
+ print(f"Wrote image {args.image}")
107
+ except MermaidAdapterError as exc:
108
+ print(f"Image rendering failed: {exc}", file=sys.stderr)
109
+ return 1
110
+
111
+ return 0
112
+
113
+
114
+ if __name__ == "__main__":
115
+ raise SystemExit(main())
@@ -0,0 +1,83 @@
1
+ """Renderer that groups scenario steps into per-scenario subgraphs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from yaddd_utils.inspect.scenario import ScenarioInfo
6
+ from yaddd_utils.mermaid._models import GraphModel, ScenarioEdge, ScenarioNode
7
+ from yaddd_utils.mermaid.renderers import RenderOptions
8
+ from yaddd_utils.mermaid.scenario import safe_id
9
+
10
+
11
+ class ScenarioGraphRenderer:
12
+ """Render scenarios as a flowchart with a subgraph per scenario."""
13
+
14
+ def __init__(self, options: RenderOptions | None = None) -> None:
15
+ self._options = options or RenderOptions()
16
+
17
+ def render(self, model: GraphModel) -> str:
18
+ """Render ``model`` as a flowchart with subgraphs."""
19
+ lines = [f"flowchart {self._options.direction}"]
20
+ current_subgraph: str | None = None
21
+ for node in model.nodes:
22
+ if node.scenario and node.scenario != current_subgraph:
23
+ if current_subgraph is not None:
24
+ lines.append(f"{self._options.indent}end")
25
+ current_subgraph = node.scenario
26
+ lines.append(f"{self._options.indent}subgraph {node.scenario}")
27
+ lines.append(self._render_node(node))
28
+ if current_subgraph is not None:
29
+ lines.append(f"{self._options.indent}end")
30
+ for edge in model.edges:
31
+ lines.append(self._render_edge(edge))
32
+ return "\n".join(lines)
33
+
34
+ def render_infos(self, infos: list[ScenarioInfo]) -> str:
35
+ """Build a grouped flowchart from several scenarios."""
36
+ return self.render(self.build_model(infos))
37
+
38
+ def build_model(self, infos: list[ScenarioInfo]) -> GraphModel:
39
+ """Create a GraphModel from multiple ScenarioInfo objects."""
40
+ model = GraphModel(title="scenarios")
41
+ for info in infos:
42
+ self._add_scenario(model, info)
43
+ return model
44
+
45
+ def _add_scenario(self, model: GraphModel, info: ScenarioInfo) -> None:
46
+ previous: str | None = None
47
+ for step in info.steps:
48
+ node_id = safe_id(f"{info.name}_{step.name}")
49
+ label = step.name
50
+ if self._options.show_levels:
51
+ label = f"[{step.level}] {label}"
52
+ if step.has_condition and self._options.show_conditions:
53
+ label = f"{label} ❓"
54
+ model.add_node(
55
+ ScenarioNode(
56
+ id=node_id,
57
+ label=label,
58
+ scenario=info.name,
59
+ step=step.name,
60
+ )
61
+ )
62
+ if previous is not None:
63
+ model.add_edge(ScenarioEdge(source=previous, target=node_id))
64
+ if step.branch_target is not None:
65
+ target_id = safe_id(step.branch_target)
66
+ model.add_node(ScenarioNode(id=target_id, label=step.branch_target))
67
+ model.add_edge(
68
+ ScenarioEdge(
69
+ source=node_id,
70
+ target=target_id,
71
+ label="branch",
72
+ )
73
+ )
74
+ previous = node_id
75
+
76
+ def _render_node(self, node: ScenarioNode) -> str:
77
+ label = node.label.replace('"', '"')
78
+ return f'{self._options.indent}{node.id}("{label}")'
79
+
80
+ def _render_edge(self, edge: ScenarioEdge) -> str:
81
+ if edge.label:
82
+ return f"{self._options.indent}{edge.source} -->|{edge.label}| {edge.target}"
83
+ return f"{self._options.indent}{edge.source} --> {edge.target}"
@@ -0,0 +1,31 @@
1
+ """Base renderer protocol and shared options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Protocol
7
+
8
+ from yaddd_utils.inspect.scenario import ScenarioInfo
9
+ from yaddd_utils.mermaid._models import GraphModel
10
+
11
+
12
+ @dataclass(frozen=True, kw_only=True, slots=True)
13
+ class RenderOptions:
14
+ """Common options for Mermaid renderers."""
15
+
16
+ direction: str = "TD"
17
+ show_conditions: bool = True
18
+ show_levels: bool = False
19
+ indent: str = " "
20
+
21
+
22
+ class Renderer(Protocol):
23
+ """Protocol for objects that render scenario models to Mermaid text."""
24
+
25
+ def render(self, model: GraphModel) -> str:
26
+ """Render ``model`` as a Mermaid diagram string."""
27
+ ...
28
+
29
+ def render_infos(self, infos: list[ScenarioInfo]) -> str:
30
+ """Render a list of ScenarioInfo objects as a Mermaid diagram string."""
31
+ ...
@@ -0,0 +1,86 @@
1
+ """Renderer that turns a single scenario into a Mermaid flowchart."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from yaddd_utils.inspect.scenario import ScenarioInfo
6
+ from yaddd_utils.mermaid._models import GraphModel, ScenarioEdge, ScenarioNode
7
+ from yaddd_utils.mermaid.renderers import RenderOptions
8
+
9
+
10
+ def safe_id(name: str) -> str:
11
+ """Produce a Mermaid-safe node identifier."""
12
+ return "".join(ch if ch.isalnum() else "_" for ch in name)
13
+
14
+
15
+ class ScenarioRenderer:
16
+ """Render one or more ScenarioInfo objects as a flat flowchart."""
17
+
18
+ def __init__(self, options: RenderOptions | None = None) -> None:
19
+ self._options = options or RenderOptions()
20
+
21
+ def render(self, model: GraphModel) -> str:
22
+ """Render ``model`` as a Mermaid flowchart."""
23
+ lines = [f"flowchart {self._options.direction}"]
24
+ for node in model.nodes:
25
+ lines.append(self._render_node(node))
26
+ for edge in model.edges:
27
+ lines.append(self._render_edge(edge))
28
+ return "\n".join(lines)
29
+
30
+ def render_info(self, info: ScenarioInfo) -> str:
31
+ """Build a flowchart for a single scenario."""
32
+ return self.render(self.build_model(info))
33
+
34
+ def render_infos(self, infos: list[ScenarioInfo]) -> str:
35
+ """Build a flat flowchart for multiple scenarios."""
36
+ if len(infos) == 1:
37
+ return self.render_info(infos[0])
38
+ parts = [self.render_info(info) for info in infos]
39
+ return "\n\n".join(parts)
40
+
41
+ def build_model(self, info: ScenarioInfo) -> GraphModel:
42
+ """Create a GraphModel from a single ScenarioInfo."""
43
+ model = GraphModel(title=info.name)
44
+ previous: str | None = None
45
+ for step in info.steps:
46
+ node_id = safe_id(f"{info.name}_{step.name}")
47
+ label = step.name
48
+ if self._options.show_levels:
49
+ label = f"[{step.level}] {label}"
50
+ if step.has_condition and self._options.show_conditions:
51
+ label = f"{label} ❓"
52
+ model.add_node(
53
+ ScenarioNode(
54
+ id=node_id,
55
+ label=label,
56
+ scenario=info.name,
57
+ step=step.name,
58
+ )
59
+ )
60
+ if previous is not None:
61
+ model.add_edge(ScenarioEdge(source=previous, target=node_id))
62
+ if step.branch_target is not None:
63
+ target_id = safe_id(step.branch_target)
64
+ model.add_node(ScenarioNode(id=target_id, label=step.branch_target))
65
+ model.add_edge(
66
+ ScenarioEdge(
67
+ source=node_id,
68
+ target=target_id,
69
+ label="branch",
70
+ )
71
+ )
72
+ previous = node_id
73
+ return model
74
+
75
+ def _render_node(self, node: ScenarioNode) -> str:
76
+ label = node.label.replace('"', '"')
77
+ if node.shape == "round":
78
+ return f'{self._options.indent}{node.id}("{label}")'
79
+ if node.shape == "diamond":
80
+ return f'{self._options.indent}{node.id}{{"{label}"}}'
81
+ return f'{self._options.indent}{node.id}["{label}"]'
82
+
83
+ def _render_edge(self, edge: ScenarioEdge) -> str:
84
+ if edge.label:
85
+ return f"{self._options.indent}{edge.source} -->|{edge.label}| {edge.target}"
86
+ return f"{self._options.indent}{edge.source} --> {edge.target}"
@@ -0,0 +1,78 @@
1
+ """Renderer that produces a Mermaid sequence diagram from scenarios."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from yaddd_utils.inspect.scenario import ScenarioInfo
6
+ from yaddd_utils.mermaid._models import GraphModel, ScenarioEdge, ScenarioNode
7
+ from yaddd_utils.mermaid.renderers import RenderOptions
8
+ from yaddd_utils.mermaid.scenario import safe_id
9
+
10
+
11
+ class ScenarioSequenceRenderer:
12
+ """Render scenario step flow as a Mermaid sequence diagram."""
13
+
14
+ def __init__(self, options: RenderOptions | None = None) -> None:
15
+ self._options = options or RenderOptions()
16
+
17
+ def render(self, model: GraphModel) -> str:
18
+ """Render ``model`` as a sequence diagram."""
19
+ lines = ["sequenceDiagram"]
20
+ actors: set[str] = set()
21
+ for node in model.nodes:
22
+ if node.scenario:
23
+ actors.add(node.scenario)
24
+ for actor in sorted(actors):
25
+ lines.append(f"{self._options.indent}participant {actor}")
26
+ current_actor: str | None = None
27
+ for edge in model.edges:
28
+ source_actor = self._actor_for(edge.source, model)
29
+ target_actor = self._actor_for(edge.target, model)
30
+ label = edge.label or "step"
31
+ if source_actor and target_actor:
32
+ lines.append(
33
+ f"{self._options.indent}{source_actor}->>{target_actor}: {label}"
34
+ )
35
+ elif source_actor and current_actor != source_actor:
36
+ current_actor = source_actor
37
+ lines.append(f"{self._options.indent}Note over {source_actor}: {label}")
38
+ return "\n".join(lines)
39
+
40
+ def render_infos(self, infos: list[ScenarioInfo]) -> str:
41
+ """Build a sequence diagram from several scenarios."""
42
+ return self.render(self.build_model(infos))
43
+
44
+ def build_model(self, infos: list[ScenarioInfo]) -> GraphModel:
45
+ """Create a GraphModel representing the sequence flow."""
46
+ model = GraphModel(title="sequence")
47
+ for info in infos:
48
+ previous: str | None = None
49
+ for step in info.steps:
50
+ node_id = safe_id(f"{info.name}_{step.name}")
51
+ model.add_node(
52
+ ScenarioNode(
53
+ id=node_id,
54
+ label=step.name,
55
+ scenario=info.name,
56
+ step=step.name,
57
+ )
58
+ )
59
+ if previous is not None:
60
+ model.add_edge(ScenarioEdge(source=previous, target=node_id))
61
+ if step.branch_target is not None:
62
+ target_id = safe_id(step.branch_target)
63
+ model.add_node(ScenarioNode(id=target_id, label=step.branch_target))
64
+ model.add_edge(
65
+ ScenarioEdge(
66
+ source=node_id,
67
+ target=target_id,
68
+ label="branch",
69
+ )
70
+ )
71
+ previous = node_id
72
+ return model
73
+
74
+ def _actor_for(self, node_id: str, model: GraphModel) -> str | None:
75
+ for node in model.nodes:
76
+ if node.id == node_id:
77
+ return node.scenario
78
+ return None
@@ -0,0 +1,43 @@
1
+ """Renderer that turns State subclasses into Mermaid state diagrams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import Any
7
+
8
+ from yaddd_utils.mermaid._models import GraphModel, ScenarioNode
9
+ from yaddd_utils.mermaid.renderers import RenderOptions
10
+
11
+
12
+ class ScenarioStateRenderer:
13
+ """Render a yaddd State subclass as a Mermaid state diagram."""
14
+
15
+ def __init__(self, options: RenderOptions | None = None) -> None:
16
+ self._options = options or RenderOptions()
17
+
18
+ def render(self, model: GraphModel) -> str:
19
+ """Render ``model`` as a state diagram."""
20
+ lines = ["stateDiagram-v2"]
21
+ for node in model.nodes:
22
+ lines.append(f"{self._options.indent}[*] --> {node.id} : initialize")
23
+ lines.append(f"{self._options.indent}{node.id} : {node.label}")
24
+ return "\n".join(lines)
25
+
26
+ def render_state_class(self, state_cls: type[Any]) -> str:
27
+ """Build a state diagram for a single State subclass."""
28
+ return self.render(self.build_model(state_cls))
29
+
30
+ def build_model(self, state_cls: type[Any]) -> GraphModel:
31
+ """Create a GraphModel from a State subclass."""
32
+ title = getattr(state_cls, "__qualname__", "State")
33
+ model = GraphModel(title=title)
34
+ fields: Sequence[str] = getattr(state_cls, "_fields", ())
35
+ for name in fields:
36
+ model.add_node(
37
+ ScenarioNode(
38
+ id=name,
39
+ label=name,
40
+ scenario=title,
41
+ )
42
+ )
43
+ return model
File without changes
File without changes
File without changes
@@ -0,0 +1,46 @@
1
+ import sys
2
+
3
+ from yaddd import Scenario, step
4
+ from yaddd_utils.inspect import ScenarioAnalyzer
5
+
6
+
7
+ class SampleScenario(Scenario[int]):
8
+ @step
9
+ async def first(self, ctx) -> int:
10
+ return ctx.state + 1
11
+
12
+ @step(when=lambda ctx: ctx.state > 0)
13
+ async def second(self, ctx) -> int:
14
+ return ctx.state + 10
15
+
16
+
17
+ class BranchingScenario(Scenario[int]):
18
+ @step
19
+ async def go(self, ctx) -> int:
20
+ return ctx.state + 1
21
+
22
+
23
+ class EmptyScenario(Scenario[int]):
24
+ pass
25
+
26
+
27
+ def test_analyzer_collects_steps_and_conditions():
28
+ analyzer = ScenarioAnalyzer()
29
+ info = analyzer.analyze_class(SampleScenario)
30
+
31
+ assert info.name == "SampleScenario"
32
+ assert len(info.steps) == 2
33
+ assert info.steps[0].name == "first"
34
+ assert info.steps[0].has_condition is False
35
+ assert info.steps[1].name == "second"
36
+ assert info.steps[1].has_condition is True
37
+
38
+
39
+ def test_analyzer_skips_empty_scenarios_when_scanning_module():
40
+ analyzer = ScenarioAnalyzer()
41
+ results = analyzer.analyze_module(sys.modules[__name__])
42
+
43
+ names = {info.name for info in results}
44
+ assert "SampleScenario" in names
45
+ assert "BranchingScenario" in names
46
+ assert "EmptyScenario" not in names
File without changes
@@ -0,0 +1,52 @@
1
+ import sys
2
+
3
+ from yaddd import Scenario, step
4
+ from yaddd_utils.inspect import ScenarioAnalyzer
5
+ from yaddd_utils.mermaid import (
6
+ RenderOptions,
7
+ ScenarioGraphRenderer,
8
+ ScenarioRenderer,
9
+ ScenarioSequenceRenderer,
10
+ )
11
+
12
+
13
+ class DemoScenario(Scenario[int]):
14
+ @step
15
+ async def start(self, ctx) -> int:
16
+ return ctx.state + 1
17
+
18
+ @step(when=lambda ctx: ctx.state > 0)
19
+ async def maybe(self, ctx) -> int:
20
+ return ctx.state + 10
21
+
22
+
23
+ def _infos():
24
+ return ScenarioAnalyzer().analyze_module(sys.modules[__name__])
25
+
26
+
27
+ def test_scenario_renderer_produces_flowchart():
28
+ info = ScenarioAnalyzer().analyze_class(DemoScenario)
29
+ renderer = ScenarioRenderer(RenderOptions(show_conditions=True))
30
+ diagram = renderer.render_info(info)
31
+
32
+ assert diagram.startswith("flowchart TD")
33
+ assert "start" in diagram
34
+ assert "maybe" in diagram
35
+
36
+
37
+ def test_graph_renderer_uses_subgraphs():
38
+ infos = _infos()
39
+ renderer = ScenarioGraphRenderer()
40
+ diagram = renderer.render_infos(infos)
41
+
42
+ assert diagram.startswith("flowchart TD")
43
+ assert "subgraph DemoScenario" in diagram
44
+
45
+
46
+ def test_sequence_renderer_produces_sequence():
47
+ infos = _infos()
48
+ renderer = ScenarioSequenceRenderer()
49
+ diagram = renderer.render_infos(infos)
50
+
51
+ assert diagram.startswith("sequenceDiagram")
52
+ assert "DemoScenario" in diagram