wup 0.2.68__tar.gz → 0.2.69__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.
Files changed (78) hide show
  1. {wup-0.2.68/wup.egg-info → wup-0.2.69}/PKG-INFO +7 -7
  2. {wup-0.2.68 → wup-0.2.69}/README.md +6 -6
  3. {wup-0.2.68 → wup-0.2.69}/pyproject.toml +1 -1
  4. wup-0.2.69/tests/test_planfile_reporter_dedupe.py +78 -0
  5. {wup-0.2.68 → wup-0.2.69}/tests/test_testql_watcher.py +13 -4
  6. wup-0.2.69/tests/test_watch_no_paths_exit.py +30 -0
  7. {wup-0.2.68 → wup-0.2.69}/tests/test_wup.py +17 -8
  8. {wup-0.2.68 → wup-0.2.69}/wup/__init__.py +1 -1
  9. {wup-0.2.68 → wup-0.2.69}/wup/cli.py +2 -1
  10. {wup-0.2.68 → wup-0.2.69}/wup/core.py +25 -16
  11. {wup-0.2.68 → wup-0.2.69}/wup/models/__init__.py +2 -0
  12. wup-0.2.69/wup/models/target.py +23 -0
  13. {wup-0.2.68 → wup-0.2.69}/wup/planfile_reporter.py +36 -3
  14. {wup-0.2.68 → wup-0.2.69}/wup/testql_watcher.py +9 -4
  15. {wup-0.2.68 → wup-0.2.69}/wup/visual_diff.py +8 -4
  16. {wup-0.2.68 → wup-0.2.69/wup.egg-info}/PKG-INFO +7 -7
  17. {wup-0.2.68 → wup-0.2.69}/wup.egg-info/SOURCES.txt +3 -0
  18. {wup-0.2.68 → wup-0.2.69}/LICENSE +0 -0
  19. {wup-0.2.68 → wup-0.2.69}/setup.cfg +0 -0
  20. {wup-0.2.68 → wup-0.2.69}/tests/test_assistant.py +0 -0
  21. {wup-0.2.68 → wup-0.2.69}/tests/test_auto_detection.py +0 -0
  22. {wup-0.2.68 → wup-0.2.69}/tests/test_cli_bridge.py +0 -0
  23. {wup-0.2.68 → wup-0.2.69}/tests/test_cli_filtering.py +0 -0
  24. {wup-0.2.68 → wup-0.2.69}/tests/test_control.py +0 -0
  25. {wup-0.2.68 → wup-0.2.69}/tests/test_e2e.py +0 -0
  26. {wup-0.2.68 → wup-0.2.69}/tests/test_endpoints_init_cli.py +0 -0
  27. {wup-0.2.68 → wup-0.2.69}/tests/test_health_summary_passed.py +0 -0
  28. {wup-0.2.68 → wup-0.2.69}/tests/test_monitoring_manifest.py +0 -0
  29. {wup-0.2.68 → wup-0.2.69}/tests/test_probe_mutex.py +0 -0
  30. {wup-0.2.68 → wup-0.2.69}/tests/test_service_inference.py +0 -0
  31. {wup-0.2.68 → wup-0.2.69}/tests/test_status_data.py +0 -0
  32. {wup-0.2.68 → wup-0.2.69}/tests/test_sync.py +0 -0
  33. {wup-0.2.68 → wup-0.2.69}/tests/test_testql_monitor.py +0 -0
  34. {wup-0.2.68 → wup-0.2.69}/tests/test_visual_diff_periodic_skip.py +0 -0
  35. {wup-0.2.68 → wup-0.2.69}/tests/test_visual_diff_progress.py +0 -0
  36. {wup-0.2.68 → wup-0.2.69}/tests/test_watch_exclude.py +0 -0
  37. {wup-0.2.68 → wup-0.2.69}/tests/test_web_client.py +0 -0
  38. {wup-0.2.68 → wup-0.2.69}/tests/test_wup_generate.py +0 -0
  39. {wup-0.2.68 → wup-0.2.69}/wup/_ast_detector.py +0 -0
  40. {wup-0.2.68 → wup-0.2.69}/wup/_base_detector.py +0 -0
  41. {wup-0.2.68 → wup-0.2.69}/wup/_hash_detector.py +0 -0
  42. {wup-0.2.68 → wup-0.2.69}/wup/_yaml_detector.py +0 -0
  43. {wup-0.2.68 → wup-0.2.69}/wup/anomaly_detector.py +0 -0
  44. {wup-0.2.68 → wup-0.2.69}/wup/anomaly_models.py +0 -0
  45. {wup-0.2.68 → wup-0.2.69}/wup/assistant.py +0 -0
  46. {wup-0.2.68 → wup-0.2.69}/wup/assistant_discovery.py +0 -0
  47. {wup-0.2.68 → wup-0.2.69}/wup/assistant_validator.py +0 -0
  48. {wup-0.2.68 → wup-0.2.69}/wup/bus.py +0 -0
  49. {wup-0.2.68 → wup-0.2.69}/wup/cli_bridge.py +0 -0
  50. {wup-0.2.68 → wup-0.2.69}/wup/cli_config_generator.py +0 -0
  51. {wup-0.2.68 → wup-0.2.69}/wup/cli_scanner.py +0 -0
  52. {wup-0.2.68 → wup-0.2.69}/wup/config.py +0 -0
  53. {wup-0.2.68 → wup-0.2.69}/wup/control.py +0 -0
  54. {wup-0.2.68 → wup-0.2.69}/wup/dependency_mapper.py +0 -0
  55. {wup-0.2.68 → wup-0.2.69}/wup/endpoints.py +0 -0
  56. {wup-0.2.68 → wup-0.2.69}/wup/event_store.py +0 -0
  57. {wup-0.2.68 → wup-0.2.69}/wup/file_watcher/events/file_events.py +0 -0
  58. {wup-0.2.68 → wup-0.2.69}/wup/generate.py +0 -0
  59. {wup-0.2.68 → wup-0.2.69}/wup/init_cli.py +0 -0
  60. {wup-0.2.68 → wup-0.2.69}/wup/models/config.py +0 -0
  61. {wup-0.2.68 → wup-0.2.69}/wup/monitoring_manifest.py +0 -0
  62. {wup-0.2.68 → wup-0.2.69}/wup/paths.py +0 -0
  63. {wup-0.2.68 → wup-0.2.69}/wup/status_data.py +0 -0
  64. {wup-0.2.68 → wup-0.2.69}/wup/sync.py +0 -0
  65. {wup-0.2.68 → wup-0.2.69}/wup/testing/events/health_events.py +0 -0
  66. {wup-0.2.68 → wup-0.2.69}/wup/testing/events/test_results.py +0 -0
  67. {wup-0.2.68 → wup-0.2.69}/wup/testing/handlers/event_handlers.py +0 -0
  68. {wup-0.2.68 → wup-0.2.69}/wup/testing/handlers/health_handlers.py +0 -0
  69. {wup-0.2.68 → wup-0.2.69}/wup/testing/queries/health_queries.py +0 -0
  70. {wup-0.2.68 → wup-0.2.69}/wup/testql_cli_generator.py +0 -0
  71. {wup-0.2.68 → wup-0.2.69}/wup/testql_discovery.py +0 -0
  72. {wup-0.2.68 → wup-0.2.69}/wup/testql_monitor.py +0 -0
  73. {wup-0.2.68 → wup-0.2.69}/wup/validate.py +0 -0
  74. {wup-0.2.68 → wup-0.2.69}/wup/web_client.py +0 -0
  75. {wup-0.2.68 → wup-0.2.69}/wup.egg-info/dependency_links.txt +0 -0
  76. {wup-0.2.68 → wup-0.2.69}/wup.egg-info/entry_points.txt +0 -0
  77. {wup-0.2.68 → wup-0.2.69}/wup.egg-info/requires.txt +0 -0
  78. {wup-0.2.68 → wup-0.2.69}/wup.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wup
