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,130 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import pytestflow
|
|
3
|
+
from pytestflow.core.sequence import TestSequence
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import json
|
|
7
|
+
from pytestflow.backend.uuids_handler import resolve_uuids
|
|
8
|
+
from pytestflow.config.config_manager import ConfigManager
|
|
9
|
+
from pytestflow.steps.action_step import ActionStep # pyright: ignore[reportUnusedImport]
|
|
10
|
+
from pytestflow.steps.pass_fail import PassFailStep # pyright: ignore[reportUnusedImport]
|
|
11
|
+
from pytestflow.steps.numeric_limit import NumericLimitStep # pyright: ignore[reportUnusedImport]
|
|
12
|
+
from pytestflow.steps.string_check import StringCheckStep # pyright: ignore[reportUnusedImport]
|
|
13
|
+
from pytestflow.steps.df_numeric_limits import DFNumericLimitsStep # pyright: ignore[reportUnusedImport]
|
|
14
|
+
from pytestflow.steps.waveform_limit import WaveformLimitStep # pyright: ignore[reportUnusedImport]
|
|
15
|
+
from pytestflow.steps.message_pop_up import MessagePopUpStep # pyright: ignore[reportUnusedImport]
|
|
16
|
+
from pytestflow.steps.flow_control import FlowControlStep # pyright: ignore[reportUnusedImport]
|
|
17
|
+
|
|
18
|
+
config = ConfigManager()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_available_sequences():
|
|
22
|
+
"""Returns a list of available sequence names from the sequences folder."""
|
|
23
|
+
sequences = []
|
|
24
|
+
try:
|
|
25
|
+
SEQUENCES_FOLDER = config.get_path("test_sequences")
|
|
26
|
+
seq_folder = Path(SEQUENCES_FOLDER)
|
|
27
|
+
if seq_folder.exists() and seq_folder.is_dir():
|
|
28
|
+
for file in seq_folder.glob("*.py"):
|
|
29
|
+
if file.name == "__init__.py":
|
|
30
|
+
continue
|
|
31
|
+
sequences.append(file.stem)
|
|
32
|
+
except ImportError:
|
|
33
|
+
print("SEQUENCES_FOLDER not defined in config.yaml", file=sys.stderr)
|
|
34
|
+
return sequences
|
|
35
|
+
|
|
36
|
+
def get_single_step(step):
|
|
37
|
+
uuid_val = getattr(step, "static_uuid", None)
|
|
38
|
+
return {
|
|
39
|
+
"type": getattr(step, "step_type", "<unnamed type>"),
|
|
40
|
+
"name": getattr(step, "name", "<unnamed step>"),
|
|
41
|
+
"uuid": str(uuid_val) if uuid_val is not None else None
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def get_seq_structure_recursive(sequence):
|
|
45
|
+
"""Return a dict with separated Setup / Main / Cleanup steps."""
|
|
46
|
+
def serialize_steps(step_list):
|
|
47
|
+
result = []
|
|
48
|
+
for step in step_list:
|
|
49
|
+
# Prefect Task
|
|
50
|
+
if isinstance(step, (
|
|
51
|
+
ActionStep,
|
|
52
|
+
PassFailStep,
|
|
53
|
+
NumericLimitStep,
|
|
54
|
+
StringCheckStep,
|
|
55
|
+
DFNumericLimitsStep,
|
|
56
|
+
WaveformLimitStep,
|
|
57
|
+
MessagePopUpStep,
|
|
58
|
+
FlowControlStep
|
|
59
|
+
)):
|
|
60
|
+
result.append(get_single_step(step))
|
|
61
|
+
|
|
62
|
+
elif isinstance(step, TestSequence):
|
|
63
|
+
result.append(get_seq_structure_recursive(step))
|
|
64
|
+
# unknown
|
|
65
|
+
else:
|
|
66
|
+
print(f"[Unknown type: {type(step)}] {step}")
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
sequence_uuid = str(sequence.static_uuid)
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
"type": "sub_sequence", # ← PRIMA chiave
|
|
74
|
+
"Name": getattr(sequence, "name", "<unnamed sequence>"),
|
|
75
|
+
"uuid": sequence_uuid,
|
|
76
|
+
"Setup": {"Steps": serialize_steps(getattr(sequence, "setup_steps", []))},
|
|
77
|
+
"Main": {"Steps": serialize_steps(getattr(sequence, "main_steps", []))},
|
|
78
|
+
"Cleanup": {"Steps": serialize_steps(getattr(sequence, "cleanup_steps", []))},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def get_seq_structure(sequence_name: str):
|
|
82
|
+
try:
|
|
83
|
+
SEQUENCES_FOLDER = config.get_path("test_sequences")
|
|
84
|
+
except ImportError:
|
|
85
|
+
return json.dumps({"error": "SEQUENCES_FOLDER not defined in config.py"})
|
|
86
|
+
|
|
87
|
+
seq_path = Path(SEQUENCES_FOLDER) / f"{sequence_name}.py"
|
|
88
|
+
if not seq_path.exists():
|
|
89
|
+
return json.dumps({"error": f"Sequence file {sequence_name}.py not found"})
|
|
90
|
+
|
|
91
|
+
spec = importlib.util.spec_from_file_location(sequence_name, seq_path)
|
|
92
|
+
module = importlib.util.module_from_spec(spec)
|
|
93
|
+
spec.loader.exec_module(module)
|
|
94
|
+
|
|
95
|
+
if not hasattr(module, "PROCESS_HOOKS"):
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
f"PROCESS_HOOKS not defined in {seq_path}. "
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
hooks = dict(module.PROCESS_HOOKS) # copia difensiva
|
|
101
|
+
|
|
102
|
+
if "main_sequence" not in hooks:
|
|
103
|
+
raise RuntimeError("PROCESS_HOOKS['main_sequence'] is required")
|
|
104
|
+
|
|
105
|
+
main_factory = hooks["main_sequence"]
|
|
106
|
+
|
|
107
|
+
if not callable(main_factory):
|
|
108
|
+
raise TypeError("PROCESS_HOOKS['main_sequence'] must be callable")
|
|
109
|
+
|
|
110
|
+
# istanzia UNA VOLTA
|
|
111
|
+
main_sequence = main_factory()
|
|
112
|
+
|
|
113
|
+
if not isinstance(main_sequence, TestSequence):
|
|
114
|
+
raise TypeError(
|
|
115
|
+
"PROCESS_HOOKS['main_sequence'] must return a TestSequence"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# UUID risolti QUI
|
|
119
|
+
resolve_uuids(main_sequence, main_factory)
|
|
120
|
+
|
|
121
|
+
# sostituisci la factory con l'istanza
|
|
122
|
+
hooks["main_sequence"] = main_sequence
|
|
123
|
+
|
|
124
|
+
# struttura per la UI
|
|
125
|
+
root = get_seq_structure_recursive(main_sequence)
|
|
126
|
+
root["type"] = "MainSequence"
|
|
127
|
+
|
|
128
|
+
return root, hooks
|
|
129
|
+
|
|
130
|
+
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import threading
|
|
4
|
+
import traceback
|
|
5
|
+
import webbrowser
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from bottle import Bottle, response, run, static_file, route, abort
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from pytestflow.backend.websocket_gateway import start_server
|
|
10
|
+
from pytestflow.backend.report_manager import report_manager
|
|
11
|
+
from pytestflow.config.config_manager import ConfigManager
|
|
12
|
+
|
|
13
|
+
CONFIG = ConfigManager().get_config()
|
|
14
|
+
|
|
15
|
+
# --------------------
|
|
16
|
+
# Paths
|
|
17
|
+
# --------------------
|
|
18
|
+
|
|
19
|
+
# BASE_DIR = directory di questo file backend/start_backend.py
|
|
20
|
+
BASE_DIR = Path(__file__).parent.resolve()
|
|
21
|
+
|
|
22
|
+
# percorso relativo al frontend
|
|
23
|
+
FRONTEND_DIR = (BASE_DIR / "frontend").resolve()
|
|
24
|
+
|
|
25
|
+
# verifica semplice (opzionale)
|
|
26
|
+
if not (FRONTEND_DIR / "index.html").exists():
|
|
27
|
+
raise RuntimeError(f"Frontend not found in {FRONTEND_DIR}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --------------------
|
|
31
|
+
# HTTP Server (Bottle)
|
|
32
|
+
# --------------------
|
|
33
|
+
|
|
34
|
+
app = Bottle()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.route("/")
|
|
38
|
+
def index():
|
|
39
|
+
return static_file("index.html", root=FRONTEND_DIR)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.route("/assets/<filepath:path>")
|
|
43
|
+
def assets(filepath):
|
|
44
|
+
return static_file(filepath, root=os.path.join(FRONTEND_DIR, "assets"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.route("/config.json")
|
|
48
|
+
def config():
|
|
49
|
+
response.content_type = 'application/json'
|
|
50
|
+
return {
|
|
51
|
+
"wsPort": CONFIG["websocket"]["port"],
|
|
52
|
+
"featureFlags": {
|
|
53
|
+
"reports": True
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# SPA fallback (Vue/React router)
|
|
59
|
+
@app.route("/<filepath:path>")
|
|
60
|
+
def spa_fallback(filepath):
|
|
61
|
+
return static_file("index.html", root=FRONTEND_DIR)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def start_http():
|
|
65
|
+
run(
|
|
66
|
+
app,
|
|
67
|
+
host=CONFIG["http"]["host"],
|
|
68
|
+
port=CONFIG["http"]["port"],
|
|
69
|
+
quiet=True,
|
|
70
|
+
reloader=False,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --------------------
|
|
75
|
+
# WebSocket Server
|
|
76
|
+
# --------------------
|
|
77
|
+
|
|
78
|
+
def start_ws():
|
|
79
|
+
asyncio.run(start_server(ws_host=CONFIG["websocket"]["host"], ws_port=CONFIG["websocket"]["port"]))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --------------------
|
|
83
|
+
# Main
|
|
84
|
+
# --------------------
|
|
85
|
+
|
|
86
|
+
def main(open_browser=False):
|
|
87
|
+
gui_url = f"http://{CONFIG["http"]["host"]}:{CONFIG["http"]["port"]}/"
|
|
88
|
+
ws_url = f"ws://{CONFIG["websocket"]["host"]}:{CONFIG["websocket"]["port"]}"
|
|
89
|
+
starter_path = os.path.normpath(
|
|
90
|
+
os.path.join(os.path.dirname(BASE_DIR), "starter_here.md")
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
print("\nPyTestFlow backend started")
|
|
94
|
+
print(f"GUI URL: {gui_url}")
|
|
95
|
+
print(f"WebSocket URL: {ws_url}")
|
|
96
|
+
print("Next: open the GUI URL, select and launch motherboard test sequence.")
|
|
97
|
+
print(f"Guide: {starter_path}\n")
|
|
98
|
+
|
|
99
|
+
if open_browser:
|
|
100
|
+
webbrowser.open(gui_url)
|
|
101
|
+
|
|
102
|
+
# Start WebSocket server in background thread (wrap to surface exceptions)
|
|
103
|
+
def _ws_thread_target():
|
|
104
|
+
try:
|
|
105
|
+
start_ws()
|
|
106
|
+
except Exception:
|
|
107
|
+
print("WebSocket thread exception:")
|
|
108
|
+
traceback.print_exc()
|
|
109
|
+
|
|
110
|
+
ws_thread = threading.Thread(target=_ws_thread_target, daemon=True)
|
|
111
|
+
ws_thread.start()
|
|
112
|
+
|
|
113
|
+
# Start HTTP server (blocking)
|
|
114
|
+
start_http()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
main()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from typing import Iterator, Tuple, Union
|
|
2
|
+
from pytestflow.core.core import StepWrapper
|
|
3
|
+
from pytestflow.core.sequence import TestSequence
|
|
4
|
+
import inspect
|
|
5
|
+
import uuid
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
PTF_NAMESPACE = uuid.UUID("9c0cb974-3f6e-40ff-b4ea-a30f68fe35b7")
|
|
9
|
+
|
|
10
|
+
def walk_sequence(
|
|
11
|
+
sequence: "TestSequence",
|
|
12
|
+
base_path: Tuple[str, ...] = (),
|
|
13
|
+
) -> Iterator[Tuple[Union["TestSequence", "StepWrapper"], Tuple[str, ...]]]:
|
|
14
|
+
"""
|
|
15
|
+
Itera ricorsivamente su una TestSequence e sui suoi step,
|
|
16
|
+
restituendo (nodo, path) per calcolare UUID coerenti.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# path per la sequence stessa
|
|
20
|
+
seq_path = (*base_path, f"SEQ:{sequence.name}")
|
|
21
|
+
yield sequence, seq_path
|
|
22
|
+
|
|
23
|
+
# funzione interna per processare liste di step
|
|
24
|
+
def walk_steps(step_list, section_name):
|
|
25
|
+
for idx, step in enumerate(step_list):
|
|
26
|
+
step_path = (*seq_path, f"{section_name}[{idx}]")
|
|
27
|
+
if isinstance(step, TestSequence):
|
|
28
|
+
# ricorsione
|
|
29
|
+
yield from walk_sequence(step, step_path)
|
|
30
|
+
else:
|
|
31
|
+
# passo normale / Prefect step
|
|
32
|
+
yield step, (*step_path, f"STEP:{getattr(step, 'name', 'unnamed')}")
|
|
33
|
+
|
|
34
|
+
walk_lists = [
|
|
35
|
+
("SETUP", getattr(sequence, "setup_steps", [])),
|
|
36
|
+
("MAIN", getattr(sequence, "main_steps", [])),
|
|
37
|
+
("CLEANUP", getattr(sequence, "cleanup_steps", [])),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
for section_name, steps in walk_lists:
|
|
41
|
+
yield from walk_steps(steps, section_name)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_root_sequence_uuid(entrypoint_fn: callable) -> uuid.UUID:
|
|
45
|
+
"""
|
|
46
|
+
Calcola un UUID stabile per la root TestSequence basandosi sulla funzione entrypoint.
|
|
47
|
+
- entrypoint_fn: la funzione che crea la root TestSequence (ENTRYPOINT)
|
|
48
|
+
"""
|
|
49
|
+
real_fn = inspect.unwrap(entrypoint_fn)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
file_path = os.path.realpath(inspect.getfile(real_fn))
|
|
53
|
+
except Exception:
|
|
54
|
+
file_path = "nofile"
|
|
55
|
+
|
|
56
|
+
key = f"RootSequence:{file_path}:{real_fn.__qualname__}"
|
|
57
|
+
return uuid.uuid5(PTF_NAMESPACE, key)
|
|
58
|
+
|
|
59
|
+
def resolve_uuids(root_sequence: "TestSequence", entrypoint_fn: callable):
|
|
60
|
+
root_id = get_root_sequence_uuid(entrypoint_fn)
|
|
61
|
+
|
|
62
|
+
for node, path in walk_sequence(root_sequence, (str(root_id),)):
|
|
63
|
+
node.static_uuid = uuid.uuid5(
|
|
64
|
+
PTF_NAMESPACE,
|
|
65
|
+
"/".join(path)
|
|
66
|
+
)
|
|
67
|
+
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import websockets
|
|
4
|
+
from pytestflow.backend.event_bus import event_bus
|
|
5
|
+
from pytestflow.backend.handlers import init_handlers
|
|
6
|
+
from pytestflow.core.runtime_control import runtime_control
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def websocket_handler(ws):
|
|
10
|
+
path = ws.request.path
|
|
11
|
+
|
|
12
|
+
if path != "/ws":
|
|
13
|
+
await ws.close(code=1008, reason="Invalid path")
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
# Prepare per-connection queue and register subscriber before draining queued updates
|
|
17
|
+
queue = asyncio.Queue()
|
|
18
|
+
|
|
19
|
+
async def on_outbound(payload):
|
|
20
|
+
await queue.put(payload)
|
|
21
|
+
|
|
22
|
+
event_bus.on("outbound", on_outbound)
|
|
23
|
+
|
|
24
|
+
# start outbound sender using the queue; the subscriber is already registered
|
|
25
|
+
sender_task = asyncio.create_task(outbound_sender(ws, queue))
|
|
26
|
+
|
|
27
|
+
# Drain existing queued updates so new connection receives pending messages
|
|
28
|
+
loop = asyncio.get_running_loop()
|
|
29
|
+
while True:
|
|
30
|
+
update = await loop.run_in_executor(None, runtime_control.get_gui_update, 0)
|
|
31
|
+
if not update:
|
|
32
|
+
break
|
|
33
|
+
await queue.put(update)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
async for message in ws:
|
|
37
|
+
data = json.loads(message)
|
|
38
|
+
cmd = data.get("cmd")
|
|
39
|
+
args = data.get("args", {})
|
|
40
|
+
|
|
41
|
+
if cmd:
|
|
42
|
+
await event_bus.emit(cmd, args)
|
|
43
|
+
|
|
44
|
+
except websockets.ConnectionClosed:
|
|
45
|
+
pass
|
|
46
|
+
finally:
|
|
47
|
+
# cleanup: remove subscriber and cancel sender
|
|
48
|
+
try:
|
|
49
|
+
event_bus._subscribers["outbound"].remove(on_outbound)
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
sender_task.cancel()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def outbound_sender(ws, queue: asyncio.Queue):
|
|
56
|
+
try:
|
|
57
|
+
while True:
|
|
58
|
+
payload = await queue.get()
|
|
59
|
+
try:
|
|
60
|
+
await ws.send(json.dumps(payload))
|
|
61
|
+
except Exception as e:
|
|
62
|
+
print("WS send error:", repr(e))
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
except websockets.ConnectionClosed:
|
|
66
|
+
print("WS disconnected")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def start_server(ws_host, ws_port):
|
|
70
|
+
loop = asyncio.get_running_loop()
|
|
71
|
+
event_bus.set_loop(loop)
|
|
72
|
+
asyncio.create_task(event_bus.start()) # starts processing the queue
|
|
73
|
+
init_handlers() # register handlers
|
|
74
|
+
|
|
75
|
+
# Start background consumer for in-process GUI updates coming from steps
|
|
76
|
+
async def consume_gui_updates():
|
|
77
|
+
while True:
|
|
78
|
+
# Only dequeue if there's at least one outbound subscriber (a connected client)
|
|
79
|
+
if not event_bus._subscribers.get("outbound"):
|
|
80
|
+
await asyncio.sleep(0.1)
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
# run blocking get in executor
|
|
84
|
+
update = await loop.run_in_executor(None, runtime_control.get_gui_update, 0.5)
|
|
85
|
+
if update:
|
|
86
|
+
await event_bus.emit("outbound", update)
|
|
87
|
+
await asyncio.sleep(0)
|
|
88
|
+
|
|
89
|
+
asyncio.create_task(consume_gui_updates())
|
|
90
|
+
async with websockets.serve(websocket_handler, ws_host, ws_port):
|
|
91
|
+
await asyncio.Future()
|
pytestflow/cli.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from importlib import resources
|
|
7
|
+
|
|
8
|
+
APP_DIR_NAME = "pytestflow"
|
|
9
|
+
|
|
10
|
+
# --------------------
|
|
11
|
+
# Template map
|
|
12
|
+
# --------------------
|
|
13
|
+
TEMPLATE_SOURCE_MAP = {
|
|
14
|
+
"process_models": ("bootstrap_templates", "process_models"),
|
|
15
|
+
"test_sequences": ("bootstrap_templates", "test_sequences"),
|
|
16
|
+
"custom_step_types": ("bootstrap_templates", "custom_step_types"),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# --------------------
|
|
20
|
+
# Workspace helpers
|
|
21
|
+
# --------------------
|
|
22
|
+
def resolve_workspace_root(custom_path: str | None = None) -> Path:
|
|
23
|
+
if custom_path:
|
|
24
|
+
return Path(custom_path).expanduser().resolve()
|
|
25
|
+
|
|
26
|
+
home = Path.home()
|
|
27
|
+
if sys.platform.startswith("win"):
|
|
28
|
+
base = Path(os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or (home / "AppData/Local"))
|
|
29
|
+
elif sys.platform == "darwin":
|
|
30
|
+
base = home / "Library/Application Support"
|
|
31
|
+
else:
|
|
32
|
+
base = Path(os.getenv("XDG_DATA_HOME") or (home / ".local/share"))
|
|
33
|
+
|
|
34
|
+
return (base / APP_DIR_NAME).resolve()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def workspace_paths(root: Path) -> dict[str, Path]:
|
|
38
|
+
subdirs = ["process_models", "test_sequences", "test_reports", "custom_step_types"]
|
|
39
|
+
paths = {"root": root}
|
|
40
|
+
for name in subdirs:
|
|
41
|
+
paths[name] = root / name
|
|
42
|
+
return paths
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _copy_templates(paths: dict[str, Path]) -> list[tuple[str, Path]]:
|
|
46
|
+
copied = []
|
|
47
|
+
|
|
48
|
+
for key, parts in TEMPLATE_SOURCE_MAP.items():
|
|
49
|
+
destination_dir = paths[key]
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
package = parts[0]
|
|
53
|
+
inner_path = parts[1:]
|
|
54
|
+
|
|
55
|
+
source_dir = resources.files(package).joinpath(*inner_path)
|
|
56
|
+
|
|
57
|
+
with resources.as_file(source_dir) as source_path:
|
|
58
|
+
source_dir_path = Path(source_path)
|
|
59
|
+
|
|
60
|
+
except Exception as e:
|
|
61
|
+
print(f"[ERROR] Cannot load templates for {key}: {e}")
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
for src in source_dir_path.rglob("*"):
|
|
65
|
+
if src.is_file():
|
|
66
|
+
relative = src.relative_to(source_dir_path)
|
|
67
|
+
target = destination_dir / relative
|
|
68
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
shutil.copy2(src, target)
|
|
71
|
+
print(f"[copied] {target}")
|
|
72
|
+
copied.append((key, target))
|
|
73
|
+
|
|
74
|
+
return copied
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def initialize_workspace_interactive() -> dict[str, Path]:
|
|
78
|
+
print("PyTestFlow workspace initialization:")
|
|
79
|
+
choice = input(
|
|
80
|
+
"Install templates in current folder (c) or default location (d)? [d/c]: "
|
|
81
|
+
).strip().lower()
|
|
82
|
+
|
|
83
|
+
if choice == "c":
|
|
84
|
+
root = Path.cwd()
|
|
85
|
+
else:
|
|
86
|
+
root = resolve_workspace_root()
|
|
87
|
+
|
|
88
|
+
paths = workspace_paths(root)
|
|
89
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
for p in paths.values():
|
|
91
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
copied = _copy_templates(paths)
|
|
94
|
+
|
|
95
|
+
# Copy config.yaml to root
|
|
96
|
+
try:
|
|
97
|
+
try:
|
|
98
|
+
# Try resources first (for installed packages)
|
|
99
|
+
config_src = resources.files("bootstrap_templates") / "config.yaml"
|
|
100
|
+
print(f"Trying resources: {config_src}")
|
|
101
|
+
with resources.as_file(config_src) as src_path:
|
|
102
|
+
target = paths["root"] / "config.yaml"
|
|
103
|
+
shutil.copy2(src_path, target)
|
|
104
|
+
print(f"Used resources: {src_path}")
|
|
105
|
+
except Exception as e:
|
|
106
|
+
print(f"Resources failed: {e}")
|
|
107
|
+
# Fallback to Path (for editable installs)
|
|
108
|
+
config_src = Path(__file__).parent.parent / "bootstrap_templates" / "config.yaml"
|
|
109
|
+
target = paths["root"] / "config.yaml"
|
|
110
|
+
print(f"Using Path: {config_src}")
|
|
111
|
+
if not config_src.exists():
|
|
112
|
+
print(f"File does not exist: {config_src}")
|
|
113
|
+
else:
|
|
114
|
+
shutil.copy2(config_src, target)
|
|
115
|
+
print(f"[copied] config.yaml: {target}")
|
|
116
|
+
copied.append(("config", target))
|
|
117
|
+
except Exception as e:
|
|
118
|
+
print(f"[ERROR] Cannot copy config.yaml: {e}")
|
|
119
|
+
|
|
120
|
+
print(f"\nPyTestFlow workspace initialized at: {root}")
|
|
121
|
+
if copied:
|
|
122
|
+
for key, target in copied:
|
|
123
|
+
print(f"[copied] {key}: {target}")
|
|
124
|
+
else:
|
|
125
|
+
print("[copied] No new files copied, templates already exist.")
|
|
126
|
+
|
|
127
|
+
# Print environment variable command
|
|
128
|
+
if sys.platform.startswith("win"):
|
|
129
|
+
print(f"\nSet environment variable for this session in PowerShell:\n$env:PYTESTFLOW_HOME='{root}'")
|
|
130
|
+
else:
|
|
131
|
+
print(f"\nSet environment variable for this session in bash/zsh:\nexport PYTESTFLOW_HOME='{root}'")
|
|
132
|
+
|
|
133
|
+
return paths
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --------------------
|
|
137
|
+
# CLI
|
|
138
|
+
# --------------------
|
|
139
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
140
|
+
parser = argparse.ArgumentParser(
|
|
141
|
+
prog="pytestflow",
|
|
142
|
+
description="PyTestFlow CLI: initialize workspace or start backend",
|
|
143
|
+
)
|
|
144
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
145
|
+
|
|
146
|
+
start_parser = subparsers.add_parser(
|
|
147
|
+
"start",
|
|
148
|
+
help="Start backend and serve the GUI",
|
|
149
|
+
)
|
|
150
|
+
start_parser.add_argument(
|
|
151
|
+
"--open",
|
|
152
|
+
action="store_true",
|
|
153
|
+
help="Open the GUI URL in your default browser",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
init_parser = subparsers.add_parser(
|
|
157
|
+
"init",
|
|
158
|
+
help="Initialize PyTestFlow workspace and copy templates",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return parser
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main(argv=None) -> int:
|
|
165
|
+
parser = build_parser()
|
|
166
|
+
args = parser.parse_args(argv)
|
|
167
|
+
command = args.command or "start"
|
|
168
|
+
|
|
169
|
+
if command == "init":
|
|
170
|
+
initialize_workspace_interactive()
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
if command == "start":
|
|
174
|
+
from pytestflow.backend.start_backend import main as start_backend
|
|
175
|
+
start_backend(open_browser=getattr(args, "open", False))
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
parser.print_help()
|
|
179
|
+
return 1
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
sys.exit(main())
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import yaml
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConfigManager:
|
|
7
|
+
_instance = None
|
|
8
|
+
|
|
9
|
+
def __new__(cls):
|
|
10
|
+
if cls._instance is None:
|
|
11
|
+
cls._instance = super(ConfigManager, cls).__new__(cls)
|
|
12
|
+
cls._instance._config = None
|
|
13
|
+
return cls._instance
|
|
14
|
+
|
|
15
|
+
def get_config(self, reload=False):
|
|
16
|
+
if self._config is None or reload:
|
|
17
|
+
self._config = self._load_config()
|
|
18
|
+
return self._config
|
|
19
|
+
|
|
20
|
+
def _load_config(self):
|
|
21
|
+
home = os.getenv("PYTESTFLOW_HOME")
|
|
22
|
+
|
|
23
|
+
if not home:
|
|
24
|
+
raise Exception(
|
|
25
|
+
"PYTESTFLOW_HOME not set. Run 'pytestflow init' first."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
root = Path(home)
|
|
29
|
+
config_path = root / "config.yaml"
|
|
30
|
+
|
|
31
|
+
if not config_path.exists():
|
|
32
|
+
raise Exception("Configuration file not found.")
|
|
33
|
+
|
|
34
|
+
with open(config_path) as f:
|
|
35
|
+
return yaml.safe_load(f)
|
|
36
|
+
|
|
37
|
+
def get_path(self, key):
|
|
38
|
+
config = self.get_config()
|
|
39
|
+
|
|
40
|
+
if key not in config:
|
|
41
|
+
raise KeyError(f"Missing config key: {key}")
|
|
42
|
+
|
|
43
|
+
home = Path(os.getenv("PYTESTFLOW_HOME"))
|
|
44
|
+
return home / config[key]
|