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.
- bootstrap_templates/__init__.py +0 -0
- bootstrap_templates/config.yaml +13 -0
- bootstrap_templates/custom_step_types/__init__.py +0 -0
- bootstrap_templates/custom_step_types/custom_step_template.py +7 -0
- bootstrap_templates/process_models/__init__.py +0 -0
- bootstrap_templates/process_models/reporting/README.md +48 -0
- bootstrap_templates/process_models/reporting/__init__.py +0 -0
- bootstrap_templates/process_models/reporting/default_report.html.j2 +122 -0
- bootstrap_templates/process_models/reporting/html_report.py +331 -0
- bootstrap_templates/process_models/sequential_model.py +141 -0
- bootstrap_templates/test_sequences/__init__.py +0 -0
- bootstrap_templates/test_sequences/basic_sequence.py +62 -0
- bootstrap_templates/test_sequences/message_box_and_flow_control.py +169 -0
- bootstrap_templates/test_sequences/motherboard_test_sequence.py +125 -0
- bootstrap_templates/test_sequences/step_types_quickstart.py +168 -0
- pytestflow/README.md +13 -0
- pytestflow/__init__.py +19 -0
- pytestflow/backend/__init__.py +2 -0
- pytestflow/backend/event_bus.py +27 -0
- pytestflow/backend/frontend/assets/full_logo-D1DRTUt8.svg +21 -0
- pytestflow/backend/frontend/assets/index-480TOyh4.js +2 -0
- pytestflow/backend/frontend/assets/index-qEI3VAQU.css +1 -0
- pytestflow/backend/frontend/index.html +14 -0
- pytestflow/backend/frontend/logo.svg +21 -0
- pytestflow/backend/handlers.py +214 -0
- pytestflow/backend/report_manager.py +15 -0
- pytestflow/backend/sequences_info.py +130 -0
- pytestflow/backend/start_backend.py +118 -0
- pytestflow/backend/uuids_handler.py +67 -0
- pytestflow/backend/websocket_gateway.py +91 -0
- pytestflow/cli.py +183 -0
- pytestflow/config/__init__.py +0 -0
- pytestflow/config/config_manager.py +44 -0
- pytestflow/core/README.md +110 -0
- pytestflow/core/__init__.py +15 -0
- pytestflow/core/context.py +41 -0
- pytestflow/core/core.py +112 -0
- pytestflow/core/pytestflow_states.py +88 -0
- pytestflow/core/runtime_control.py +164 -0
- pytestflow/core/seq_file_runner.py +38 -0
- pytestflow/core/sequence.py +404 -0
- pytestflow/core/utils.py +81 -0
- pytestflow/flow_utils/README.md +6 -0
- pytestflow/flow_utils/__init__.py +0 -0
- pytestflow/flow_utils/conditions.py +0 -0
- pytestflow/flow_utils/transitions.py +0 -0
- pytestflow/starter_here.md +43 -0
- pytestflow/steps/README.md +43 -0
- pytestflow/steps/__init__.py +15 -0
- pytestflow/steps/action_step.py +94 -0
- pytestflow/steps/common.py +51 -0
- pytestflow/steps/df_numeric_limits.py +151 -0
- pytestflow/steps/flow_control.py +86 -0
- pytestflow/steps/message_pop_up.py +76 -0
- pytestflow/steps/numeric_limit.py +109 -0
- pytestflow/steps/pass_fail.py +49 -0
- pytestflow/steps/string_check.py +104 -0
- pytestflow/steps/waveform_limit.py +170 -0
- pytestflow-0.2.0.dist-info/METADATA +73 -0
- pytestflow-0.2.0.dist-info/RECORD +63 -0
- pytestflow-0.2.0.dist-info/WHEEL +5 -0
- pytestflow-0.2.0.dist-info/entry_points.txt +2 -0
- pytestflow-0.2.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# PyTestFlow Core
|
|
2
|
+
|
|
3
|
+
The `pytestflow.core` package hosts the building blocks of the framework: the
|
|
4
|
+
Prefect-integrated `Step` wrapper, shared execution context, sequence and
|
|
5
|
+
process-model flows, and the state objects returned by each step. These pieces
|
|
6
|
+
realize PyTestFlow's philosophy of **steps-as-tasks**, a **shared context**, and
|
|
7
|
+
**composable flows**.
|
|
8
|
+
|
|
9
|
+
See the [project README](../../readme.md) for an overview and
|
|
10
|
+
[steps documentation](../steps/README.md) for available step implementations.
|
|
11
|
+
|
|
12
|
+
## Context management
|
|
13
|
+
|
|
14
|
+
`ptf_context` is a singleton `TestContext` that exposes four useful attributes:
|
|
15
|
+
|
|
16
|
+
- `globals` – values shared across the entire process model run.
|
|
17
|
+
- `locals` – values scoped to the currently executing sequence.
|
|
18
|
+
- `results` – optional history of step execution results.
|
|
19
|
+
- `current_step` – pointer to the `Step` instance currently executing.
|
|
20
|
+
|
|
21
|
+
Steps can access the context implicitly through autowired parameters or
|
|
22
|
+
explicitly by importing `ptf_context`:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from pytestflow.core import ptf_context, step
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@step(name="load_config")
|
|
29
|
+
def load_config():
|
|
30
|
+
ptf_context.globals["serial"] = "ABC123"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Step wrapper
|
|
34
|
+
|
|
35
|
+
`@step` wraps a plain function with the `Step` class, which in turn wraps the
|
|
36
|
+
function with Prefect's `@task`. The wrapper collects metadata (`get_meta_info`),
|
|
37
|
+
allows enrichment (`add_additional_info`), and exposes Prefect task helpers like
|
|
38
|
+
`.submit()`.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from pytestflow.core import step
|
|
42
|
+
from pytestflow.core.pytestflow_states import PyTestflowDone
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@step(name="greet")
|
|
46
|
+
def greet(user: str) -> PyTestflowDone:
|
|
47
|
+
print(f"Hello {user}")
|
|
48
|
+
return PyTestflowDone(ptf_result={"step_status": "done", "output": user})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
If `user` is not provided explicitly, the decorator will look for
|
|
52
|
+
`ptf_context.locals["user"]` or `ptf_context.globals["user"]`.
|
|
53
|
+
|
|
54
|
+
Specialized decorators like `@numeric_limit_step` or `@action_step` rely on this
|
|
55
|
+
core wrapper and simply post-process the return value into the appropriate
|
|
56
|
+
`PyTestflowState` subclass.
|
|
57
|
+
|
|
58
|
+
## Sequences as flows
|
|
59
|
+
|
|
60
|
+
Steps execute through the `Sequence` and `TestSequence` classes, both declared as
|
|
61
|
+
Prefect flows. A `Sequence` runs an ordered list of steps, manages context scope,
|
|
62
|
+
and aggregates child `PyTestflowState` instances. `TestSequence` extends this
|
|
63
|
+
behavior with setup/main/cleanup sections.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from pytestflow.core import Sequence, step
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@step(name="measure")
|
|
70
|
+
def measure():
|
|
71
|
+
return 42
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
seq = Sequence("demo", [measure])
|
|
75
|
+
state = seq.run()
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Sequences accept `default_parameters` merged with runtime overrides. Nested
|
|
79
|
+
sequences execute in isolated local namespaces; constructing a subsequence with
|
|
80
|
+
`allow_parent_mutation=True` grants controlled write access to the parent's
|
|
81
|
+
locals via `ptf_context.locals`.
|
|
82
|
+
|
|
83
|
+
## Process models
|
|
84
|
+
|
|
85
|
+
Process models inherit from `Sequence` and coordinate the broader UUT lifecycle.
|
|
86
|
+
`SequentialProcessModel` wires callbacks for `pre_uut`, `post_uut`, `report`, and
|
|
87
|
+
`database_logging` around a main sequence and stores intermediate results in
|
|
88
|
+
`ptf_context.locals` for later callbacks.
|
|
89
|
+
|
|
90
|
+
## State objects
|
|
91
|
+
|
|
92
|
+
Every step returns a subclass of `PyTestflowState`. The most common ones are:
|
|
93
|
+
|
|
94
|
+
- `PyTestflowPassed`
|
|
95
|
+
- `PyTestflowFailed`
|
|
96
|
+
- `PyTestflowDone`
|
|
97
|
+
- `PyTestflowError`
|
|
98
|
+
|
|
99
|
+
Each state carries a `ptf_result` dictionary and optional child states when
|
|
100
|
+
returned from sequences or process models.
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
result = seq.run()
|
|
104
|
+
print(result.ptf_result["step_status"]) # sequence_passed/failed
|
|
105
|
+
for name, child in result.children:
|
|
106
|
+
print(name, child.ptf_result)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Back to [steps documentation](../steps/README.md) or the
|
|
110
|
+
[project README](../../readme.md).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .core import step,StepWrapper
|
|
2
|
+
from .context import ptf_context
|
|
3
|
+
from .pytestflow_states import PyTestflowPassed, PyTestflowFailed, PyTestflowDone, PyTestflowError
|
|
4
|
+
from .sequence import Sequence
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"step",
|
|
8
|
+
"StepWrapper",
|
|
9
|
+
"ptf_context",
|
|
10
|
+
"PyTestflowPassed",
|
|
11
|
+
"PyTestflowFailed",
|
|
12
|
+
"PyTestflowDone",
|
|
13
|
+
"PyTestflowError",
|
|
14
|
+
"Sequence",
|
|
15
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestContext:
|
|
7
|
+
"""Manages local/global variables and step results for test execution."""
|
|
8
|
+
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self.locals: Dict[str, Any] = {} # Per-sequence or per-step local vars
|
|
11
|
+
self.globals: Dict[str, Any] = {} # Shared across all steps
|
|
12
|
+
self.results: Dict[str, List[Dict[str, Any]]] = defaultdict(list) # step_name -> list of result dicts
|
|
13
|
+
self.this_context: "Context" = self # Self-ref (TestStand-style)
|
|
14
|
+
self.contex_created_timestamp: datetime = datetime.now()
|
|
15
|
+
|
|
16
|
+
# # The following methods are commented out as they are not used in the current context.
|
|
17
|
+
# def log_result(self, step_name: str, result_dict: Dict[str, Any]) -> None:
|
|
18
|
+
# """Append a structured result to the results dict."""
|
|
19
|
+
# self.results[step_name].append(result_dict)
|
|
20
|
+
|
|
21
|
+
# def get_last_result(self, step_name: str) -> Optional[Dict[str, Any]]:
|
|
22
|
+
# """Get last execution result for a given step."""
|
|
23
|
+
# return self.results[step_name][-1] if self.results[step_name] else None
|
|
24
|
+
|
|
25
|
+
# def __repr__(self) -> str:
|
|
26
|
+
# return (
|
|
27
|
+
# f"<Context locals={list(self.locals.keys())} "
|
|
28
|
+
# f"globals={list(self.globals.keys())} "
|
|
29
|
+
# f"results={len(self.results)}>"
|
|
30
|
+
# )
|
|
31
|
+
# <=== End of commented-out methods ===>
|
|
32
|
+
|
|
33
|
+
def __repr__(self) -> str:
|
|
34
|
+
return (
|
|
35
|
+
f"<Context locals={list(self.locals.keys())} "
|
|
36
|
+
f"globals={list(self.globals.keys())}>"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Global singleton instance
|
|
41
|
+
ptf_context = TestContext() # ptf_context = PyTestFlow execution context
|
pytestflow/core/core.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# core/core.py
|
|
2
|
+
from functools import wraps
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import uuid
|
|
5
|
+
from prefect import task
|
|
6
|
+
from pytestflow.core.utils import get_data_for_gui, resolve_args
|
|
7
|
+
from pytestflow.core.context import ptf_context # Import ptf_context for step injection
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def safe_getattr(obj, attr):
|
|
11
|
+
"""Safe getattr that returns None if attribute is missing or context unavailable."""
|
|
12
|
+
try:
|
|
13
|
+
return getattr(obj, attr, None)
|
|
14
|
+
except Exception:
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StepWrapper:
|
|
19
|
+
"""
|
|
20
|
+
A callable wrapper around a user function that integrates with Prefect @task,
|
|
21
|
+
while capturing runtime metadata (start time, duration, flow/task IDs).
|
|
22
|
+
Also supports user-supplied additional_info for inclusion in ptf_result.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, fn, name=None, autowire=True, **task_kwargs):
|
|
26
|
+
self.fn = fn
|
|
27
|
+
self.name = name or fn.__name__
|
|
28
|
+
self.static_uuid: uuid.UUID | None = None
|
|
29
|
+
self.autowire = autowire
|
|
30
|
+
self.task_kwargs = task_kwargs
|
|
31
|
+
|
|
32
|
+
# Metadata and additional info
|
|
33
|
+
self._stepmeta = {}
|
|
34
|
+
self.additional_info = {}
|
|
35
|
+
|
|
36
|
+
# Wrap the function with Prefect's @task
|
|
37
|
+
self.prefect_task = task(name=self.name, **task_kwargs)(self._run)
|
|
38
|
+
|
|
39
|
+
def _run(self, *args, **kwargs):
|
|
40
|
+
# Reset metadata before every run
|
|
41
|
+
self._stepmeta = {}
|
|
42
|
+
self.additional_info = {}
|
|
43
|
+
|
|
44
|
+
# Start time
|
|
45
|
+
start_time = datetime.utcnow()
|
|
46
|
+
|
|
47
|
+
# Inject the current step into ptf_context
|
|
48
|
+
ptf_context.current_step = self
|
|
49
|
+
|
|
50
|
+
# Send start to GUI
|
|
51
|
+
get_data_for_gui(self, start_time)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
# Resolve arguments if autowire is enabled
|
|
55
|
+
if self.autowire:
|
|
56
|
+
args, kwargs = resolve_args(self.fn, args, kwargs)
|
|
57
|
+
|
|
58
|
+
# Execute the original function
|
|
59
|
+
result = self.fn(*args, **kwargs)
|
|
60
|
+
|
|
61
|
+
finally:
|
|
62
|
+
# Always clear the pointer to avoid leaking across steps
|
|
63
|
+
ptf_context.current_step = None
|
|
64
|
+
|
|
65
|
+
# End time and duration
|
|
66
|
+
end_time = datetime.utcnow()
|
|
67
|
+
duration = (end_time - start_time).total_seconds()
|
|
68
|
+
|
|
69
|
+
# Collect core metadata
|
|
70
|
+
self._stepmeta = {
|
|
71
|
+
"start_time": start_time.isoformat(),
|
|
72
|
+
"end_time": end_time.isoformat(),
|
|
73
|
+
"duration_s": duration,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
def get_meta_info(self):
|
|
79
|
+
"""Return collected metadata + any user-supplied additional_info."""
|
|
80
|
+
meta = dict(self._stepmeta) if self._stepmeta else {}
|
|
81
|
+
if self.additional_info:
|
|
82
|
+
meta["additional_info"] = dict(self.additional_info)
|
|
83
|
+
return meta
|
|
84
|
+
|
|
85
|
+
def add_additional_info(self, key, value):
|
|
86
|
+
"""Add additional information to the step (will be included in ptf_result)."""
|
|
87
|
+
self.additional_info[key] = value
|
|
88
|
+
|
|
89
|
+
def __call__(self, *args, **kwargs):
|
|
90
|
+
"""Delegate execution to the Prefect task."""
|
|
91
|
+
return self.prefect_task(*args, **kwargs)
|
|
92
|
+
|
|
93
|
+
def __getattr__(self, name):
|
|
94
|
+
"""
|
|
95
|
+
Delegate attribute access to the Prefect task.
|
|
96
|
+
This allows the StepWrapper to behave like a Prefect task
|
|
97
|
+
(e.g., .fn, .name, .submit, etc.).
|
|
98
|
+
"""
|
|
99
|
+
return getattr(self.prefect_task, name)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def step(name=None, autowire=True, **task_kwargs):
|
|
103
|
+
"""
|
|
104
|
+
Decorator factory that produces a StepWrapper object.
|
|
105
|
+
Usage:
|
|
106
|
+
@step(name="my_step")
|
|
107
|
+
def foo():
|
|
108
|
+
return 42
|
|
109
|
+
"""
|
|
110
|
+
def decorator(fn):
|
|
111
|
+
return StepWrapper(fn, name=name, autowire=autowire, **task_kwargs)
|
|
112
|
+
return decorator
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from prefect.states import State
|
|
2
|
+
from pydantic import Field
|
|
3
|
+
from typing import Optional, List, Tuple, Dict
|
|
4
|
+
|
|
5
|
+
class PyTestflowState(State):
|
|
6
|
+
"""
|
|
7
|
+
Base class for all PyTestFlow states.
|
|
8
|
+
Uses `ptf_result` for internal step data and sets Prefect `.data` field
|
|
9
|
+
to `ptf_result["output"]` if available.
|
|
10
|
+
"""
|
|
11
|
+
ptf_result: Dict = Field(default_factory=dict)
|
|
12
|
+
children: Optional[List[Tuple[str, State]]] = Field(default_factory=list)
|
|
13
|
+
|
|
14
|
+
def __init__(self, *, ptf_result=None, message=None, children=None, **kwargs):
|
|
15
|
+
ptf_result = ptf_result or {}
|
|
16
|
+
|
|
17
|
+
# Set internal .data result from ptf_result["output"]
|
|
18
|
+
data: Any = ptf_result.get("output", None)
|
|
19
|
+
kwargs.setdefault("data", data) # directly sets self.data
|
|
20
|
+
|
|
21
|
+
# Set default type if missing
|
|
22
|
+
kwargs.setdefault("type", "COMPLETED")
|
|
23
|
+
|
|
24
|
+
super().__init__(
|
|
25
|
+
message=message,
|
|
26
|
+
**kwargs
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
object.__setattr__(self, "ptf_result", ptf_result)
|
|
30
|
+
object.__setattr__(self, "children", children or [])
|
|
31
|
+
|
|
32
|
+
def add_child(self, name: str, state: State):
|
|
33
|
+
self.children.append((name, state))
|
|
34
|
+
|
|
35
|
+
def summarize(self):
|
|
36
|
+
return {
|
|
37
|
+
"name": self.name,
|
|
38
|
+
"status": self.ptf_result.get("step_status"),
|
|
39
|
+
"children": [name for name, _ in self.children]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PyTestflowPassed(PyTestflowState):
|
|
44
|
+
def __init__(self, ptf_result=None, message="Step passed", children=None, **kwargs):
|
|
45
|
+
super().__init__(
|
|
46
|
+
name="Passed",
|
|
47
|
+
type="COMPLETED",
|
|
48
|
+
ptf_result=ptf_result,
|
|
49
|
+
message=message,
|
|
50
|
+
children=children,
|
|
51
|
+
**kwargs
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PyTestflowFailed(PyTestflowState):
|
|
56
|
+
def __init__(self, ptf_result=None, message="Step failed", children=None, **kwargs):
|
|
57
|
+
super().__init__(
|
|
58
|
+
name="Failed",
|
|
59
|
+
type="COMPLETED",
|
|
60
|
+
ptf_result=ptf_result,
|
|
61
|
+
message=message,
|
|
62
|
+
children=children,
|
|
63
|
+
**kwargs
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class PyTestflowDone(PyTestflowState):
|
|
68
|
+
def __init__(self, ptf_result=None, message="Step done", children=None, **kwargs):
|
|
69
|
+
super().__init__(
|
|
70
|
+
name="Done",
|
|
71
|
+
type="COMPLETED",
|
|
72
|
+
ptf_result=ptf_result,
|
|
73
|
+
message=message,
|
|
74
|
+
children=children,
|
|
75
|
+
**kwargs
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PyTestflowError(PyTestflowState):
|
|
80
|
+
def __init__(self, ptf_result=None, message="Unhandled error", children=None, **kwargs):
|
|
81
|
+
super().__init__(
|
|
82
|
+
name="Error",
|
|
83
|
+
type="CRASHED",
|
|
84
|
+
ptf_result=ptf_result,
|
|
85
|
+
message=message,
|
|
86
|
+
children=children,
|
|
87
|
+
**kwargs
|
|
88
|
+
)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
import uuid
|
|
4
|
+
import asyncio
|
|
5
|
+
import queue
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RuntimeControl:
|
|
9
|
+
def __init__(self, default_throttle_ms: float = 5.0):
|
|
10
|
+
self._shutdown = False
|
|
11
|
+
# --- PAUSE / THROTTLE / STOP DOMAIN ---
|
|
12
|
+
self._lock = threading.Lock()
|
|
13
|
+
self._resume_condition = threading.Condition(self._lock)
|
|
14
|
+
self._paused = False
|
|
15
|
+
self._throttle_ms = max(0.0, float(default_throttle_ms))
|
|
16
|
+
self._stop_requested = False
|
|
17
|
+
|
|
18
|
+
# --- POPUP DOMAIN (another lock) ---
|
|
19
|
+
self._popup_lock = threading.Lock()
|
|
20
|
+
self._popup_condition = threading.Condition(self._popup_lock)
|
|
21
|
+
self._pending_popups = {} # request_id -> selected
|
|
22
|
+
|
|
23
|
+
# GUI update queue for in-process communication (step -> backend)
|
|
24
|
+
self._gui_update_queue = queue.Queue()
|
|
25
|
+
|
|
26
|
+
def shutdown(self):
|
|
27
|
+
with self._popup_condition:
|
|
28
|
+
self._shutdown = True
|
|
29
|
+
self._popup_condition.notify_all()
|
|
30
|
+
|
|
31
|
+
with self._resume_condition:
|
|
32
|
+
self._resume_condition.notify_all()
|
|
33
|
+
|
|
34
|
+
# =========================================================
|
|
35
|
+
# PAUSE / THROTTLE / STOP
|
|
36
|
+
# =========================================================
|
|
37
|
+
|
|
38
|
+
def set_stop_requested(self, stop_requested: bool) -> None:
|
|
39
|
+
with self._lock:
|
|
40
|
+
self._stop_requested = bool(stop_requested)
|
|
41
|
+
|
|
42
|
+
def set_paused(self, paused: bool) -> None:
|
|
43
|
+
with self._resume_condition:
|
|
44
|
+
self._paused = bool(paused)
|
|
45
|
+
if not self._paused:
|
|
46
|
+
self._resume_condition.notify_all()
|
|
47
|
+
|
|
48
|
+
def is_paused(self) -> bool:
|
|
49
|
+
with self._lock:
|
|
50
|
+
return self._paused
|
|
51
|
+
|
|
52
|
+
def wait_until_resumed(self) -> None:
|
|
53
|
+
with self._resume_condition:
|
|
54
|
+
while self._paused:
|
|
55
|
+
self._resume_condition.wait(timeout=0.2)
|
|
56
|
+
|
|
57
|
+
def set_throttle_ms(self, throttle_ms: float) -> None:
|
|
58
|
+
with self._lock:
|
|
59
|
+
self._throttle_ms = max(0.0, float(throttle_ms))
|
|
60
|
+
|
|
61
|
+
def get_throttle_ms(self) -> float:
|
|
62
|
+
with self._lock:
|
|
63
|
+
return self._throttle_ms
|
|
64
|
+
|
|
65
|
+
def get_throttle_seconds(self) -> float:
|
|
66
|
+
return self.get_throttle_ms() / 1000.0
|
|
67
|
+
|
|
68
|
+
def sleep_with_pause(self, delay_seconds: float) -> None:
|
|
69
|
+
if delay_seconds <= 0:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
end_time = time.monotonic() + delay_seconds
|
|
73
|
+
while True:
|
|
74
|
+
self.wait_until_resumed()
|
|
75
|
+
remaining = end_time - time.monotonic()
|
|
76
|
+
if remaining <= 0:
|
|
77
|
+
break
|
|
78
|
+
time.sleep(min(remaining, 0.05))
|
|
79
|
+
|
|
80
|
+
def checkpoint_before_step(self, step_index: int, apply_throttle: bool = True) -> None:
|
|
81
|
+
if self._stop_requested:
|
|
82
|
+
raise RuntimeError("Stop requested")
|
|
83
|
+
|
|
84
|
+
self.wait_until_resumed()
|
|
85
|
+
if apply_throttle and step_index > 0:
|
|
86
|
+
self.sleep_with_pause(self.get_throttle_seconds())
|
|
87
|
+
self.wait_until_resumed()
|
|
88
|
+
|
|
89
|
+
def snapshot(self) -> dict:
|
|
90
|
+
with self._lock:
|
|
91
|
+
return {
|
|
92
|
+
"paused": self._paused,
|
|
93
|
+
"throttle_ms": self._throttle_ms,
|
|
94
|
+
"stop_requested": self._stop_requested
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# =========================================================
|
|
98
|
+
# POPUP (lock separato)
|
|
99
|
+
# =========================================================
|
|
100
|
+
|
|
101
|
+
def show_popup(self, popup_data: dict = None) -> dict:
|
|
102
|
+
request_id = str(uuid.uuid4())
|
|
103
|
+
|
|
104
|
+
with self._popup_condition:
|
|
105
|
+
self._pending_popups[request_id] = None
|
|
106
|
+
|
|
107
|
+
payload = {
|
|
108
|
+
"cmd": "display_modal_popup",
|
|
109
|
+
"args": {
|
|
110
|
+
"request_id": request_id,
|
|
111
|
+
"popup_data": popup_data
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# Always enqueue payload so backend can deliver it when a GUI connects
|
|
116
|
+
self.send_gui_update(payload)
|
|
117
|
+
|
|
118
|
+
# Also try immediate in-process emit to reach already-connected clients
|
|
119
|
+
try:
|
|
120
|
+
import pytestflow.backend.event_bus as _eb
|
|
121
|
+
if getattr(_eb.event_bus, "loop", None):
|
|
122
|
+
print("[RuntimeControl] Enqueued popup and sending immediate in-process event_bus emit")
|
|
123
|
+
asyncio.run_coroutine_threadsafe(
|
|
124
|
+
_eb.event_bus.emit("outbound", payload),
|
|
125
|
+
_eb.event_bus.loop
|
|
126
|
+
)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
print("[RuntimeControl] immediate event_bus emit failed (will rely on queue). Error:", e)
|
|
129
|
+
|
|
130
|
+
while self._pending_popups.get(request_id) is None and not self._shutdown:
|
|
131
|
+
self._popup_condition.wait(timeout=0.2)
|
|
132
|
+
|
|
133
|
+
if self._shutdown:
|
|
134
|
+
self._pending_popups.pop(request_id, None)
|
|
135
|
+
raise RuntimeError("Shutdown requested")
|
|
136
|
+
|
|
137
|
+
return self._pending_popups.pop(request_id)
|
|
138
|
+
|
|
139
|
+
def set_popup_response(self, request_id, button_pressed, response_text=None):
|
|
140
|
+
with self._popup_condition:
|
|
141
|
+
if request_id in self._pending_popups:
|
|
142
|
+
self._pending_popups[request_id] = {
|
|
143
|
+
"button": button_pressed,
|
|
144
|
+
"text": response_text
|
|
145
|
+
}
|
|
146
|
+
self._popup_condition.notify_all()
|
|
147
|
+
|
|
148
|
+
# GUI update queue API
|
|
149
|
+
def send_gui_update(self, update_data: dict) -> None:
|
|
150
|
+
"""Called by steps/other threads to enqueue a GUI update payload."""
|
|
151
|
+
self._gui_update_queue.put(update_data)
|
|
152
|
+
|
|
153
|
+
def get_gui_update(self, timeout: float = 0.5):
|
|
154
|
+
"""Called by backend consumer (in executor) to dequeue a GUI update.
|
|
155
|
+
|
|
156
|
+
Returns the update dict or None if timeout expires.
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
return self._gui_update_queue.get(timeout=timeout)
|
|
160
|
+
except queue.Empty:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
runtime_control = RuntimeControl()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from prefect import flow
|
|
5
|
+
from prefect.context import get_run_context
|
|
6
|
+
from pytestflow.config.config_manager import ConfigManager
|
|
7
|
+
|
|
8
|
+
config = ConfigManager()
|
|
9
|
+
process_models_dir = config.get_path("process_models")
|
|
10
|
+
|
|
11
|
+
def load_process_model(process_models_dir: str, model_name: str):
|
|
12
|
+
file_path = Path(process_models_dir) / f"{model_name}.py"
|
|
13
|
+
|
|
14
|
+
spec = importlib.util.spec_from_file_location(
|
|
15
|
+
f"user_{model_name}",
|
|
16
|
+
file_path
|
|
17
|
+
)
|
|
18
|
+
module = importlib.util.module_from_spec(spec)
|
|
19
|
+
sys.modules[f"user_{model_name}"] = module
|
|
20
|
+
spec.loader.exec_module(module)
|
|
21
|
+
|
|
22
|
+
return module.PROCESS_MODEL
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@flow()
|
|
26
|
+
def run_sequence_file(process_model_callbacks, model_name):
|
|
27
|
+
# Following line are because we cannot do @flow(name=test_sequence.name)
|
|
28
|
+
ctx = get_run_context()
|
|
29
|
+
|
|
30
|
+
ctx.flow_run.name = process_model_callbacks["main_sequence"].name
|
|
31
|
+
|
|
32
|
+
my_proc_model = load_process_model(process_models_dir, model_name)
|
|
33
|
+
|
|
34
|
+
process_model = my_proc_model(
|
|
35
|
+
name=process_model_callbacks["main_sequence"].name,
|
|
36
|
+
callbacks=process_model_callbacks
|
|
37
|
+
)
|
|
38
|
+
return process_model.run(return_state=True)
|