3
- Version: 0.2.68
3
+ Version: 0.2.69
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
6
  License-Expression: Apache-2.0
@@ -42,17 +42,17 @@ Dynamic: license-file
42
42
 
43
43
  ## AI Cost Tracking
44
44
 
45
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.68-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
46
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$3.88-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-39.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
45
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.69-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
46
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$5.86-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-51.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
47
47
 
48
- - 🤖 **LLM usage:** $3.8816 (82 commits)
49
- - 👤 **Human dev:** ~$3968 (39.7h @ $100/h, 30min dedup)
48
+ - 🤖 **LLM usage:** $5.8610 (97 commits)
49
+ - 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
50
50
 
51
- Generated on 2026-07-03 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
51
+ Generated on 2026-07-16 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
52
52
 
53
53
  ---
54
54
 
55
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.68-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
55
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.69-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
56
56
 
57
57
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
58
58
 
@@ -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.68-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-$3.88-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-39.7h-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.69-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-$5.86-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-51.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $3.8816 (82 commits)
10
- - 👤 **Human dev:** ~$3968 (39.7h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $5.8610 (97 commits)
10
+ - 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
11
11
 
12
- Generated on 2026-07-03 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
12
+ Generated on 2026-07-16 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.68-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.69-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,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wup"
7
- version = "0.2.68"
7
+ version = "0.2.69"
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.10"
@@ -0,0 +1,78 @@
1
+ """Regression: dedupe registry must not mute recurrences of CLOSED tickets.
2
+
3
+ A stale `.wup/planfile-tickets.json` entry whose ticket was already
4
+ done/canceled used to suppress every future ticket for the same
5
+ failure fingerprint — a regression of an old outage became silently
6
+ invisible (found live: `docker stop connect-scenario` produced no ticket
7
+ because PLF-1950 from a month earlier still held the fingerprint).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from types import SimpleNamespace
15
+
16
+ from wup import planfile_reporter as reporter_mod
17
+ from wup.models.config import PlanfileConfig
18
+ from wup.planfile_reporter import PlanfileReporter
19
+
20
+
21
+ def _reporter(tmp_path: Path) -> PlanfileReporter:
22
+ cfg = PlanfileConfig(enabled=True, command="planfile")
23
+ return PlanfileReporter(tmp_path, cfg)
24
+
25
+
26
+ def _seed_dedupe(rep: PlanfileReporter, fingerprint_kwargs: dict, ticket_id: str) -> None:
27
+ fingerprint = rep._fingerprint(**fingerprint_kwargs)
28
+ rep.dedupe_path.parent.mkdir(parents=True, exist_ok=True)
29
+ rep.dedupe_path.write_text(
30
+ json.dumps({fingerprint: {"ticket_id": ticket_id, "service": fingerprint_kwargs["service"], "stage": fingerprint_kwargs["stage"]}}),
31
+ encoding="utf-8",
32
+ )
33
+
34
+
35
+ FAIL = {"service": "svc", "status": "down", "stage": "probe", "message": "connection refused"}
36
+
37
+
38
+ def test_open_ticket_still_mutes_recurrence(tmp_path, monkeypatch):
39
+ rep = _reporter(tmp_path)
40
+ _seed_dedupe(rep, FAIL, "PLF-1")
41
+
42
+ def fake_run(cmd, **kwargs):
43
+ assert "show" in cmd
44
+ return SimpleNamespace(returncode=0, stdout=json.dumps({"id": "PLF-1", "status": "open"}), stderr="")
45
+
46
+ monkeypatch.setattr(reporter_mod.subprocess, "run", fake_run)
47
+ assert rep.report_failure(**FAIL) == "PLF-1"
48
+
49
+
50
+ def test_closed_ticket_refiles_fresh_ticket(tmp_path, monkeypatch):
51
+ rep = _reporter(tmp_path)
52
+ _seed_dedupe(rep, FAIL, "PLF-1")
53
+ calls = []
54
+
55
+ def fake_run(cmd, **kwargs):
56
+ calls.append(list(cmd))
57
+ if "show" in cmd:
58
+ return SimpleNamespace(returncode=0, stdout=json.dumps({"id": "PLF-1", "status": "done"}), stderr="")
59
+ return SimpleNamespace(returncode=0, stdout="✓ Created PLF-2: [AUTO-DIAG] wup-svc probe down", stderr="")
60
+
61
+ monkeypatch.setattr(reporter_mod.subprocess, "run", fake_run)
62
+ monkeypatch.setattr(rep, "_wait_for_planfile_store_ready", lambda timeout_s=30.0: True)
63
+
64
+ assert rep.report_failure(**FAIL) == "PLF-2"
65
+ # registry now points at the fresh ticket
66
+ dedupe = json.loads(rep.dedupe_path.read_text(encoding="utf-8"))
67
+ assert [v["ticket_id"] for v in dedupe.values()] == ["PLF-2"]
68
+
69
+
70
+ def test_show_error_keeps_muting_conservatively(tmp_path, monkeypatch):
71
+ rep = _reporter(tmp_path)
72
+ _seed_dedupe(rep, FAIL, "PLF-1")
73
+
74
+ def fake_run(cmd, **kwargs):
75
+ return SimpleNamespace(returncode=1, stdout="", stderr="boom")
76
+
77
+ monkeypatch.setattr(reporter_mod.subprocess, "run", fake_run)
78
+ assert rep.report_failure(**FAIL) == "PLF-1"
@@ -246,7 +246,10 @@ def test_planfile_reporter_creates_deduped_ticket(monkeypatch):
246
246
 
247
247
  assert first == "PLF-999"
248
248
  assert second == "PLF-999"
249
- assert len(calls) == 1
249
+ # Recurrence check may `ticket show` the deduped id, but only ONE
250
+ # ticket may ever be created for the same open fingerprint.
251
+ create_calls = [c for c in calls if c[:3] == ["planfile", "ticket", "create"]]
252
+ assert len(create_calls) == 1
250
253
  assert calls[0][:3] == ["planfile", "ticket", "create"]
251
254
  assert "--label" in calls[0]
252
255
  assert "llm-ready" in calls[0]
@@ -442,7 +445,13 @@ def test_visual_differ_disabled_by_default():
442
445
  # Differ is created but flagged disabled — run_for_service() must be a no-op
443
446
  assert watcher.visual_differ is not None
444
447
  assert watcher.visual_differ.cfg.enabled is False
445
- results = asyncio.run(watcher.visual_differ.run_for_service("svc", ["/x"]))
448
+ from wup.models.target import ServiceTestTarget
449
+
450
+ results = asyncio.run(
451
+ watcher.visual_differ.run_for_service(
452
+ ServiceTestTarget(service="svc", endpoints=["/x"])
453
+ )
454
+ )
446
455
  assert results == []
447
456
 
448
457
 
@@ -532,8 +541,8 @@ def test_quick_pass_actions_prefer_config_endpoints_for_visual_diff():
532
541
  self.cfg = VisualDiffConfig(enabled=True)
533
542
  self.calls = []
534
543
 
535
- async def run_for_service(self, service, endpoints):
536
- self.calls.append((service, list(endpoints)))
544
+ async def run_for_service(self, target):
545
+ self.calls.append((target.service, list(target.endpoints)))
537
546
  return []
538
547
 
539
548
  differ = RecordingDiffer()
@@ -0,0 +1,30 @@
1
+ """Regression: a watcher that never watched must not exit as a clean run.
2
+
3
+ `start_watching` used to bare-`return` when every configured watch path was
4
+ missing — under systemd (Restart=on-failure) the clean exit 0 meant the unit
5
+ silently died and never came back. It now returns False and the CLI turns
6
+ that into exit code 1.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from wup.core import WupWatcher
12
+ from wup.models.config import ProjectConfig, WatchConfig, WupConfig
13
+
14
+
15
+ def _watcher_with_paths(paths: list[str]) -> WupWatcher:
16
+ cfg = WupConfig(
17
+ project=ProjectConfig(name="t"),
18
+ watch=WatchConfig(paths=paths),
19
+ )
20
+ return WupWatcher(project_root="/nonexistent-wup-test-root", config=cfg)
21
+
22
+
23
+ def test_start_watching_returns_false_when_no_valid_paths() -> None:
24
+ watcher = _watcher_with_paths(["does-not-exist/**"])
25
+ assert watcher.start_watching() is False
26
+
27
+
28
+ def test_start_watching_returns_false_for_explicit_missing_paths() -> None:
29
+ watcher = _watcher_with_paths([])
30
+ assert watcher.start_watching(["/nonexistent-wup-test-root/a"]) is False
@@ -11,6 +11,7 @@ import pytest
11
11
  from wup.config import load_config, save_config, get_default_config
12
12
  from wup.core import WupWatcher
13
13
  from wup.dependency_mapper import DependencyMapper
14
+ from wup.models.target import ServiceTestTarget
14
15
  from wup.models.config import (
15
16
  WupConfig,
16
17
  WatchConfig,
@@ -1056,7 +1057,7 @@ class TestVisualDiffer:
1056
1057
  pages_from_endpoints=False,
1057
1058
  )
1058
1059
  differ = VisualDiffer(tmpdir, cfg)
1059
- pages = differ._pages_for_service("users", ["/api/users"])
1060
+ pages = differ._pages_for_service(ServiceTestTarget(service="users", endpoints=["/api/users"]))
1060
1061
  assert pages == ["http://localhost:8080/dashboard"]
1061
1062
 
1062
1063
  def test_pages_for_service_from_endpoints(self):
@@ -1066,7 +1067,9 @@ class TestVisualDiffer:
1066
1067
  pages_from_endpoints=True,
1067
1068
  )
