wup 0.2.9__tar.gz → 0.2.12__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wup
3
- Version: 0.2.9
3
+ Version: 0.2.12
4
4
  Summary: WUP (What's Up) - Intelligent file watcher for regression testing in large projects
5
5
  Author-email: Tom Sapletta <tom@sapletta.com>
6
- License-Expression: Apache-2.0
6
+ License: Apache-2.0
7
7
  Project-URL: Homepage, https://github.com/semcod/wup
8
8
  Project-URL: Repository, https://github.com/semcod/wup
9
9
  Keywords: wup,watcher,testing,regression,file-monitoring
@@ -21,6 +21,7 @@ Requires-Dist: watchdog>=4.0.0
21
21
  Requires-Dist: psutil>=5.9.0
22
22
  Requires-Dist: rich>=13.0.0
23
23
  Requires-Dist: typer>=0.9.0
24
+ Requires-Dist: pyyaml>=6.0
24
25
  Dynamic: license-file
25
26
 
26
27
  # WUP (What's Up)
@@ -28,17 +29,17 @@ Dynamic: license-file
28
29
 
29
30
  ## AI Cost Tracking
30
31
 
31
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.9-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
32
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$1.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-2.4h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
32
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.12-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
33
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$1.95-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-3.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
33
34
 
34
- - 🤖 **LLM usage:** $1.5000 (10 commits)
35
- - 👤 **Human dev:** ~$240 (2.4h @ $100/h, 30min dedup)
35
+ - 🤖 **LLM usage:** $1.9500 (13 commits)
36
+ - 👤 **Human dev:** ~$312 (3.1h @ $100/h, 30min dedup)
36
37
 
37
38
  Generated on 2026-04-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
38
39
 
39
40
  ---
40
41
 
41
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.9-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
42
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.12-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
42
43
 
43
44
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
44
45
 
@@ -3,17 +3,17 @@
3
3
 
4
4
  ## AI Cost Tracking
5
5
 
6
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.9-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$1.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-2.4h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.12-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$1.95-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-3.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $1.5000 (10 commits)
10
- - 👤 **Human dev:** ~$240 (2.4h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $1.9500 (13 commits)
10
+ - 👤 **Human dev:** ~$312 (3.1h @ $100/h, 30min dedup)
11
11
 
12
12
  Generated on 2026-04-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
13
13
 
14
14
  ---
15
15
 
16
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.9-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
16
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.12-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
17
17
 
18
18
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
19
19
 
@@ -4,11 +4,10 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wup"
7
- version = "0.2.9"
7
+ version = "0.2.12"
8
8
  description = "WUP (What's Up) - Intelligent file watcher for regression testing in large projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
11
- license = "Apache-2.0"
12
11
  authors = [
13
12
  {name = "Tom Sapletta", email = "tom@sapletta.com"},
14
13
  ]
@@ -18,6 +17,7 @@ dependencies = [
18
17
  "psutil>=5.9.0",
19
18
  "rich>=13.0.0",
20
19
  "typer>=0.9.0",
20
+ "pyyaml>=6.0",
21
21
  ]
22
22
  classifiers = [
23
23
  "Development Status :: 3 - Alpha",
@@ -29,6 +29,11 @@ classifiers = [
29
29
  "Programming Language :: Python :: 3.12",
30
30
  ]
31
31
 
32
+ [project.license]
33
+ text = "Apache-2.0"
34
+ [tool.setuptools.packages.find]
35
+ include = ["wup*"]
36
+
32
37
  [project.scripts]
33
38
  wup = "wup.cli:app"
34
39
 
@@ -0,0 +1,201 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import tempfile
5
+ from pathlib import Path
6
+ from subprocess import CompletedProcess
7
+
8
+ from wup.testql_watcher import TestQLWatcher
9
+ from wup.models.config import WupConfig, ProjectConfig, TestQLConfig
10
+
11
+
12
+ def test_process_changed_file_creates_track_on_failure():
13
+ with tempfile.TemporaryDirectory() as tmpdir:
14
+ root = Path(tmpdir)
15
+ app_file = root / "app" / "users" / "routes.py"
16
+ app_file.parent.mkdir(parents=True, exist_ok=True)
17
+ app_file.write_text("print('x')\n", encoding="utf-8")
18
+
19
+ scenario_dir = root / "testql-scenarios"
20
+ scenario_dir.mkdir(parents=True, exist_ok=True)
21
+ failing_scenario = scenario_dir / "app-users.testql.toon.yaml"
22
+ failing_scenario.write_text("name: failing\n", encoding="utf-8")
23
+
24
+ # Pass empty config to prevent loading from temp dir
25
+ from wup.models.config import TestQLConfig, WatchConfig
26
+ empty_config = WupConfig(
27
+ project=ProjectConfig(name="test"),
28
+ services=[],
29
+ test_strategy=None,
30
+ watch=WatchConfig(), # Add watch config to avoid file filtering issues
31
+ testql=TestQLConfig(scenario_dir="testql-scenarios")
32
+ )
33
+ watcher = TestQLWatcher(
34
+ project_root=str(root),
35
+ deps_file=str(root / "deps.json"),
36
+ scenarios_dir="testql-scenarios",
37
+ track_dir=".wup/tracks",
38
+ config=empty_config,
39
+ )
40
+
41
+ watcher.dependency_mapper.service_to_endpoints["app/users"] = ["/api/v1/users"]
42
+
43
+ def fake_run_testql(args, timeout):
44
+ if "--dry-run" in args:
45
+ return CompletedProcess(args=args, returncode=1, stdout="", stderr="intentional failure")
46
+ return CompletedProcess(args=args, returncode=0, stdout="{}", stderr="")
47
+
48
+ watcher._run_testql = fake_run_testql # type: ignore[method-assign]
49
+
50
+ result = asyncio.run(watcher.process_changed_file_once(str(app_file)))
51
+
52
+ assert result["processed_items"] >= 1
53
+ assert result["last_track_path"] is not None
54
+
55
+ track_path = Path(result["last_track_path"])
56
+ assert track_path.exists()
57
+
58
+ track_payload = json.loads(track_path.read_text(encoding="utf-8"))
59
+ assert track_payload["service"] == "app/users"
60
+ assert track_payload["stage"] == "quick"
61
+ assert "intentional failure" in track_payload["stderr_head"]
62
+
63
+
64
+ def test_browser_event_file_is_written_without_service_url():
65
+ with tempfile.TemporaryDirectory() as tmpdir:
66
+ root = Path(tmpdir)
67
+ scenario_dir = root / "testql-scenarios"
68
+ scenario_dir.mkdir(parents=True, exist_ok=True)
69
+ scenario_file = scenario_dir / "api-users-smoke.testql.toon.yaml"
70
+ scenario_file.write_text("name: smoke\n", encoding="utf-8")
71
+
72
+ watcher = TestQLWatcher(
73
+ project_root=str(root),
74
+ deps_file=str(root / "deps.json"),
75
+ scenarios_dir="testql-scenarios",
76
+ track_dir=".wup/tracks",
77
+ )
78
+
79
+ result = CompletedProcess(args=["testql", "run"], returncode=1, stdout="", stderr="boom")
80
+ track_path = watcher._write_track(
81
+ service="app/users",
82
+ stage="quick",
83
+ scenario=scenario_file,
84
+ result=result,
85
+ )
86
+
87
+ assert track_path.exists()
88
+ event_file = root / ".wup" / "browser-events" / "latest.json"
89
+ assert event_file.exists()
90
+ event_payload = json.loads(event_file.read_text(encoding="utf-8"))
91
+ assert event_payload["type"] == "wup_testql_error"
92
+ assert event_payload["service"] == "app/users"
93
+
94
+
95
+ def test_config_endpoints_use_base_url_from_yaml_config():
96
+ with tempfile.TemporaryDirectory() as tmpdir:
97
+ root = Path(tmpdir)
98
+ cfg = WupConfig(
99
+ project=ProjectConfig(name="demo"),
100
+ testql=TestQLConfig(
101
+ base_url="http://localhost:8100",
102
+ explicit_endpoints=["/connect-config"],
103
+ endpoints_by_service={"connect-config": ["/connect-config-sitemap"]},
104
+ ),
105
+ )
106
+
107
+ watcher = TestQLWatcher(
108
+ project_root=str(root),
109
+ deps_file=str(root / "deps.json"),
110
+ scenarios_dir="testql-scenarios",
111
+ track_dir=".wup/tracks",
112
+ config=cfg,
113
+ )
114
+
115
+ endpoints = watcher._get_config_endpoints_for_service("connect-config")
116
+ assert "http://localhost:8100/connect-config" in endpoints
117
+ assert "http://localhost:8100/connect-config-sitemap" in endpoints
118
+
119
+
120
+ def test_config_endpoints_use_base_url_from_env_when_yaml_missing():
121
+ with tempfile.TemporaryDirectory() as tmpdir:
122
+ root = Path(tmpdir)
123
+ cfg = WupConfig(
124
+ project=ProjectConfig(name="demo"),
125
+ testql=TestQLConfig(
126
+ base_url="",
127
+ base_url_env="WUP_BASE_URL",
128
+ explicit_endpoints=["/connect-data"],
129
+ ),
130
+ )
131
+
132
+ old_value = os.environ.get("WUP_BASE_URL")
133
+ os.environ["WUP_BASE_URL"] = "http://localhost:8100"
134
+ try:
135
+ watcher = TestQLWatcher(
136
+ project_root=str(root),
137
+ deps_file=str(root / "deps.json"),
138
+ scenarios_dir="testql-scenarios",
139
+ track_dir=".wup/tracks",
140
+ config=cfg,
141
+ )
142
+
143
+ endpoints = watcher._get_config_endpoints_for_service("connect-data")
144
+ assert "http://localhost:8100/connect-data" in endpoints
145
+ finally:
146
+ if old_value is None:
147
+ os.environ.pop("WUP_BASE_URL", None)
148
+ else:
149
+ os.environ["WUP_BASE_URL"] = old_value
150
+
151
+
152
+ def test_service_health_transitions_are_persisted():
153
+ with tempfile.TemporaryDirectory() as tmpdir:
154
+ root = Path(tmpdir)
155
+ scenario_dir = root / "testql-scenarios"
156
+ scenario_dir.mkdir(parents=True, exist_ok=True)
157
+ scenario_file = scenario_dir / "connect-config-smoke.testql.toon.yaml"
158
+ scenario_file.write_text("name: smoke\n", encoding="utf-8")
159
+
160
+ watcher = TestQLWatcher(
161
+ project_root=str(root),
162
+ deps_file=str(root / "deps.json"),
163
+ scenarios_dir="testql-scenarios",
164
+ track_dir=".wup/tracks",
165
+ )
166
+
167
+ # 1) First quick run fails -> service goes down
168
+ def failing_run(args, timeout):
169
+ return CompletedProcess(args=args, returncode=1, stdout="", stderr="down")
170
+
171
+ watcher._run_testql = failing_run # type: ignore[method-assign]
172
+ failed = asyncio.run(watcher.run_quick_test("connect-config", []))
173
+ assert failed is False
174
+
175
+ health_state_path = root / ".wup" / "service-health.json"
176
+ health_events_path = root / ".wup" / "service-health-events.jsonl"
177
+ assert health_state_path.exists()
178
+ assert health_events_path.exists()
179
+
180
+ state = json.loads(health_state_path.read_text(encoding="utf-8"))
181
+ assert state["connect-config"]["status"] == "down"
182
+
183
+ # 2) Next quick run succeeds -> service goes up
184
+ def passing_run(args, timeout):
185
+ return CompletedProcess(args=args, returncode=0, stdout="ok", stderr="")
186
+
187
+ watcher._run_testql = passing_run # type: ignore[method-assign]
188
+ passed = asyncio.run(watcher.run_quick_test("connect-config", []))
189
+ assert passed is True
190
+
191
+ state = json.loads(health_state_path.read_text(encoding="utf-8"))
192
+ assert state["connect-config"]["status"] == "up"
193
+
194
+ events = []
195
+ with health_events_path.open("r", encoding="utf-8") as handle:
196
+ for line in handle:
197
+ events.append(json.loads(line))
198
+
199
+ statuses = [event.get("status") for event in events if event.get("service") == "connect-config"]
200
+ assert "down" in statuses
201
+ assert "up" in statuses
@@ -7,7 +7,7 @@ WUP monitors file changes and runs intelligent regression tests using a 3-layer
7
7
  3. Detail Layer: Full tests with blame reports (only on failure)
8
8
  """
9
9
 
10
- __version__ = "0.2.9"
10
+ __version__ = "0.2.12"
11
11
  __author__ = "Tom Sapletta"
12
12
 
13
13
  from .config import load_config, save_config, get_default_config
@@ -171,77 +171,124 @@ def map_deps(
171
171
  def status(
172
172
  deps_file: str = typer.Option("deps.json", "--deps", "-d", help="Path to dependency map file"),
173
173
  config: Optional[str] = typer.Option(None, "--config", "-C", help="Path to wup.yaml config file"),
174
+ delta_seconds: int = typer.Option(0, "--delta-seconds", help="Show only service health transitions from last N seconds"),
175
+ failed_only: bool = typer.Option(False, "--failed-only", help="Show only currently failing services"),
176
+ watch: bool = typer.Option(False, "--watch", "-w", help="Live mode: refresh display in real time"),
177
+ interval: int = typer.Option(5, "--interval", "-i", help="Refresh interval in seconds for --watch mode"),
174
178
  ):
175
179
  """
176
180
  Show dependency map status and configuration.
177
181
  """
182
+ import json
183
+ import time
184
+
178
185
  project_path = Path(".").resolve()
179
-
180
- # Load configuration
181
186
  config_path = Path(config) if config else None
182
187
  wup_config = load_config(project_path, config_path)
183
-
184
- console.print(f"[bold cyan]📊 WUP Status[/bold cyan]")
185
- console.print(f"[dim]Project: {wup_config.project.name}[/dim]")
186
- console.print(f"[dim]Description: {wup_config.project.description}[/dim]")
187
- console.print()
188
-
189
- # Show watch configuration
190
- console.print("[bold]Watch Configuration:[/bold]")
191
- console.print(f" Paths: {', '.join(wup_config.watch.paths) if wup_config.watch.paths else 'default'}")
192
- console.print(f" Excludes: {', '.join(wup_config.watch.exclude_patterns)}")
193
- console.print()
194
-
195
- # Show services
196
- if wup_config.services:
197
- console.print(f"[bold]Services ({len(wup_config.services)}):[/bold]")
198
- for svc in wup_config.services:
199
- console.print(f" [cyan]{svc.name}[/cyan]")
200
- console.print(f" Root: {svc.root}")
201
- console.print(f" Quick: scope={svc.quick_tests.scope}, max={svc.quick_tests.max_endpoints}")
202
- console.print(f" Detail: scope={svc.detail_tests.scope}, max={svc.detail_tests.max_endpoints}")
203
- console.print()
204
-
205
- # Show dependency map status
188
+ health_state_path = project_path / ".wup" / "service-health.json"
189
+ health_events_path = project_path / ".wup" / "service-health-events.jsonl"
190
+
206
191
  deps_path = Path(deps_file)
207
-
208
- if not deps_path.exists():
209
- console.print(f"[yellow]Warning: Dependency file '{deps_file}' does not exist[/yellow]")
210
- console.print(f"[dim]Run 'wup map-deps' to create it[/dim]")
211
- console.print()
212
- return
213
-
214
- import json
215
- with open(deps_file) as f:
216
- deps = json.load(f)
217
-
218
- services = deps.get("services", {})
219
- files = deps.get("files", {})
220
-
221
- console.print(f"[bold]Dependency Map:[/bold]")
222
- console.print(f" Services: {len(services)}")
223
- console.print(f" Files: {len(files)}")
224
- console.print()
225
-
226
- if services:
227
- console.print("[bold]Service Details:[/bold]")
228
- for service, info in sorted(services.items()):
229
- # Handle both dict format (new) and list format (legacy)
230
- if isinstance(info, dict):
231
- endpoints = info.get("endpoints", [])
232
- service_files = info.get("files", [])
233
- elif isinstance(info, list):
234
- endpoints = info
235
- service_files = []
192
+
193
+ def _build_panel(ts: float) -> "Group":
194
+ from rich.console import Group
195
+ from rich.text import Text
196
+ from rich.padding import Padding
197
+ lines: list = []
198
+
199
+ # header
200
+ lines.append(Text.from_markup(
201
+ f"[bold cyan]📊 WUP Status[/bold cyan] "
202
+ f"[dim]{wup_config.project.name}[/dim] "
203
+ f"[dim]updated {time.strftime('%H:%M:%S', time.localtime(ts))}[/dim]"
204
+ ))
205
+
206
+ # --- failing services ---
207
+ health_state: dict = {}
208
+ if health_state_path.exists():
209
+ try:
210
+ payload = json.loads(health_state_path.read_text(encoding="utf-8"))
211
+ if isinstance(payload, dict):
212
+ health_state = payload
213
+ except json.JSONDecodeError:
214
+ pass
215
+
216
+ if failed_only or watch:
217
+ failing = [
218
+ (svc, data)
219
+ for svc, data in sorted(health_state.items())
220
+ if isinstance(data, dict) and data.get("status") == "down"
221
+ ]
222
+ lines.append(Text(""))
223
+ lines.append(Text.from_markup("[bold]Currently failing services:[/bold]"))
224
+ if not failing:
225
+ lines.append(Text.from_markup(" [green]✓ None[/green]"))
226
+ else:
227
+ for svc, data in failing:
228
+ stage = data.get("stage", "")
229
+ message = data.get("message", "")
230
+ track_file = data.get("track_file", "")
231
+ lines.append(Text.from_markup(f" [red]✗ {svc}[/red] [dim]{stage}[/dim]"))
232
+ if message:
233
+ lines.append(Text.from_markup(f" [dim]{message}[/dim]"))
234
+ if track_file:
235
+ lines.append(Text.from_markup(f" [dim]track: {track_file}[/dim]"))
236
+
237
+ # --- delta ---
238
+ effective_delta = delta_seconds if delta_seconds > 0 else (30 if watch else 0)
239
+ if effective_delta > 0:
240
+ cutoff = int(ts) - effective_delta
241
+ recent_events: list = []
242
+ if health_events_path.exists():
243
+ with health_events_path.open("r", encoding="utf-8") as handle:
244
+ for line in handle:
245
+ line = line.strip()
246
+ if not line:
247
+ continue
248
+ try:
249
+ event = json.loads(line)
250
+ except json.JSONDecodeError:
251
+ continue
252
+ if int(event.get("timestamp", 0)) >= cutoff:
253
+ recent_events.append(event)
254
+
255
+ lines.append(Text(""))
256
+ lines.append(Text.from_markup(f"[bold]Service health delta (last {effective_delta}s):[/bold]"))
257
+ if not recent_events:
258
+ lines.append(Text.from_markup(" [yellow]No health transitions in selected window[/yellow]"))
236
259
  else:
237
- endpoints = []
238
- service_files = []
239
-
240
- console.print(f" [cyan]{service}[/cyan]")
241
- console.print(f" Endpoints: {len(endpoints)}")
242
- console.print(f" Files: {len(service_files)}")
243
- if endpoints:
244
- console.print(f" Sample endpoints: {', '.join(endpoints[:3])}")
260
+ recent_events.sort(key=lambda e: int(e.get("timestamp", 0)), reverse=True)
261
+ for event in recent_events:
262
+ svc = event.get("service", "unknown")
263
+ prev = event.get("previous_status", "unknown")
264
+ curr = event.get("status", "unknown")
265
+ stage = event.get("stage", "")
266
+ message = event.get("message", "")
267
+ track_file = event.get("track_file", "")
268
+ arrow_color = "green" if curr == "up" else "red"
269
+ lines.append(Text.from_markup(
270
+ f" [cyan]{svc}[/cyan]: {prev} [bold {arrow_color}]→ {curr}[/bold {arrow_color}] [dim]({stage})[/dim]"
271
+ ))
272
+ if message:
273
+ lines.append(Text.from_markup(f" [dim]{message}[/dim]"))
274
+ if track_file:
275
+ lines.append(Text.from_markup(f" [dim]track: {track_file}[/dim]"))
276
+
277
+ return Group(*lines)
278
+
279
+ if not watch:
280
+ console.print(_build_panel(time.time()))
281
+ return
282
+
283
+ # --- live / watch mode ---
284
+ from rich.live import Live
285
+ try:
286
+ with Live(_build_panel(time.time()), refresh_per_second=1, console=console) as live:
287
+ while True:
288
+ time.sleep(interval)
289
+ live.update(_build_panel(time.time()))
290
+ except KeyboardInterrupt:
291
+ pass
245
292
 
246
293
 
247
294
  @app.command()
@@ -132,11 +132,19 @@ class WupWatcher:
132
132
  if re.search(pattern, path_lower):
133
133
  return svc.name
134
134
 
135
+ # Heuristic: if top-level directory matches known prefix patterns (e.g. connect-*)
136
+ # use it directly as the service name — takes priority over stale deps.json
137
+ if parts:
138
+ top = parts[0]
139
+ import re as _re
140
+ if _re.match(r'^(connect|backend|frontend|api|app|worker|service)[-_]', top):
141
+ return top
142
+
135
143
  # Use dependency mapper if available
136
144
  service = self.dependency_mapper.get_service_for_file(file_path)
137
145
  if service:
138
146
  return service
139
-
147
+
140
148
  # Fallback: use first two meaningful parts (only if file exists)
141
149
  if len(parts) >= 2:
142
150
  # Check if file exists (absolute path)
@@ -51,10 +51,12 @@ class TestQLWatcher(WupWatcher):
51
51
 
52
52
  __test__ = False
53
53
 
54
+ _UNSET = object()
55
+
54
56
  def __init__(
55
57
  self,
56
58
  project_root: str,
57
- scenarios_dir: str = "testql-scenarios",
59
+ scenarios_dir=_UNSET,
58
60
  testql_bin: str = "testql",
59
61
  track_dir: str = ".wup/tracks",
60
62
  browser_service_url: Optional[str] = None,
@@ -69,11 +71,13 @@ class TestQLWatcher(WupWatcher):
69
71
  # Pass config to parent class
70
72
  super().__init__(project_root=project_root, config=config, **kwargs)
71
73
 
72
- # Use config scenario_dir if available, otherwise use parameter default
73
- if config and config.testql and config.testql.scenario_dir:
74
+ # Explicit constructor arg wins; otherwise use config; final fallback default
75
+ if scenarios_dir is not self._UNSET:
76
+ self.scenarios_dir = self.project_root / scenarios_dir
77
+ elif config and config.testql and config.testql.scenario_dir:
74
78
  self.scenarios_dir = self.project_root / config.testql.scenario_dir
75
79
  else:
76
- self.scenarios_dir = self.project_root / scenarios_dir
80
+ self.scenarios_dir = self.project_root / "testql-scenarios"
77
81
  self.testql_bin = testql_bin
78
82
  self.testql_extra_args = config.testql.extra_args if config and config.testql else []
79
83
 
@@ -85,8 +89,79 @@ class TestQLWatcher(WupWatcher):
85
89
  events_file=self.project_root / ".wup" / "browser-events" / "latest.json",
86
90
  )
87
91
  self.last_track_path: Optional[Path] = None
92
+ self.health_state_path = self.project_root / ".wup" / "service-health.json"
93
+ self.health_events_path = self.project_root / ".wup" / "service-health-events.jsonl"
94
+ self.health_state_path.parent.mkdir(parents=True, exist_ok=True)
95
+ self.service_health = self._load_service_health()
88
96
  self.config = config
89
97
 
98
+ def _load_service_health(self) -> Dict[str, Dict]:
99
+ if not self.health_state_path.exists():
100
+ return {}
101
+ try:
102
+ payload = json.loads(self.health_state_path.read_text(encoding="utf-8"))
103
+ if isinstance(payload, dict):
104
+ return payload
105
+ except (json.JSONDecodeError, OSError):
106
+ return {}
107
+ return {}
108
+
109
+ def _save_service_health(self) -> None:
110
+ self.health_state_path.write_text(
111
+ json.dumps(self.service_health, indent=2),
112
+ encoding="utf-8",
113
+ )
114
+
115
+ def _record_health_transition(
116
+ self,
117
+ *,
118
+ service: str,
119
+ status: str,
120
+ stage: str,
121
+ message: str = "",
122
+ track_file: Optional[str] = None,
123
+ ) -> None:
124
+ now = int(time.time())
125
+ previous = self.service_health.get(service, {})
126
+ previous_status = previous.get("status", "unknown")
127
+
128
+ self.service_health[service] = {
129
+ "status": status,
130
+ "updated_at": now,
131
+ "stage": stage,
132
+ "message": message,
133
+ "track_file": track_file or "",
134
+ }
135
+ self._save_service_health()
136
+
137
+ changed = previous_status != status
138
+ if not changed:
139
+ return
140
+
141
+ event = {
142
+ "timestamp": now,
143
+ "service": service,
144
+ "status": status,
145
+ "previous_status": previous_status,
146
+ "stage": stage,
147
+ "message": message,
148
+ "track_file": track_file or "",
149
+ }
150
+ with self.health_events_path.open("a", encoding="utf-8") as handle:
151
+ handle.write(json.dumps(event) + "\n")
152
+
153
+ self.browser_notifier.notify(
154
+ {
155
+ "type": "wup_service_health_change",
156
+ "service": service,
157
+ "status": status,
158
+ "previous_status": previous_status,
159
+ "stage": stage,
160
+ "message": message,
161
+ "track_file": track_file,
162
+ }
163
+ )
164
+
90
165
  def _tokenize_service(self, service: str) -> List[str]:
91
166
  raw_tokens = re.split(r"[^a-zA-Z0-9]+", service.lower())
92
167
  return [token for token in raw_tokens if len(token) >= 3]
@@ -98,8 +173,9 @@ class TestQLWatcher(WupWatcher):
98
173
  service_specific = by_service.get(service, [])
99
174
  merged: List[str] = []
100
175
  for endpoint in [*service_specific, *explicit]:
101
- if endpoint not in merged:
102
- merged.append(endpoint)
176
+ endpoint_url = self._to_full_url(endpoint)
177
+ if endpoint_url not in merged:
178
+ merged.append(endpoint_url)
103
179
  return merged
104
180
 
105
181
  def _resolve_base_url(self) -> str:
@@ -284,11 +360,24 @@ class TestQLWatcher(WupWatcher):
284
360
  scenario=scenario,
285
361
  result=result,
286
362
  )
363
+ self._record_health_transition(
364
+ service=service,
365
+ status="down",
366
+ stage="quick",
367
+ message=result.stderr.strip() or result.stdout.strip() or "Quick TestQL failed",
368
+ track_file=str(track_path),
369
+ )
287
370
  self.console.print(
288
371
  f"[red]✗ Quick failed: {scenario.name} | track: {track_path}[/red]"
289
372
  )
290
373
  return False
291
374
 
375
+ self._record_health_transition(
376
+ service=service,
377
+ status="up",
378
+ stage="quick",
379
+ message="Quick TestQL passed",
380
+ )
292
381
  self.console.print(f"[green]✓ Quick TestQL passed for {service}[/green]")
293
382
  return True
294
383
 
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wup
3
- Version: 0.2.9
3
+ Version: 0.2.12
4
4
  Summary: WUP (What's Up) - Intelligent file watcher for regression testing in large projects
5
5
  Author-email: Tom Sapletta <tom@sapletta.com>
6
- License-Expression: Apache-2.0
6
+ License: Apache-2.0
7
7
  Project-URL: Homepage, https://github.com/semcod/wup
8
8
  Project-URL: Repository, https://github.com/semcod/wup
9
9
  Keywords: wup,watcher,testing,regression,file-monitoring
@@ -21,6 +21,7 @@ Requires-Dist: watchdog>=4.0.0
21
21
  Requires-Dist: psutil>=5.9.0
22
22
  Requires-Dist: rich>=13.0.0
23
23
  Requires-Dist: typer>=0.9.0
24
+ Requires-Dist: pyyaml>=6.0
24
25
  Dynamic: license-file
25
26
 
26
27
  # WUP (What's Up)
@@ -28,17 +29,17 @@ Dynamic: license-file
28
29
 
29
30
  ## AI Cost Tracking
30
31
 
31
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.9-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
32
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$1.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-2.4h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
32
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.12-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
33
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$1.95-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-3.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
33
34
 
34
- - 🤖 **LLM usage:** $1.5000 (10 commits)
35
- - 👤 **Human dev:** ~$240 (2.4h @ $100/h, 30min dedup)
35
+ - 🤖 **LLM usage:** $1.9500 (13 commits)
36
+ - 👤 **Human dev:** ~$312 (3.1h @ $100/h, 30min dedup)
36
37
 
37
38
  Generated on 2026-04-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
38
39
 
39
40
  ---
40
41
 
41
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.9-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
42
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.12-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
42
43
 
43
44
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
44
45
 
@@ -2,3 +2,4 @@ watchdog>=4.0.0
2
2
  psutil>=5.9.0
3
3
  rich>=13.0.0
4
4
  typer>=0.9.0
5
+ pyyaml>=6.0
@@ -1,90 +0,0 @@
1
- import asyncio
2
- import json
3
- import tempfile
4
- from pathlib import Path
5
- from subprocess import CompletedProcess
6
-
7
- from wup.testql_watcher import TestQLWatcher
8
- from wup.models.config import WupConfig, ProjectConfig
9
-
10
-
11
- def test_process_changed_file_creates_track_on_failure():
12
- with tempfile.TemporaryDirectory() as tmpdir:
13
- root = Path(tmpdir)
14
- app_file = root / "app" / "users" / "routes.py"
15
- app_file.parent.mkdir(parents=True, exist_ok=True)
16
- app_file.write_text("print('x')\n", encoding="utf-8")
17
-
18
- scenario_dir = root / "testql-scenarios"
19
- scenario_dir.mkdir(parents=True, exist_ok=True)
20
- failing_scenario = scenario_dir / "app-users.testql.toon.yaml"
21
- failing_scenario.write_text("name: failing\n", encoding="utf-8")
22
-
23
- # Pass empty config to prevent loading from temp dir
24
- from wup.models.config import TestQLConfig
25
- empty_config = WupConfig(
26
- project=ProjectConfig(name="test"),
27
- services=[],
28
- test_strategy=None,
29
- testql=TestQLConfig(scenario_dir="testql-scenarios")
30
- )
31
- watcher = TestQLWatcher(
32
- project_root=str(root),
33
- deps_file=str(root / "deps.json"),
34
- scenarios_dir="testql-scenarios",
35
- track_dir=".wup/tracks",
36
- config=empty_config,
37
- )
38
-
39
- watcher.dependency_mapper.service_to_endpoints["app/users"] = ["/api/v1/users"]
40
-
41
- def fake_run_testql(args, timeout):
42
- if "--dry-run" in args:
43
- return CompletedProcess(args=args, returncode=1, stdout="", stderr="intentional failure")
44
- return CompletedProcess(args=args, returncode=0, stdout="{}", stderr="")
45
-
46
- watcher._run_testql = fake_run_testql # type: ignore[method-assign]
47
-
48
- result = asyncio.run(watcher.process_changed_file_once(str(app_file)))
49
-
50
- assert result["processed_items"] >= 1
51
- assert result["last_track_path"] is not None
52
-
53
- track_path = Path(result["last_track_path"])
54
- assert track_path.exists()
55
-
56
- track_payload = json.loads(track_path.read_text(encoding="utf-8"))
57
- assert track_payload["service"] == "app/users"
58
- assert track_payload["stage"] == "quick"
59
- assert "intentional failure" in track_payload["stderr_head"]
60
-
61
-
62
- def test_browser_event_file_is_written_without_service_url():
63
- with tempfile.TemporaryDirectory() as tmpdir:
64
- root = Path(tmpdir)
65
- scenario_dir = root / "testql-scenarios"
66
- scenario_dir.mkdir(parents=True, exist_ok=True)
67
- scenario_file = scenario_dir / "api-users-smoke.testql.toon.yaml"
68
- scenario_file.write_text("name: smoke\n", encoding="utf-8")
69
-
70
- watcher = TestQLWatcher(
71
- project_root=str(root),
72
- deps_file=str(root / "deps.json"),
73
- scenarios_dir="testql-scenarios",
74
- track_dir=".wup/tracks",
75
- )
76
-
77
- result = CompletedProcess(args=["testql", "run"], returncode=1, stdout="", stderr="boom")
78
- track_path = watcher._write_track(
79
- service="app/users",
80
- stage="quick",
81
- scenario=scenario_file,
82
- result=result,
83
- )
84
-
85
- assert track_path.exists()
86
- event_file = root / ".wup" / "browser-events" / "latest.json"
87
- assert event_file.exists()
88
- event_payload = json.loads(event_file.read_text(encoding="utf-8"))
89
- assert event_payload["type"] == "wup_testql_error"
90
- assert event_payload["service"] == "app/users"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes