pytestflow 0.2.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.
Files changed (70) hide show
  1. pytestflow-0.2.0/MANIFEST.in +7 -0
  2. pytestflow-0.2.0/PKG-INFO +73 -0
  3. pytestflow-0.2.0/bootstrap_templates/__init__.py +0 -0
  4. pytestflow-0.2.0/bootstrap_templates/config.yaml +13 -0
  5. pytestflow-0.2.0/bootstrap_templates/custom_step_types/__init__.py +0 -0
  6. pytestflow-0.2.0/bootstrap_templates/custom_step_types/custom_step_template.py +7 -0
  7. pytestflow-0.2.0/bootstrap_templates/process_models/__init__.py +0 -0
  8. pytestflow-0.2.0/bootstrap_templates/process_models/reporting/README.md +48 -0
  9. pytestflow-0.2.0/bootstrap_templates/process_models/reporting/__init__.py +0 -0
  10. pytestflow-0.2.0/bootstrap_templates/process_models/reporting/default_report.html.j2 +122 -0
  11. pytestflow-0.2.0/bootstrap_templates/process_models/reporting/html_report.py +331 -0
  12. pytestflow-0.2.0/bootstrap_templates/process_models/sequential_model.py +141 -0
  13. pytestflow-0.2.0/bootstrap_templates/test_sequences/__init__.py +0 -0
  14. pytestflow-0.2.0/bootstrap_templates/test_sequences/basic_sequence.py +62 -0
  15. pytestflow-0.2.0/bootstrap_templates/test_sequences/message_box_and_flow_control.py +169 -0
  16. pytestflow-0.2.0/bootstrap_templates/test_sequences/motherboard_test_sequence.py +125 -0
  17. pytestflow-0.2.0/bootstrap_templates/test_sequences/step_types_quickstart.py +168 -0
  18. pytestflow-0.2.0/pyproject.toml +64 -0
  19. pytestflow-0.2.0/pytestflow/README.md +13 -0
  20. pytestflow-0.2.0/pytestflow/__init__.py +19 -0
  21. pytestflow-0.2.0/pytestflow/backend/__init__.py +2 -0
  22. pytestflow-0.2.0/pytestflow/backend/event_bus.py +27 -0
  23. pytestflow-0.2.0/pytestflow/backend/frontend/assets/full_logo-D1DRTUt8.svg +21 -0
  24. pytestflow-0.2.0/pytestflow/backend/frontend/assets/index-480TOyh4.js +2 -0
  25. pytestflow-0.2.0/pytestflow/backend/frontend/assets/index-qEI3VAQU.css +1 -0
  26. pytestflow-0.2.0/pytestflow/backend/frontend/index.html +14 -0
  27. pytestflow-0.2.0/pytestflow/backend/frontend/logo.svg +21 -0
  28. pytestflow-0.2.0/pytestflow/backend/handlers.py +214 -0
  29. pytestflow-0.2.0/pytestflow/backend/report_manager.py +15 -0
  30. pytestflow-0.2.0/pytestflow/backend/sequences_info.py +130 -0
  31. pytestflow-0.2.0/pytestflow/backend/start_backend.py +118 -0
  32. pytestflow-0.2.0/pytestflow/backend/uuids_handler.py +67 -0
  33. pytestflow-0.2.0/pytestflow/backend/websocket_gateway.py +91 -0
  34. pytestflow-0.2.0/pytestflow/cli.py +183 -0
  35. pytestflow-0.2.0/pytestflow/config/__init__.py +0 -0
  36. pytestflow-0.2.0/pytestflow/config/config_manager.py +44 -0
  37. pytestflow-0.2.0/pytestflow/core/README.md +110 -0
  38. pytestflow-0.2.0/pytestflow/core/__init__.py +15 -0
  39. pytestflow-0.2.0/pytestflow/core/context.py +41 -0
  40. pytestflow-0.2.0/pytestflow/core/core.py +112 -0
  41. pytestflow-0.2.0/pytestflow/core/pytestflow_states.py +88 -0
  42. pytestflow-0.2.0/pytestflow/core/runtime_control.py +164 -0
  43. pytestflow-0.2.0/pytestflow/core/seq_file_runner.py +38 -0
  44. pytestflow-0.2.0/pytestflow/core/sequence.py +404 -0
  45. pytestflow-0.2.0/pytestflow/core/utils.py +81 -0
  46. pytestflow-0.2.0/pytestflow/flow_utils/README.md +6 -0
  47. pytestflow-0.2.0/pytestflow/flow_utils/__init__.py +0 -0
  48. pytestflow-0.2.0/pytestflow/flow_utils/conditions.py +0 -0
  49. pytestflow-0.2.0/pytestflow/flow_utils/transitions.py +0 -0
  50. pytestflow-0.2.0/pytestflow/starter_here.md +43 -0
  51. pytestflow-0.2.0/pytestflow/steps/README.md +43 -0
  52. pytestflow-0.2.0/pytestflow/steps/__init__.py +15 -0
  53. pytestflow-0.2.0/pytestflow/steps/action_step.py +94 -0
  54. pytestflow-0.2.0/pytestflow/steps/common.py +51 -0
  55. pytestflow-0.2.0/pytestflow/steps/df_numeric_limits.py +151 -0
  56. pytestflow-0.2.0/pytestflow/steps/flow_control.py +86 -0
  57. pytestflow-0.2.0/pytestflow/steps/message_pop_up.py +76 -0
  58. pytestflow-0.2.0/pytestflow/steps/numeric_limit.py +109 -0
  59. pytestflow-0.2.0/pytestflow/steps/pass_fail.py +49 -0
  60. pytestflow-0.2.0/pytestflow/steps/string_check.py +104 -0
  61. pytestflow-0.2.0/pytestflow/steps/waveform_limit.py +170 -0
  62. pytestflow-0.2.0/pytestflow.egg-info/PKG-INFO +73 -0
  63. pytestflow-0.2.0/pytestflow.egg-info/SOURCES.txt +68 -0
  64. pytestflow-0.2.0/pytestflow.egg-info/dependency_links.txt +1 -0
  65. pytestflow-0.2.0/pytestflow.egg-info/entry_points.txt +2 -0
  66. pytestflow-0.2.0/pytestflow.egg-info/requires.txt +8 -0
  67. pytestflow-0.2.0/pytestflow.egg-info/top_level.txt +2 -0
  68. pytestflow-0.2.0/readme.md +45 -0
  69. pytestflow-0.2.0/setup.cfg +4 -0
  70. pytestflow-0.2.0/setup.py +4 -0