1068
1069
  differ = VisualDiffer(tmpdir, cfg)
1069
- pages = differ._pages_for_service("users", ["/connect-users", "/connect-users/1"])
1070
+ pages = differ._pages_for_service(
1071
+ ServiceTestTarget(service="users", endpoints=["/connect-users", "/connect-users/1"])
1072
+ )
1070
1073
  assert "http://localhost:8080/connect-users" in pages
1071
1074
  assert "http://localhost:8080/connect-users/1" in pages
1072
1075
 
@@ -1083,8 +1086,10 @@ class TestVisualDiffer:
1083
1086
  )
1084
1087
  differ = VisualDiffer(tmpdir, cfg)
1085
1088
  pages = differ._pages_for_service(
1086
- "backend",
1087
- ["/api/v3/health", "/connect-config", "http://localhost:8080/connect-data"],
1089
+ ServiceTestTarget(
1090
+ service="backend",
1091
+ endpoints=["/api/v3/health", "/connect-config", "http://localhost:8080/connect-data"],
1092
+ )
1088
1093
  )
1089
1094
  assert "http://localhost:8080/api/v3/health" not in pages
1090
1095
  assert "http://localhost:8080/connect-config" in pages
@@ -1097,7 +1102,7 @@ class TestVisualDiffer:
1097
1102
  pages_from_endpoints=False,
