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
@@ -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]