pytestflow 0.2.0__py3-none-any.whl

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 (63) hide show
  1. bootstrap_templates/__init__.py +0 -0
  2. bootstrap_templates/config.yaml +13 -0
  3. bootstrap_templates/custom_step_types/__init__.py +0 -0
  4. bootstrap_templates/custom_step_types/custom_step_template.py +7 -0
  5. bootstrap_templates/process_models/__init__.py +0 -0
  6. bootstrap_templates/process_models/reporting/README.md +48 -0
  7. bootstrap_templates/process_models/reporting/__init__.py +0 -0
  8. bootstrap_templates/process_models/reporting/default_report.html.j2 +122 -0
  9. bootstrap_templates/process_models/reporting/html_report.py +331 -0
  10. bootstrap_templates/process_models/sequential_model.py +141 -0
  11. bootstrap_templates/test_sequences/__init__.py +0 -0
  12. bootstrap_templates/test_sequences/basic_sequence.py +62 -0
  13. bootstrap_templates/test_sequences/message_box_and_flow_control.py +169 -0
  14. bootstrap_templates/test_sequences/motherboard_test_sequence.py +125 -0
  15. bootstrap_templates/test_sequences/step_types_quickstart.py +168 -0
  16. pytestflow/README.md +13 -0
  17. pytestflow/__init__.py +19 -0
  18. pytestflow/backend/__init__.py +2 -0
  19. pytestflow/backend/event_bus.py +27 -0
  20. pytestflow/backend/frontend/assets/full_logo-D1DRTUt8.svg +21 -0
  21. pytestflow/backend/frontend/assets/index-480TOyh4.js +2 -0
  22. pytestflow/backend/frontend/assets/index-qEI3VAQU.css +1 -0
  23. pytestflow/backend/frontend/index.html +14 -0
  24. pytestflow/backend/frontend/logo.svg +21 -0
  25. pytestflow/backend/handlers.py +214 -0
  26. pytestflow/backend/report_manager.py +15 -0
  27. pytestflow/backend/sequences_info.py +130 -0
  28. pytestflow/backend/start_backend.py +118 -0
  29. pytestflow/backend/uuids_handler.py +67 -0
  30. pytestflow/backend/websocket_gateway.py +91 -0
  31. pytestflow/cli.py +183 -0
  32. pytestflow/config/__init__.py +0 -0
  33. pytestflow/config/config_manager.py +44 -0
  34. pytestflow/core/README.md +110 -0
  35. pytestflow/core/__init__.py +15 -0
  36. pytestflow/core/context.py +41 -0
  37. pytestflow/core/core.py +112 -0
  38. pytestflow/core/pytestflow_states.py +88 -0
  39. pytestflow/core/runtime_control.py +164 -0
  40. pytestflow/core/seq_file_runner.py +38 -0
  41. pytestflow/core/sequence.py +404 -0
  42. pytestflow/core/utils.py +81 -0
  43. pytestflow/flow_utils/README.md +6 -0
  44. pytestflow/flow_utils/__init__.py +0 -0
  45. pytestflow/flow_utils/conditions.py +0 -0
  46. pytestflow/flow_utils/transitions.py +0 -0
  47. pytestflow/starter_here.md +43 -0
  48. pytestflow/steps/README.md +43 -0
  49. pytestflow/steps/__init__.py +15 -0
  50. pytestflow/steps/action_step.py +94 -0
  51. pytestflow/steps/common.py +51 -0
  52. pytestflow/steps/df_numeric_limits.py +151 -0
  53. pytestflow/steps/flow_control.py +86 -0
  54. pytestflow/steps/message_pop_up.py +76 -0
  55. pytestflow/steps/numeric_limit.py +109 -0
  56. pytestflow/steps/pass_fail.py +49 -0
  57. pytestflow/steps/string_check.py +104 -0
  58. pytestflow/steps/waveform_limit.py +170 -0
  59. pytestflow-0.2.0.dist-info/METADATA +73 -0
  60. pytestflow-0.2.0.dist-info/RECORD +63 -0
  61. pytestflow-0.2.0.dist-info/WHEEL +5 -0
  62. pytestflow-0.2.0.dist-info/entry_points.txt +2 -0
  63. pytestflow-0.2.0.dist-info/top_level.txt +2 -0
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
File without changes
@@ -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
+
File without changes
@@ -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
@@ -0,0 +1,141 @@
1
+ from pytestflow.core.sequence import Sequence
2
+ from pytestflow.steps.action_step import action_step
3
+ from pytestflow.core.context import ptf_context
4
+ from pytestflow.steps.flow_control import flow_control_step
5
+ from pytestflow.steps.message_pop_up import message_pop_up_step
6
+ from pytestflow.backend.report_manager import report_manager
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ PROCESS_MODELS_DIR = Path(__file__).resolve().parent
11
+ sys.path.insert(0, str(PROCESS_MODELS_DIR))
12
+
13
+ from bootstrap_templates.process_models.reporting.html_report import report_callback_jinja
14
+
15
+
16
+ # ---------------------
17
+ # DEFAULT CALLBACKS
18
+ # ---------------------
19
+
20
+ # ✅ Pre-UUT: ask user to enter SN
21
+ @message_pop_up_step(
22
+ show_response_box=True,
23
+ title="User Input",
24
+ msg="Enter Serial Number",
25
+ buttons=["Run", "Cancel"],
26
+ name="pre_uut_callback"
27
+ )
28
+ def pre_uut_callback():
29
+ pass # logic handled by decorator
30
+
31
+ # ✅ Post-UUT: (placeholder)
32
+ @action_step(name="post_uut_callback")
33
+ def post_uut_callback():
34
+ print("📝 Post-UUT executed.")
35
+ pass
36
+
37
+
38
+ # ✅ Database logging (placeholder)
39
+ @action_step(name="database_logging_callback")
40
+ def database_logging_callback():
41
+ print("💾 Database logging executed.")
42
+ return "DB logging done"
43
+
44
+
45
+ DEFAULT_CALLBACKS = {
46
+ "pre_uut": pre_uut_callback,
47
+ "main_sequence": None, # mandatory
48
+ "post_uut": post_uut_callback,
49
+ "report": report_callback_jinja,
50
+ "database_logging": database_logging_callback,
51
+ }
52
+
53
+
54
+ class SequentialProcessModel(Sequence):
55
+ def __init__(
56
+ self,
57
+ name: str = "SequentialProcessModel",
58
+ callbacks: dict | None = None,
59
+ ):
60
+ # Validate main sequence presence
61
+ callbacks = callbacks or {}
62
+ # Merge with defaults
63
+ self.callbacks = {**DEFAULT_CALLBACKS, **callbacks}
64
+ self._pre_uut_step_name = None
65
+
66
+ assert self.callbacks["main_sequence"] is not None, "Main Seq callback must be provided."
67
+
68
+ pre_uut_step = self.callbacks.get("pre_uut")
69
+ if pre_uut_step is not None:
70
+ self._pre_uut_step_name = getattr(pre_uut_step, "name", getattr(pre_uut_step, "__name__", "pre_uut"))
71
+
72
+ @flow_control_step(name="pre_uut_flow_gate", next_steps={0: "end", 1: "next"})
73
+ def pre_uut_flow_gate():
74
+ user_response = ptf_context.locals.get("_pre_uut_user_response")
75
+ button = self._extract_button_from_response(user_response)
76
+ return button == "run"
77
+
78
+ steps = []
79
+ if pre_uut_step is not None:
80
+ steps.append(pre_uut_step)
81
+ steps.append(pre_uut_flow_gate)
82
+
83
+ main_sequence = self.callbacks["main_sequence"]
84
+
85
+ @action_step(name="main_sequence_step", store_as="main_results")
86
+ def main_sequence_step():
87
+ if isinstance(main_sequence, Sequence):
88
+ main_state = main_sequence.run(return_state=True)
89
+ elif hasattr(main_sequence, "run"):
90
+ main_state = main_sequence.run(return_state=True)
91
+ else:
92
+ try:
93
+ main_state = main_sequence(return_state=True)
94
+ except TypeError:
95
+ main_state = main_sequence()
96
+
97
+ # Backward compatibility for existing integrations that still read `main_result`.
98
+ ptf_context.locals["main_result"] = main_state
99
+ ptf_context.locals["__last_result__"] = main_state
100
+ return main_state
101
+
102
+ steps.append(main_sequence_step)
103
+ if self.callbacks.get("post_uut") is not None:
104
+ steps.append(self.callbacks["post_uut"])
105
+ if self.callbacks.get("report") is not None:
106
+ steps.append(self.callbacks["report"])
107
+ if self.callbacks.get("database_logging") is not None:
108
+ steps.append(self.callbacks["database_logging"])
109
+
110
+ # Build Sequence
111
+ super().__init__(name=name, steps=steps, apply_throttle=False)
112
+
113
+ def _extract_button_from_response(self, user_response) -> str | None:
114
+ if isinstance(user_response, dict):
115
+ button = user_response.get("button")
116
+ if button is not None:
117
+ return str(button).strip().lower()
118
+ if user_response is None:
119
+ return None
120
+ return str(user_response).strip().lower()
121
+
122
+ def _extract_pre_uut_user_response(self, state):
123
+ ptf_result = getattr(state, "ptf_result", {}) or {}
124
+ if "user_response" in ptf_result:
125
+ return ptf_result.get("user_response")
126
+
127
+ output = ptf_result.get("output")
128
+ if isinstance(output, dict):
129
+ if "user_response" in output:
130
+ return output.get("user_response")
131
+ if "button" in output:
132
+ return output
133
+ return output
134
+
135
+ def _exec_step(self, step_fn):
136
+ name, state = super()._exec_step(step_fn)
137
+ if self._pre_uut_step_name and name == self._pre_uut_step_name:
138
+ ptf_context.locals["_pre_uut_user_response"] = self._extract_pre_uut_user_response(state)
139
+ return name, state
140
+
141
+ PROCESS_MODEL = SequentialProcessModel
File without changes