1098
1103
  )
1099
1104
  differ = VisualDiffer(tmpdir, cfg)
1100
- pages = differ._pages_for_service("users", [])
1105
+ pages = differ._pages_for_service(ServiceTestTarget(service="users"))
1101
1106
  assert pages == ["http://localhost:8080/users"]
1102
1107
 
1103
1108
  def test_pages_for_service_absolute_url_passthrough(self):
@@ -1108,7 +1113,7 @@ class TestVisualDiffer:
1108
1113
  pages_from_endpoints=False,
1109
1114
  )
1110
1115
  differ = VisualDiffer(tmpdir, cfg)
1111
- pages = differ._pages_for_service("svc", [])
1116
+ pages = differ._pages_for_service(ServiceTestTarget(service="svc"))
1112
1117
  assert pages == ["https://example.com/connect-dashboard"]
1113
1118
 
1114
1119
  def test_diff_snapshots_baseline(self):
@@ -1144,7 +1149,9 @@ class TestVisualDiffer:
1144
1149
  with tempfile.TemporaryDirectory() as tmpdir:
1145
1150
  cfg = VisualDiffConfig(enabled=False)
1146
1151
  differ = VisualDiffer(tmpdir, cfg)
1147
- results = asyncio.run(differ.run_for_service("svc", ["/x"]))
1152
+ results = asyncio.run(
1153
+ differ.run_for_service(ServiceTestTarget(service="svc", endpoints=["/x"]))
1154
+ )
1148
1155
  assert results == []
