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.
- yaddd_utils-0.1.0/.gitignore +163 -0
- yaddd_utils-0.1.0/PKG-INFO +60 -0
- yaddd_utils-0.1.0/README.md +39 -0
- yaddd_utils-0.1.0/pyproject.toml +42 -0
- yaddd_utils-0.1.0/src/yaddd_utils/__init__.py +6 -0
- yaddd_utils-0.1.0/src/yaddd_utils/inspect/__init__.py +12 -0
- yaddd_utils-0.1.0/src/yaddd_utils/inspect/registry.py +51 -0
- yaddd_utils-0.1.0/src/yaddd_utils/inspect/scenario.py +97 -0
- yaddd_utils-0.1.0/src/yaddd_utils/mermaid/__init__.py +21 -0
- yaddd_utils-0.1.0/src/yaddd_utils/mermaid/_adapter.py +48 -0
- yaddd_utils-0.1.0/src/yaddd_utils/mermaid/_models.py +55 -0
- yaddd_utils-0.1.0/src/yaddd_utils/mermaid/cli.py +115 -0
- yaddd_utils-0.1.0/src/yaddd_utils/mermaid/graph.py +83 -0
- yaddd_utils-0.1.0/src/yaddd_utils/mermaid/renderers.py +31 -0
- yaddd_utils-0.1.0/src/yaddd_utils/mermaid/scenario.py +86 -0
- yaddd_utils-0.1.0/src/yaddd_utils/mermaid/sequence.py +78 -0
- yaddd_utils-0.1.0/src/yaddd_utils/mermaid/state.py +43 -0
- yaddd_utils-0.1.0/src/yaddd_utils/py.typed +0 -0
- yaddd_utils-0.1.0/tests/unit/__init__.py +0 -0
- yaddd_utils-0.1.0/tests/unit/inspect/__init__.py +0 -0
- yaddd_utils-0.1.0/tests/unit/inspect/test_scenario.py +46 -0
- yaddd_utils-0.1.0/tests/unit/mermaid/__init__.py +0 -0
- yaddd_utils-0.1.0/tests/unit/mermaid/test_renderers.py +52 -0
|
@@ -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,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
|