@@ -0,0 +1,7 @@
1
+ include readme.md
2
+ include pytestflow/starter_here.md
3
+ recursive-include bootstrap_templates *
4
+ recursive-include pytestflow/backend/frontend *
5
+ global-exclude __pycache__/*
6
+ global-exclude *.py[cod]
7
+ global-exclude .pytest_cache/*
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytestflow
3
+ Version: 0.2.0
4
+ Summary: Test automation framework inspired by NI TestStand, built on Prefect
5
+ Author-email: Alberto Manzoni <alb.manzoni@gmail.com>
6
+ Project-URL: Homepage, https://github.com/Alberto-Manzoni/PyTestFlow
7
+ Project-URL: Repository, https://github.com/Alberto-Manzoni/PyTestFlow
8
+ Project-URL: Issues, https://github.com/Alberto-Manzoni/PyTestFlow/issues
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Testing
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: prefect==3.6.24
21
+ Requires-Dist: pandas
22
+ Requires-Dist: numpy
23
+ Requires-Dist: rich
24
+ Requires-Dist: bottle
25
+ Requires-Dist: websockets
26
+ Requires-Dist: fakeredis==2.34.1
27
+ Requires-Dist: jinja2
28
+
29
+ # PyTestFlow
30
+
31
+ PyTestFlow is a Python test executive built on top of Prefect.
32
+ It turns decorated Python functions into traceable test steps and runs them in
33
+ ordered flows.
34
+
35
+ > Requires `prefect>=3.4` and Python 3.10+.
36
+
37
+ ## Repositories
38
+
39
+ - Engine: https://github.com/Alberto-Manzoni/PyTestFlow
40
+ - Frontend: https://github.com/Alberto-Manzoni/PyTestFlow-FrontEnd
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ python -m pip install pytestflow
46
+ ```
47
+
48
+ ### Init the workspace
49
+
50
+ ```bash
51
+ pytestflow init
52
+ ```
53
+
54
+ Set the environment variable as suggested from the CLI
55
+
56
+
57
+ ## Usage
58
+
59
+ ```bash
60
+ pytestflow start
61
+ ```
62
+ Open the web gui at the url indicated by the CLI.
63
+
64
+
65
+
66
+ ## Core concepts
67
+
68
+ - Steps are Prefect tasks defined using `@step` or specialized step decorators.
69
+ - A `TestSequence` is a Prefect flow that aggregates and executes multiple steps, tracking their states.
70
+ - `ptf_context` is a shared runtime context that provides access to `globals`, `locals`, `results`, and `current_step`.
71
+ - `SequentialProcessModel` orchestrates execution callbacks in a fixed lifecycle:
72
+ `pre_uut -> main_sequence -> post_uut -> report -> database_logging`.
73
+ - The main output of the process model is stored in both `main_results` and `main_result` for backward compatibility.
File without changes
@@ -0,0 +1,13 @@
1
+ http:
2
+ host: 127.0.0.1
3
+ port: 8001
4
+
5
+ websocket:
6
+ host: 127.0.0.1
7
+ port: 8766
8
+
9
+
10
+ process_models: process_models
11
+ test_sequences: test_sequences
12
+ test_reports: test_reports
13
+ custom_step_types: custom_step_types
@@ -0,0 +1,7 @@
1
+ from pytestflow.steps.action_step import action_step
2
+
3
+
4
+ @action_step(name="my_custom_step")
5
+ def my_custom_step():
6
+ return "Implement your custom logic here"
7
+
@@ -0,0 +1,48 @@
1
+ # Reporting
2
+
3
+ Modules that transform aggregated `PyTestflowState` data into JSON or HTML
4
+ reports. Reporting flows typically run from the process model's `report`
5
+ callback, consuming `ptf_context.locals["main_result"]` to present sequence and
6
+ step outcomes captured by Prefect.
7
+
8
+ ## Jinja HTML Reporter (opt-in)
9
+
10
+ `html_report.py` remains the default/legacy path.
11
+
12
+ To opt into Jinja-based rendering, use:
13
+
14
+ - `bootstrap_templates.process_models.reporting.html_jinja_report.generate_html_jinja_report`
15
+ - `bootstrap_templates.process_models.reporting.html_jinja_report.report_callback_jinja`
16
+
17
+ If `jinja2` is missing, only the Jinja reporter path raises a friendly runtime
18
+ error with install guidance. Existing `html_report.py` behavior is unchanged.
19
+
20
+ ### Process model callback swap example
21
+
22
+ ```python
23
+ from bootstrap_templates.process_models.sequential_model import SequentialProcessModel
24
+ from bootstrap_templates.process_models.reporting.html_jinja_report import report_callback_jinja
25
+
26
+ process_model = SequentialProcessModel(
27
+ callbacks={
28
+ "main_sequence": main_seq,
29
+ "pre_uut": None,
30
+ "post_uut": None,
31
+ "report": report_callback_jinja, # opt-in
32
+ "database_logging": None,
33
+ }
34
+ )
35
+ ```
36
+
37
+ ### Direct generation with template/CSS overrides
38
+
39
+ ```python
40
+ from bootstrap_templates.process_models.reporting.html_jinja_report import generate_html_jinja_report
41
+
42
+ report_path = generate_html_jinja_report(
43
+ serial_number="SN-001",
44
+ root_state=main_results,
45
+ template_path="my_template.html.j2", # optional
46
+ css_path="my_report.css", # optional
47
+ )
48
+ ```
@@ -0,0 +1,122 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>{{ title }}</title>
7
+ <style>
8
+ {{ css_text }}
9
+ </style>
10
+ </head>
11
+ <body>
12
+ <h1>
13
+ {{ title }}
14
+ <span class="badge {{ top_status_class }}">{{ top_status }}</span>
15
+ </h1>
16
+
17
+ <div class="meta">
18
+ <div><b>Generated:</b> {{ generated_at }}</div>
19
+ {% if top_message %}
20
+ <div><b>Message:</b> {{ top_message }}</div>
21
+ {% endif %}
22
+ <div><b>State Type:</b> <code class="inline">{{ top_type }}</code></div>
23
+ <div>
24
+ <b>Steps:</b>
25
+ {{ stats.total }} total -
26
+ {{ stats.passed }} passed /
27
+ {{ stats.failed }} failed /
28
+ {{ stats.done }} done /
29
+ {{ stats.other }} other
30
+ </div>
31
+ </div>
32
+
33
+ <h2>Summary</h2>
34
+ <table class="summary-table">
35
+ <thead>
36
+ <tr>
37
+ <th width="60">#</th>
38
+ <th>Step</th>
39
+ <th width="120">Status</th>
40
+ <th width="120">State</th>
41
+ <th width="120">Subsequence</th>
42
+ </tr>
43
+ </thead>
44
+ <tbody>
45
+ {% for row in summary_rows %}
46
+ <tr>
47
+ <td>{{ "%03d"|format(row.index) }}</td>
48
+ <td><a href="#{{ row.anchor_id }}">{{ row.label }}</a></td>
49
+ <td><span class="badge {{ row.status_class }}">{{ row.status }}</span></td>
50
+ <td><code class="inline">{{ row.type_label }}</code></td>
51
+ <td><code class="inline">{{ "Yes" if row.is_subsequence else "No" }}</code></td>
52
+ </tr>
53
+ {% endfor %}
54
+ </tbody>
55
+ </table>
56
+
57
+ <h2>Contents</h2>
58
+ <ul class="toc">
59
+ {% for item in details %}
60
+ <li style="margin-left: {{ item.depth * 12 }}px;">
61
+ <a href="#{{ item.anchor_id }}">{{ item.display_label }}</a>
62
+ </li>
63
+ {% endfor %}
64
+ </ul>
65
+
66
+ <h2>Details</h2>
67
+ {% for item in details %}
68
+ <div id="{{ item.anchor_id }}" class="{{ 'indent' if item.depth > 0 else '' }}">
69
+ <h3>
70
+ {{ item.display_label }}
71
+ <span class="badge {{ item.status_class }}">{{ item.status }}</span>
72
+ </h3>
73
+
74
+ <div class="row">
75
+ <div><b>PyTestFlow State:</b> <code class="inline">{{ item.meta.ptf_state_type }}</code></div>
76
+ <div><b>Prefect State:</b> <code class="inline">{{ item.meta.prefect_state_type }}</code></div>
77
+ {% if item.meta.step_type %}
78
+ <div><b>Step Type:</b> <code class="inline">{{ item.meta.step_type }}</code></div>
79
+ {% endif %}
80
+ {% if item.meta.run_id_value %}
81
+ <div><b>{{ item.meta.run_id_label }}:</b> <code class="inline">{{ item.meta.run_id_value }}</code></div>
82
+ {% endif %}
83
+ {% if item.message %}
84
+ <div><b>Message:</b> {{ item.message }}</div>
85
+ {% endif %}
86
+ </div>
87
+
88
+ {% if item.step_details %}
89
+ <table class="summary-table">
90
+ <thead>
91
+ <tr><th colspan="2">Step Details</th></tr>
92
+ </thead>
93
+ <tbody>
94
+ {% for key, value in item.step_details.items() %}
95
+ <tr>
96
+ <td><b>{{ key }}</b></td>
97
+ <td>{{ value }}</td>
98
+ </tr>
99
+ {% endfor %}
100
+ </tbody>
101
+ </table>
102
+ {% endif %}
103
+
104
+ {% if item.meta.additional_info %}
105
+ <table class="summary-table">
106
+ <thead>
107
+ <tr><th colspan="2">Additional Info</th></tr>
108
+ </thead>
109
+ <tbody>
110
+ {% for key, value in item.meta.additional_info.items() %}
111
+ <tr>
112
+ <td><b>{{ key }}</b></td>
113
+ <td>{{ value }}</td>
114
+ </tr>
115
+ {% endfor %}
116
+ </tbody>
117
+ </table>
118
+ {% endif %}
119
+ </div>
120
+ {% endfor %}
121
+ </body>
122
+ </html>
@@ -0,0 +1,331 @@
1
+ from __future__ import annotations
2
+ import os
3
+ import re
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Any
7
+ from pytestflow.backend.report_manager import report_manager
8
+ from pytestflow.config.config_manager import ConfigManager
9
+ from pytestflow.core.context import ptf_context
10
+ from pytestflow.steps.action_step import action_step
11
+
12
+
13
+ DEFAULT_TEMPLATE_PATH = Path(__file__).resolve().parent / "default_report.html.j2"
14
+ DEFAULT_CSS = """
15
+ :root { --fg:#222; --bg:#fff; --muted:#666; --border:#ddd; }
16
+ body { font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif; color:var(--fg); background:var(--bg); margin:24px; line-height:1.4; }
17
+ h1 { margin:0 0 6px 0; }
18
+ h2 { margin-top: 20px; }
19
+ h3 { margin: 8px 0; }
20
+ .meta { color:var(--muted); margin-bottom:20px; }
21
+ .summary-table { width:100%; border-collapse: collapse; margin: 8px 0 18px 0; }
22
+ .summary-table th, .summary-table td { padding:8px 10px; border-bottom:1px solid var(--border); vertical-align: top; text-align: left; }
23
+ .summary-table th { background:#fafafa; }
24
+ .row { display:flex; gap:8px; align-items:center; flex-wrap: wrap; }
25
+ .indent { padding-left: 22px; border-left: 3px solid #f0f0f0; margin: 6px 0 10px 0; }
26
+ .badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:12px; font-weight:600; }
27
+ .badge-pass { background:#e8f8ee; color:#117a3a; border:1px solid #bfead0; }
28
+ .badge-fail { background:#fdeceb; color:#a1130f; border:1px solid #f3c2bf; }
29
+ .badge-other { background:#eef2ff; color:#283593; border:1px solid #c5cae9; }
30
+ code.inline { background:#f7f7f9; padding:2px 4px; border-radius:4px; }
31
+ ul.toc { margin: 8px 0 20px 0; }
32
+ ul.toc li { margin: 3px 0; }
33
+ """
34
+
35
+
36
+ def _load_jinja2():
37
+ try:
38
+ import jinja2
39
+ except Exception as exc: # pragma: no cover - exercised via tests with monkeypatch
40
+ raise RuntimeError(
41
+ "Jinja reporting requires 'jinja2'. Install it with: pip install jinja2"
42
+ ) from exc
43
+ return jinja2
44
+
45
+
46
+ def _get_reports_folder() -> str:
47
+ try:
48
+ config = ConfigManager().get_config()
49
+ return config["test_reports"]
50
+ except Exception:
51
+ return "test_reports"
52
+
53
+
54
+ REPORTS_FOLDER = _get_reports_folder()
55
+
56
+
57
+ def _ensure_dir(path: str) -> None:
58
+ os.makedirs(path, exist_ok=True)
59
+
60
+
61
+ def _now_ts() -> str:
62
+ return datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
63
+
64
+
65
+ def _slug(text: str) -> str:
66
+ text = re.sub(r"\s+", "-", str(text).strip())
67
+ text = re.sub(r"[^a-zA-Z0-9\-_.]+", "", text)
68
+ return text or "item"
69
+
70
+
71
+ def _extract_children(state: Any) -> list[tuple[str, Any]]:
72
+ return list(getattr(state, "children", []) or [])
73
+
74
+
75
+ def _extract_message(state: Any) -> str:
76
+ return getattr(state, "message", "") or ""
77
+
78
+
79
+ def _extract_status_name(state: Any) -> str:
80
+ name = getattr(state, "name", None)
81
+ if name:
82
+ return str(name)
83
+ return state.__class__.__name__
84
+
85
+
86
+ def _extract_type_label(state: Any) -> str:
87
+ return str(getattr(state, "type", "")) or ""
88
+
89
+
90
+ def _guess_step_label(name: str, state: Any) -> str:
91
+ ptf_result = getattr(state, "ptf_result", None)
92
+ if isinstance(ptf_result, dict) and "step_name" in ptf_result:
93
+ return str(ptf_result["step_name"])
94
+ msg = _extract_message(state)
95
+ if name.startswith("<pytestflow.core.sequence.Sequence"):
96
+ match = re.search(r"Sequence\s+(.+?)\s+(passed|failed)", msg, flags=re.I)
97
+ if match:
98
+ return f"{match.group(1)} (subsequence)"
99
+ return "Subsequence"
100
+ return name
101
+
102
+
103
+ def _status_class(status: str) -> str:
104
+ status_upper = status.upper()
105
+ if "PAS" in status_upper:
106
+ return "badge-pass"
107
+ if "FAIL" in status_upper or status_upper == "ERROR":
108
+ return "badge-fail"
109
+ return "badge-other"
110
+
111
+
112
+ def _collect_step_stats(state: Any) -> dict[str, int]:
113
+ stats = {"passed": 0, "failed": 0, "done": 0, "other": 0, "total": 0}
114
+ for _, child_state in _extract_children(state):
115
+ stats["total"] += 1
116
+ status = _extract_status_name(child_state).lower()
117
+ if "pass" in status:
118
+ stats["passed"] += 1
119
+ elif "fail" in status:
120
+ stats["failed"] += 1
121
+ elif "done" in status:
122
+ stats["done"] += 1
123
+ else:
124
+ stats["other"] += 1
125
+ return stats
126
+
127
+
128
+ def _extract_step_details(state: Any) -> dict[str, Any]:
129
+ ptf_result = getattr(state, "ptf_result", None)
130
+ if not isinstance(ptf_result, dict):
131
+ return {}
132
+
133
+ excluded = {
134
+ "step_name",
135
+ "flow_run_id",
136
+ "flow_name",
137
+ "task_run_id",
138
+ "task_name",
139
+ "additional_info",
140
+ }
141
+ details: dict[str, Any] = {}
142
+ for key, value in ptf_result.items():
143
+ if key in excluded:
144
+ continue
145
+ details[str(key)] = value
146
+ return details
147
+
148
+
149
+ def _extract_meta(state: Any) -> dict[str, Any]:
150
+ ptf_result = getattr(state, "ptf_result", None)
151
+ task_run_id = getattr(state, "task_run_id", None)
152
+ flow_run_id = getattr(state, "flow_run_id", None)
153
+ has_children = bool(_extract_children(state))
154
+ run_id_label = "Flow Run ID" if has_children else "Task Run ID"
155
+ run_id_value = flow_run_id if has_children else task_run_id
156
+
157
+ meta: dict[str, Any] = {
158
+ "ptf_state_type": state.__class__.__name__,
159
+ "prefect_state_type": _extract_type_label(state),
160
+ "run_id_label": run_id_label,
161
+ "run_id_value": run_id_value,
162
+ }
163
+
164
+ if isinstance(ptf_result, dict):
165
+ step_type = ptf_result.get("step_type")
166
+ if step_type is not None:
167
+ meta["step_type"] = step_type
168
+ additional_info = ptf_result.get("additional_info")
169
+ if isinstance(additional_info, dict):
170
+ meta["additional_info"] = additional_info
171
+ return meta
172
+
173
+
174
+ def _build_tree_node(
175
+ state: Any,
176
+ *,
177
+ name: str,
178
+ index: str,
179
+ root_anchor: str,
180
+ depth: int,
181
+ ) -> dict[str, Any]:
182
+ label = _guess_step_label(name, state)
183
+ anchor_id = _slug(f"{root_anchor}-{index}-{label}")
184
+ status = _extract_status_name(state)
185
+ children: list[dict[str, Any]] = []
186
+ for child_idx, (child_name, child_state) in enumerate(_extract_children(state), start=1):
187
+ child_index = f"{index}.{child_idx}" if index else str(child_idx)
188
+ children.append(
189
+ _build_tree_node(
190
+ child_state,
191
+ name=str(child_name),
192
+ index=child_index,
193
+ root_anchor=root_anchor,
194
+ depth=depth + 1,
195
+ )
196
+ )
197
+
198
+ return {
199
+ "label": label,
200
+ "index": index,
201
+ "display_label": f"{index} - {label}" if index else label,
202
+ "anchor_id": anchor_id,
203
+ "status": status,
204
+ "status_class": _status_class(status),
205
+ "type_label": _extract_type_label(state),
206
+ "message": _extract_message(state),
207
+ "is_subsequence": bool(children),
208
+ "meta": _extract_meta(state),
209
+ "step_details": _extract_step_details(state),
210
+ "children": children,
211
+ "depth": depth,
212
+ }
213
+
214
+
215
+ def _flatten_tree(node: dict[str, Any], out: list[dict[str, Any]]) -> None:
216
+ out.append(node)
217
+ for child in node["children"]:
218
+ _flatten_tree(child, out)
219
+
220
+
221
+ def _extract_serial_number_from_context() -> str:
222
+ user_response = ptf_context.locals.get("_pre_uut_user_response")
223
+ if not isinstance(user_response, dict):
224
+ return ""
225
+ return str(user_response.get("text", "") or "")
226
+
227
+
228
+ def build_report_context(serial_number: str, root_state: Any) -> dict[str, Any]:
229
+ generated_at = _now_ts()
230
+ title = "Test Sequence Report" + (f" - SN: {serial_number}" if serial_number else "")
231
+ root_anchor = _slug(f"root-{title}")
232
+
233
+ root_node = _build_tree_node(
234
+ root_state,
235
+ name=title,
236
+ index="0",
237
+ root_anchor=root_anchor,
238
+ depth=0,
239
+ )
240
+ details: list[dict[str, Any]] = []
241
+ _flatten_tree(root_node, details)
242
+
243
+ summary_rows = []
244
+ for i, child in enumerate(root_node["children"], start=1):
245
+ summary_rows.append(
246
+ {
247
+ "index": i,
248
+ "label": child["label"],
249
+ "anchor_id": child["anchor_id"],
250
+ "status": child["status"],
251
+ "status_class": child["status_class"],
252
+ "type_label": child["type_label"],
253
+ "is_subsequence": child["is_subsequence"],
254
+ }
255
+ )
256
+
257
+ return {
258
+ "title": title,
259
+ "serial_number": serial_number,
260
+ "generated_at": generated_at,
261
+ "top_status": _extract_status_name(root_state),
262
+ "top_status_class": _status_class(_extract_status_name(root_state)),
263
+ "top_type": _extract_type_label(root_state),
264
+ "top_message": _extract_message(root_state),
265
+ "stats": _collect_step_stats(root_state),
266
+ "summary_rows": summary_rows,
267
+ "tree": root_node,
268
+ "details": details,
269
+ }
270
+
271
+
272
+ def _read_css_text(css_path: str | None) -> str:
273
+ if not css_path:
274
+ return DEFAULT_CSS
275
+ with open(css_path, "r", encoding="utf-8") as file_obj:
276
+ return file_obj.read()
277
+
278
+
279
+ def generate_html_jinja_report(
280
+ serial_number: str,
281
+ root_state: Any,
282
+ out_dir: str | None = None,
283
+ template_path: str | None = None,
284
+ css_path: str | None = None,
285
+ ) -> str:
286
+ if out_dir is None:
287
+ out_dir = REPORTS_FOLDER
288
+ _ensure_dir(out_dir)
289
+
290
+ context = build_report_context(serial_number, root_state)
291
+ context["css_text"] = _read_css_text(css_path)
292
+
293
+ jinja2 = _load_jinja2()
294
+ if template_path:
295
+ template_file = Path(template_path).resolve()
296
+ environment = jinja2.Environment(
297
+ loader=jinja2.FileSystemLoader(str(template_file.parent)),
298
+ autoescape=jinja2.select_autoescape(["html", "xml"]),
299
+ )
300
+ template = environment.get_template(template_file.name)
301
+ else:
302
+ environment = jinja2.Environment(
303
+ loader=jinja2.FileSystemLoader(str(DEFAULT_TEMPLATE_PATH.parent)),
304
+ autoescape=jinja2.select_autoescape(["html", "xml"]),
305
+ )
306
+ template = environment.get_template(DEFAULT_TEMPLATE_PATH.name)
307
+
308
+ html_doc = template.render(**context)
309
+ filename = f"{context['generated_at']}_{_slug(context['title'])}.html"
310
+ out_path = os.path.join(out_dir, filename)
311
+ with open(out_path, "w", encoding="utf-8") as file_obj:
312
+ file_obj.write(html_doc)
313
+ return out_path
314
+
315
+
316
+ @action_step(name="report_callback_jinja")
317
+ def report_callback_jinja():
318
+ main_results = ptf_context.locals.get("main_results") or ptf_context.locals.get("main_result")
319
+ if main_results is None:
320
+ raise ValueError("Main sequence results are missing. Expected 'main_results' in context.")
321
+
322
+ serial_number = _extract_serial_number_from_context()
323
+
324
+ try:
325
+ report_path = generate_html_jinja_report(serial_number, main_results)
326
+ print(f"HTML Jinja report generated: {report_path}")
327
+ except Exception as e:
328
+ print(f"Jinja report generation failed: {e}.")
329
+
330
+ report_manager.set_last_report(report_path)
331
+ return report_path