1149
1156
 
1150
1157
  def test_run_for_service_summarizes_fetch_errors(self, monkeypatch):
@@ -1177,7 +1184,9 @@ class TestVisualDiffer:
1177
1184
  monkeypatch.setattr(visual_diff_module.console, "print", lambda message: printed.append(str(message)))
1178
1185
 
1179
1186
  results = asyncio.run(
1180
- differ.run_for_service("frontend", ["/a", "/b", "/c", "/d"])
1187
+ differ.run_for_service(
1188
+ ServiceTestTarget(service="frontend", endpoints=["/a", "/b", "/c", "/d"])
1189
+ )
1181
1190
  )
1182
1191
 
1183
1192
  assert len(results) == 4
@@ -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.68"
10
+ __version__ = "0.2.69"
11
11
  __author__ = "Tom Sapletta"
12
12
 
13
13
  from .config import load_config, save_config, get_default_config
@@ -198,7 +198,8 @@ def watch(
198
198
  asyncio.run(watcher.run_with_dashboard())
199
199
  else:
200
200
  console.print("[green]Starting watcher...[/green]")
201
- watcher.start_watching()
201
+ if watcher.start_watching() is False:
202
+ raise typer.Exit(1)
202
203
 
203
204
 
204
205
  def _auto_generate_config(project_path: Path, mode: str):
@@ -25,6 +25,7 @@ from watchdog.observers.polling import PollingObserver
25
25
  from .config import load_config
26
26
  from .dependency_mapper import DependencyMapper
27
27
  from .models.config import WupConfig, ServiceConfig
28
+ from .models.target import ServiceTestTarget
28
29
  from .planfile_reporter import PlanfileReporter
29
30
 
30
31
 
@@ -278,14 +279,15 @@ class WupWatcher:
278
279
  return
279
280
 
280
281
  test_type, service, endpoints = self.test_queue.popleft()
282
+ target = ServiceTestTarget(service, endpoints)
281
283
 
282
284
  try:
283
285
  if test_type == "quick":
284
- passed = await self.run_quick_test(service, endpoints)
286
+ passed = await self.run_quick_test(target)
285
287
  if not passed:
286
288
  self.schedule_detail_test(service)
287
289
  elif test_type == "detail":
288
- await self.run_detail_test(service, endpoints)
290
+ await self.run_detail_test(target)
289
291
  except Exception as e:
290
292
  self.console.print(f"[red]Error testing {service}: {e}[/red]")
291
293
 
@@ -302,17 +304,17 @@ class WupWatcher:
302
304
  except Exception:
303
305
  return True
304
306
 
305
- async def run_quick_test(self, service: str, endpoints: List[str]) -> bool:
307
+ async def run_quick_test(self, target: ServiceTestTarget) -> bool:
306
308
  """
307
309
  Run a quick test for a service (smoke test).
308
310
 
309
311
  Args:
310
- service: Service name
311
- endpoints: List of endpoints to test
312
+ target: Service and endpoints to test
312
313
 
313
314
  Returns:
314
315
  True if all tests passed, False otherwise
315
316
  """
317
+ service, endpoints = target.service, target.endpoints
316
318
  self.console.print(f"[cyan]🧪 Quick testing {service} ({len(endpoints)} endpoints)[/cyan]")
317
319
 
318
320
  if not endpoints:
@@ -346,17 +348,17 @@ class WupWatcher:
346
348
 
347
349
  return passed
348
350
 
349
- async def run_detail_test(self, service: str, endpoints: List[str]) -> Dict:
351
+ async def run_detail_test(self, target: ServiceTestTarget) -> Dict:
350
352
  """
351
353
  Run a detailed test for a service with blame report.
352
354
 
353
355
  Args:
354
- service: Service name
355
- endpoints: List of endpoints to test
356
+ target: Service and endpoints to test
356
357
 
357
358
  Returns:
358
359
  Dictionary with test results and blame information
359
360
  """
361
+ service, endpoints = target.service, target.endpoints
360
362
  self.console.print(f"[cyan]🔍 Detail testing {service} ({len(endpoints)} endpoints)[/cyan]")
361
363
 
362
364
  results = {
@@ -564,35 +566,42 @@ class WupWatcher:
564
566
  return observer
565
567
  raise
566
568
 
567
- def start_watching(self, watch_paths: Optional[List[str]] = None):
569
+ def start_watching(self, watch_paths: Optional[List[str]] = None) -> bool:
568
570
  """
569
571
  Start watching for file changes.
570
-
572
+
571
573
  Args:
572
574
  watch_paths: List of paths to watch (default: from config or common source directories)
575
+
576
+ Returns:
577
+ False when no valid paths could be watched (configuration error —
578
+ CLI turns this into a non-zero exit so supervisors notice instead
579
+ of treating a watcher that never watched as a clean run),
580
+ True after a graceful interrupt.
573
581
  """
574
582
  if watch_paths is None:
575
583
  watch_paths = self.build_watched_paths()
576
-
584
+
577
585
  # Filter to existing paths
578
586
  watch_paths = [p for p in watch_paths if Path(p).exists()]
579
-
587
+
580
588
  if not watch_paths:
581
589
  self.console.print("[red]No valid paths to watch[/red]")
582
- return
583
-
590
+ return False
591
+
584
592
  event_handler = WupEventHandler(self)
585
593
  observer = self._create_and_start_observer(event_handler, watch_paths)
586
594
  self.console.print(f"[green]🕵️ Watching: {', '.join(watch_paths)}[/green]")
587
-
595
+
588
596
  try:
589
597
  while True:
590
598
  asyncio.run(self.process_test_queue_once())
591
599
  time.sleep(1)
592
600
  except KeyboardInterrupt:
593
601
  observer.stop()
594
-
602
+
595
603
  observer.join()
604
+ return True
596
605
 
597
606
  def create_status_table(self) -> Table:
598
607
  """
@@ -15,6 +15,7 @@ from .config import (
15
15
  WebConfig,
16
16
  WupConfig,
17
17
  )
18
+ from .target import ServiceTestTarget
18
19
 
19
20
  ServiceType = str # Type alias: 'web', 'shell', 'auto'
20
21
 
@@ -25,6 +26,7 @@ __all__ = [
25
26
  "ServiceConfig",
26
27
  "ServiceTestConfig",
27
28
  "ServiceType",
29
+ "ServiceTestTarget",
28
30
  "TestQLConfig",
29
31
  "TestStrategyConfig",
30
32
  "VisualDiffConfig",
@@ -0,0 +1,23 @@
1
+ """Runtime data models for WUP.
2
+
3
+ A :class:`ServiceTestTarget` bundles a service name with the endpoints to test
4
+ for it. It replaces the ``(service, endpoints)`` pair that was previously
5
+ passed together through every test-entry method on ``WupWatcher``,
6
+ ``TestQLWatcher`` and ``VisualDiffer`` (a recurring data clump).
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import List
11
+
12
+
13
+ @dataclass
14
+ class ServiceTestTarget:
15
+ """A service and the endpoints that should be exercised for it.
16
+
17
+ Args:
18
+ service: Name of the service under test.
19
+ endpoints: Endpoint URLs (or page routes) to probe for the service.
20
+ """
21
+
22
+ service: str
23
+ endpoints: List[str] = field(default_factory=list)
@@ -46,7 +46,14 @@ class PlanfileReporter:
46
46
  dedupe = self._load_dedupe()
47
47
  existing = dedupe.get(fingerprint)
48
48
  if existing:
49
- return existing.get("ticket_id")
49
+ # A registry entry only mutes the recurrence while its ticket is
50
+ # still open — a regression after the ticket was closed (done or
51
+ # canceled) must file a fresh ticket, otherwise the failure is
52
+ # silently invisible forever (stale entries survive watcher
53
+ # restarts, so clear_service_stage alone does not cover this).
54
+ existing_id = existing.get("ticket_id")
55
+ if existing_id and not self._ticket_is_closed(existing_id):
56
+ return existing_id
50
57
 
51
58
  name = self._ticket_name(service=service, stage=stage, status=status)
52
59
  description = self._ticket_description(
@@ -73,6 +80,32 @@ class PlanfileReporter:
73
80
  self.console.print(f"[yellow]🧾 WUP created planfile ticket {ticket_id}: {name}[/yellow]")
74
81
  return ticket_id
75
82
 
83
+ def _ticket_is_closed(self, ticket_id: str) -> bool:
84
+ """True when the deduped ticket is done/canceled (regression may re-file).
85
+
86
+ Conservative on any error (planfile missing, ticket gone, bad JSON):
87
+ treat the ticket as still open so dedupe keeps muting — a wrong True
88
+ would spam duplicate tickets, a wrong False only delays a re-file.
89
+ """
90
+ try:
91
+ result = subprocess.run(
92
+ [self.config.command, "ticket", "show", ticket_id, "--format", "json"],
93
+ cwd=str(self.project_root),
94
+ capture_output=True,
95
+ text=True,
96
+ timeout=15,
97
+ )
98
+ except (OSError, subprocess.TimeoutExpired):
99
+ return False
100
+ if result.returncode != 0:
101
+ return False
102
+ try:
103
+ payload = json.loads(result.stdout or "{}")
104
+ except ValueError:
105
+ return False
106
+ ticket = payload.get("ticket", payload) if isinstance(payload, dict) else {}
107
+ return str(ticket.get("status", "")).strip().lower() in {"done", "canceled", "cancelled"}
108
+
76
109
  def clear_service_stage(self, *, service: str, stage: str) -> None:
77
110
  """Allow a future recurrence to create a fresh ticket after recovery."""
78
111
  if not self.enabled or not self.dedupe_path.exists():
@@ -118,7 +151,7 @@ class PlanfileReporter:
118
151
  timeout=30,
119
152
  )
120
153
  except (OSError, subprocess.TimeoutExpired) as exc:
121
- self.console.print(f"[yellow]planfile ticket creation skipped: {exc}[/yellow]")
154
+ self.console.print(f"[yellow]planfile ticket creation skipped: {exc} (cmd: {cmd[0]})[/yellow]")
122
155
  return None
123
156
 
124
157
  stdout = (result.stdout or "").strip()
@@ -134,7 +167,7 @@ class PlanfileReporter:
134
167
  timeout=30,
135
168
  )
136
169
  except (OSError, subprocess.TimeoutExpired) as exc:
137
- self.console.print(f"[yellow]planfile ticket creation skipped: {exc}[/yellow]")
170
+ self.console.print(f"[yellow]planfile ticket creation skipped: {exc} (cmd: {cmd[0]})[/yellow]")
138
171
  return None
139
172
  stdout = (result.stdout or "").strip()
140
173
  stderr = (result.stderr or "").strip()
@@ -15,6 +15,7 @@ from urllib import error, request
15
15
 
16
16
  from .config import load_config
17
17
  from .core import WupWatcher
18
+ from .models.target import ServiceTestTarget
18
19
  from wup.bus import bus
19
20
  from wup.testing.events.test_results import ScenarioFailed, ScenarioPassed
20
21
  from .models.config import WupConfig, ServiceConfig
@@ -590,7 +591,9 @@ class TestQLWatcher(WupWatcher):
590
591
  self.console.print(f"[green]✓ Quick TestQL passed for {service}[/green]")
591
592
  if self._should_run_visual_diff():
592
593
  visual_endpoints = self._get_config_endpoints_for_service(service) or merged_endpoints
593
- visual_results = await self.visual_differ.run_for_service(service, visual_endpoints)
594
+ visual_results = await self.visual_differ.run_for_service(
595
+ ServiceTestTarget(service=service, endpoints=visual_endpoints)
596
+ )
594
597
  visual_issues = [
595
598
  item for item in visual_results
596
599
  if item.get("diff", {}).get("status") == "issue"
@@ -781,7 +784,9 @@ class TestQLWatcher(WupWatcher):
781
784
  await self.web_client.send_pass(service=service, stage="quick")
782
785
  if self.visual_differ and self.visual_differ.cfg.enabled:
783
786
  visual_endpoints = self._get_config_endpoints_for_service(service) or merged_endpoints
784
- visual_results = await self.visual_differ.run_for_service(service, visual_endpoints)
787
+ visual_results = await self.visual_differ.run_for_service(
788
+ ServiceTestTarget(service=service, endpoints=visual_endpoints)
789
+ )
785
790
  visual_issues = [
786
791
  item for item in visual_results
787
792
  if item.get("diff", {}).get("status") == "issue"
@@ -1007,7 +1012,7 @@ class TestQLWatcher(WupWatcher):
1007
1012
  self._probe_thread.start()
1008
1013
  self.console.print(f"[green]Live probes enabled (every {interval}s)[/green]")
1009
1014
 
1010
- def start_watching(self, watch_paths: Optional[List[str]] = None):
1015
+ def start_watching(self, watch_paths: Optional[List[str]] = None) -> bool:
1011
1016
  """Start file watcher and optional periodic TestQL/HTTP probes."""
1012
1017
  self._start_periodic_probe_thread()
1013
- super().start_watching(watch_paths)
1018
+ return super().start_watching(watch_paths)
@@ -35,6 +35,7 @@ from rich.progress import (
35
35
  )
36
36
 
37
37
  from .models.config import VisualDiffConfig
38
+ from .models.target import ServiceTestTarget
38
39
 
39
40
 
40
41
  console = Console()
@@ -346,7 +347,8 @@ class VisualDiffer:
346
347
  Usage::
347
348
 
348
349
  differ = VisualDiffer(project_root, config.visual_diff)
349
- results = await differ.run_for_service(service, changed_endpoints)
350
+ target = ServiceTestTarget(service, changed_endpoints)
351
+ results = await differ.run_for_service(target)
350
352
  """
351
353
 
352
354
  def __init__(
@@ -363,8 +365,9 @@ class VisualDiffer:
363
365
  self.base_url = _resolve_base_url(cfg)
364
366
  self._console = console or globals()["console"]
365
367
 
366
- def _pages_for_service(self, service: str, endpoints: List[str]) -> List[str]:
368
+ def _pages_for_service(self, target: ServiceTestTarget) -> List[str]:
367
369
  """Build list of full URLs to scan for this service."""
370
+ service, endpoints = target.service, target.endpoints
368
371
  pages: List[str] = list(self.cfg.pages)
369
372
 
370
373
  if self.cfg.pages_from_endpoints and endpoints:
@@ -458,12 +461,13 @@ class VisualDiffer:
458
461
  )
459
462
 
460
463
  async def run_for_service(
461
- self, service: str, endpoints: List[str]
464
+ self, target: ServiceTestTarget
462
465
  ) -> List[Dict[str, Any]]:
463
466
  """
464
467
  Scan pages for *service*, diff against stored snapshots.
465
468
  Returns list of diff results (one per page).
466
469
  """
470
+ service = target.service
467
471
  if not self.cfg.enabled:
468
472
  return []
469
473
 
@@ -474,7 +478,7 @@ class VisualDiffer:
474
478
  if self.cfg.delay_seconds > 0:
475
479
  await asyncio.sleep(self.cfg.delay_seconds)
476
480
 
477
- pages = self._pages_for_service(service, endpoints)
481
+ pages = self._pages_for_service(target)
478
482
  max_pages = max(1, int(self.cfg.max_pages or 5))
479
483
  if len(pages) > max_pages:
480
484
  pages = pages[:max_pages]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wup
3
- Version: 0.2.68
3
+ Version: 0.2.69
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
6
  License-Expression: Apache-2.0
@@ -42,17 +42,17 @@ Dynamic: license-file
42
42
 
43
43
  ## AI Cost Tracking
44
44
 
45
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.68-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
46
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$3.88-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-39.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
45
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.69-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
46
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$5.86-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-51.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
47
47
 
48
- - 🤖 **LLM usage:** $3.8816 (82 commits)
49
- - 👤 **Human dev:** ~$3968 (39.7h @ $100/h, 30min dedup)
48
+ - 🤖 **LLM usage:** $5.8610 (97 commits)
49
+ - 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
50
50
 
51
- Generated on 2026-07-03 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
51
+ Generated on 2026-07-16 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
52
52
 
53
53
  ---
54
54
 
55
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.68-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
55
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.69-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
56
56
 
57
57
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
58
58
 
@@ -10,6 +10,7 @@ tests/test_e2e.py
10
10
  tests/test_endpoints_init_cli.py
11
11
  tests/test_health_summary_passed.py
12
12
  tests/test_monitoring_manifest.py
13
+ tests/test_planfile_reporter_dedupe.py
13
14
  tests/test_probe_mutex.py
14
15
  tests/test_service_inference.py
15
16
  tests/test_status_data.py
@@ -19,6 +20,7 @@ tests/test_testql_watcher.py
19
20
  tests/test_visual_diff_periodic_skip.py
20
21
  tests/test_visual_diff_progress.py
21
22
  tests/test_watch_exclude.py
23
+ tests/test_watch_no_paths_exit.py
22
24
  tests/test_web_client.py
23
25
  tests/test_wup.py
24
26
  tests/test_wup_generate.py
@@ -66,6 +68,7 @@ wup.egg-info/top_level.txt
66
68
  wup/file_watcher/events/file_events.py
67
69
  wup/models/__init__.py
68
70
  wup/models/config.py
71
+ wup/models/target.py
69
72
  wup/testing/events/health_events.py
70
73
  wup/testing/events/test_results.py
71
74
  wup/testing/handlers/event_handlers.py
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
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
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
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