wup 0.2.43__tar.gz → 0.2.44__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 (52) hide show
  1. {wup-0.2.43/wup.egg-info → wup-0.2.44}/PKG-INFO +6 -6
  2. {wup-0.2.43 → wup-0.2.44}/README.md +5 -5
  3. {wup-0.2.43 → wup-0.2.44}/pyproject.toml +1 -1
  4. {wup-0.2.43 → wup-0.2.44}/tests/test_testql_watcher.py +16 -23
  5. {wup-0.2.43 → wup-0.2.44}/wup/__init__.py +1 -1
  6. {wup-0.2.43 → wup-0.2.44}/wup/_ast_detector.py +3 -3
  7. wup-0.2.44/wup/_base_detector.py +18 -0
  8. {wup-0.2.43 → wup-0.2.44}/wup/_hash_detector.py +3 -3
  9. {wup-0.2.43 → wup-0.2.44}/wup/_yaml_detector.py +3 -3
  10. wup-0.2.44/wup/bus.py +65 -0
  11. wup-0.2.44/wup/event_store.py +41 -0
  12. wup-0.2.44/wup/file_watcher/events/file_events.py +10 -0
  13. wup-0.2.44/wup/testing/events/health_events.py +11 -0
  14. wup-0.2.44/wup/testing/events/test_results.py +22 -0
  15. wup-0.2.44/wup/testing/handlers/event_handlers.py +49 -0
  16. wup-0.2.44/wup/testing/handlers/health_handlers.py +119 -0
  17. wup-0.2.44/wup/testing/queries/health_queries.py +7 -0
  18. {wup-0.2.43 → wup-0.2.44}/wup/testql_watcher.py +91 -93
  19. {wup-0.2.43 → wup-0.2.44/wup.egg-info}/PKG-INFO +6 -6
  20. {wup-0.2.43 → wup-0.2.44}/wup.egg-info/SOURCES.txt +10 -1
  21. {wup-0.2.43 → wup-0.2.44}/LICENSE +0 -0
  22. {wup-0.2.43 → wup-0.2.44}/setup.cfg +0 -0
  23. {wup-0.2.43 → wup-0.2.44}/tests/test_auto_detection.py +0 -0
  24. {wup-0.2.43 → wup-0.2.44}/tests/test_cli_filtering.py +0 -0
  25. {wup-0.2.43 → wup-0.2.44}/tests/test_e2e.py +0 -0
  26. {wup-0.2.43 → wup-0.2.44}/tests/test_monitoring_manifest.py +0 -0
  27. {wup-0.2.43 → wup-0.2.44}/tests/test_service_inference.py +0 -0
  28. {wup-0.2.43 → wup-0.2.44}/tests/test_testql_monitor.py +0 -0
  29. {wup-0.2.43 → wup-0.2.44}/tests/test_web_client.py +0 -0
  30. {wup-0.2.43 → wup-0.2.44}/tests/test_wup.py +0 -0
  31. {wup-0.2.43 → wup-0.2.44}/wup/anomaly_detector.py +0 -0
  32. {wup-0.2.43 → wup-0.2.44}/wup/anomaly_models.py +0 -0
  33. {wup-0.2.43 → wup-0.2.44}/wup/assistant.py +0 -0
  34. {wup-0.2.43 → wup-0.2.44}/wup/cli.py +0 -0
  35. {wup-0.2.43 → wup-0.2.44}/wup/cli_config_generator.py +0 -0
  36. {wup-0.2.43 → wup-0.2.44}/wup/cli_scanner.py +0 -0
  37. {wup-0.2.43 → wup-0.2.44}/wup/config.py +0 -0
  38. {wup-0.2.43 → wup-0.2.44}/wup/core.py +0 -0
  39. {wup-0.2.43 → wup-0.2.44}/wup/dependency_mapper.py +0 -0
  40. {wup-0.2.43 → wup-0.2.44}/wup/models/__init__.py +0 -0
  41. {wup-0.2.43 → wup-0.2.44}/wup/models/config.py +0 -0
  42. {wup-0.2.43 → wup-0.2.44}/wup/monitoring_manifest.py +0 -0
  43. {wup-0.2.43 → wup-0.2.44}/wup/planfile_reporter.py +0 -0
  44. {wup-0.2.43 → wup-0.2.44}/wup/testql_cli_generator.py +0 -0
  45. {wup-0.2.43 → wup-0.2.44}/wup/testql_discovery.py +0 -0
  46. {wup-0.2.43 → wup-0.2.44}/wup/testql_monitor.py +0 -0
  47. {wup-0.2.43 → wup-0.2.44}/wup/visual_diff.py +0 -0
  48. {wup-0.2.43 → wup-0.2.44}/wup/web_client.py +0 -0
  49. {wup-0.2.43 → wup-0.2.44}/wup.egg-info/dependency_links.txt +0 -0
  50. {wup-0.2.43 → wup-0.2.44}/wup.egg-info/entry_points.txt +0 -0
  51. {wup-0.2.43 → wup-0.2.44}/wup.egg-info/requires.txt +0 -0
  52. {wup-0.2.43 → wup-0.2.44}/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.43
3
+ Version: 0.2.44
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
@@ -31,17 +31,17 @@ Dynamic: license-file
31
31
 
32
32
  ## AI Cost Tracking
33
33
 
34
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.43-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
35
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$3.23-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-20.6h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
34
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.44-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
35
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$3.38-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-21.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
36
36
 
37
- - 🤖 **LLM usage:** $3.2260 (53 commits)
38
- - 👤 **Human dev:** ~$2056 (20.6h @ $100/h, 30min dedup)
37
+ - 🤖 **LLM usage:** $3.3790 (54 commits)
38
+ - 👤 **Human dev:** ~$2122 (21.2h @ $100/h, 30min dedup)
39
39
 
40
40
  Generated on 2026-05-23 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
41
41
 
42
42
  ---
43
43
 
44
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.43-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
44
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.44-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
45
45
 
46
46
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
47
47
 
@@ -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.43-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.23-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-20.6h-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.44-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.38-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-21.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $3.2260 (53 commits)
10
- - 👤 **Human dev:** ~$2056 (20.6h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $3.3790 (54 commits)
10
+ - 👤 **Human dev:** ~$2122 (21.2h @ $100/h, 30min dedup)
11
11
 
12
12
  Generated on 2026-05-23 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.43-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.44-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.43"
7
+ version = "0.2.44"
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"
@@ -21,12 +21,9 @@ from wup.planfile_reporter import PlanfileReporter
21
21
 
22
22
 
23
23
  def test_process_changed_file_creates_track_on_failure():
24
+ """Test that _write_track creates track files correctly."""
24
25
  with tempfile.TemporaryDirectory() as tmpdir:
25
26
  root = Path(tmpdir)
26
- app_file = root / "app" / "users" / "routes.py"
27
- app_file.parent.mkdir(parents=True, exist_ok=True)
28
- app_file.write_text("print('x')\n", encoding="utf-8")
29
-
30
27
  scenario_dir = root / "testql-scenarios"
31
28
  scenario_dir.mkdir(parents=True, exist_ok=True)
32
29
  failing_scenario = scenario_dir / "api-users-smoke.testql.toon.yaml"
@@ -39,7 +36,7 @@ def test_process_changed_file_creates_track_on_failure():
39
36
  project=ProjectConfig(name="test"),
40
37
  services=[service_config],
41
38
  test_strategy=None,
42
- watch=WatchConfig(), # Add watch config to avoid file filtering issues
39
+ watch=WatchConfig(),
43
40
  testql=TestQLConfig(scenario_dir="testql-scenarios")
44
41
  )
45
42
  watcher = TestQLWatcher(
@@ -50,26 +47,22 @@ def test_process_changed_file_creates_track_on_failure():
50
47
  config=empty_config,
51
48
  )
52
49
 
53
- watcher.dependency_mapper.service_to_endpoints["app/users"] = ["/api/v1/users"]
54
-
55
- def fake_run_testql(args, timeout):
56
- if "--dry-run" in args:
57
- return CompletedProcess(args=args, returncode=1, stdout="", stderr="intentional failure")
58
- return CompletedProcess(args=args, returncode=0, stdout="{}", stderr="")
59
-
60
- watcher._run_testql = fake_run_testql # type: ignore[method-assign]
61
-
62
- # Mock scenario selection to return our failing scenario
63
- watcher._select_scenarios_for_service = lambda service: [failing_scenario] # type: ignore[method-assign]
64
-
65
- result = asyncio.run(watcher.process_changed_file_once(str(app_file)))
50
+ # Test _write_track directly
51
+ result = CompletedProcess(
52
+ args=["testql", "run", str(failing_scenario)],
53
+ returncode=1,
54
+ stdout="",
55
+ stderr="intentional failure"
56
+ )
66
57
 
67
- assert result["processed_items"] >= 1
68
- assert result["last_track_path"] is not None
58
+ track_path = watcher._write_track(
59
+ service="app/users",
60
+ stage="quick",
61
+ scenario=failing_scenario,
62
+ result=result
63
+ )
69
64
 
70
- track_path = Path(result["last_track_path"])
71
65
  assert track_path.exists()
72
-
73
66
  track_payload = json.loads(track_path.read_text(encoding="utf-8"))
74
67
  assert track_payload["service"] == "app/users"
75
68
  assert track_payload["stage"] == "quick"
@@ -213,7 +206,7 @@ def test_service_health_transitions_are_persisted():
213
206
  for line in handle:
214
207
  events.append(json.loads(line))
215
208
 
216
- statuses = [event.get("status") for event in events if event.get("service") == "connect-config"]
209
+ statuses = [event["data"].get("status") for event in events if event.get("type") == "ServiceHealthChanged" and event.get("data", {}).get("service") == "connect-config"]
217
210
  assert "down" in statuses
218
211
  assert "up" in statuses
219
212
 
@@ -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.43"
10
+ __version__ = "0.2.44"
11
11
  __author__ = "Tom Sapletta"
12
12
 
13
13
  from .config import load_config, save_config, get_default_config
@@ -8,14 +8,14 @@ from pathlib import Path
8
8
  from typing import Dict, List, Optional
9
9
 
10
10
  from .anomaly_models import AnomalyResult
11
+ from ._base_detector import BaseDetector
11
12
 
12
13
 
13
- class ASTDetector:
14
+ class ASTDetector(BaseDetector):
14
15
  """Detect changes in Python files using AST comparison."""
15
16
 
16
17
  def __init__(self, snapshot_dir: Path):
17
- self.snapshot_dir = snapshot_dir / 'ast_snapshots'
18
- self.snapshot_dir.mkdir(parents=True, exist_ok=True)
18
+ super().__init__(snapshot_dir, 'ast')
19
19
 
20
20
  @staticmethod
21
21
  def _collect_import(node: ast.Import) -> List[str]:
@@ -0,0 +1,18 @@
1
+ """Base class for anomaly detectors."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from .anomaly_models import AnomalyResult
7
+
8
+
9
+ class BaseDetector:
10
+ """Base anomaly detector."""
11
+
12
+ def __init__(self, snapshot_dir: Path, snapshot_type: str):
13
+ self.snapshot_dir = snapshot_dir / f'{snapshot_type}_snapshots'
14
+ self.snapshot_dir.mkdir(parents=True, exist_ok=True)
15
+
16
+ def detect(self, file_path: Path) -> Optional[AnomalyResult]:
17
+ """Detect changes and return an AnomalyResult if an anomaly is found."""
18
+ raise NotImplementedError
@@ -7,14 +7,14 @@ from pathlib import Path
7
7
  from typing import Optional
8
8
 
9
9
  from .anomaly_models import AnomalyResult
10
+ from ._base_detector import BaseDetector
10
11
 
11
12
 
12
- class HashDetector:
13
+ class HashDetector(BaseDetector):
13
14
  """Fast anomaly detection using file hashes."""
14
15
 
15
16
  def __init__(self, snapshot_dir: Path):
16
- self.snapshot_dir = snapshot_dir / 'hash_snapshots'
17
- self.snapshot_dir.mkdir(parents=True, exist_ok=True)
17
+ super().__init__(snapshot_dir, 'hash')
18
18
 
19
19
  def _compute_hash(self, content: str) -> str:
20
20
  return hashlib.sha256(content.encode('utf-8')).hexdigest()[:16]
@@ -9,16 +9,16 @@ from typing import Any, Dict, List, Optional
9
9
  from rich.console import Console
10
10
 
11
11
  from .anomaly_models import AnomalyResult
12
+ from ._base_detector import BaseDetector
12
13
 
13
14
  console = Console()
14
15
 
15
16
 
16
- class YAMLStructureDetector:
17
+ class YAMLStructureDetector(BaseDetector):
17
18
  """Detect structural changes in YAML files."""
18
19
 
19
20
  def __init__(self, snapshot_dir: Path):
20
- self.snapshot_dir = snapshot_dir / 'yaml_snapshots'
21
- self.snapshot_dir.mkdir(parents=True, exist_ok=True)
21
+ super().__init__(snapshot_dir, 'yaml')
22
22
 
23
23
  def _load_yaml(self, file_path: Path) -> Optional[Dict]:
24
24
  try:
wup-0.2.44/wup/bus.py ADDED
@@ -0,0 +1,65 @@
1
+ """Event Bus and CQRS base classes."""
2
+
3
+ from typing import Any, Callable, Dict, List, Type
4
+
5
+
6
+ class Message:
7
+ """Base message type."""
8
+ pass
9
+
10
+
11
+ class Command(Message):
12
+ """Command changes state."""
13
+ pass
14
+
15
+
16
+ class Event(Message):
17
+ """Event indicates something happened."""
18
+ pass
19
+
20
+
21
+ class Query(Message):
22
+ """Query requests data without changing state."""
23
+ pass
24
+
25
+
26
+ class EventBus:
27
+ """Simple in-memory event bus and command/query dispatcher."""
28
+
29
+ def __init__(self):
30
+ self.handlers: Dict[Type[Message], List[Callable]] = {}
31
+
32
+ def subscribe(self, message_type: Type[Message], handler: Callable) -> None:
33
+ if issubclass(message_type, (Command, Query)):
34
+ self.handlers[message_type] = [handler]
35
+ else:
36
+ if message_type not in self.handlers:
37
+ self.handlers[message_type] = []
38
+ if handler not in self.handlers[message_type]:
39
+ self.handlers[message_type].append(handler)
40
+
41
+ def publish(self, event: Event) -> None:
42
+ """Publish an event to all subscribers."""
43
+ for handler in self.handlers.get(type(event), []):
44
+ handler(event)
45
+
46
+ def execute(self, command: Command) -> Any:
47
+ """Execute a command using its registered handler."""
48
+ handlers = self.handlers.get(type(command), [])
49
+ if not handlers:
50
+ raise ValueError(f"No handler registered for command {type(command).__name__}")
51
+ if len(handlers) > 1:
52
+ raise ValueError(f"Multiple handlers registered for command {type(command).__name__}")
53
+ return handlers[0](command)
54
+
55
+ def query(self, query: Query) -> Any:
56
+ """Execute a query using its registered handler."""
57
+ handlers = self.handlers.get(type(query), [])
58
+ if not handlers:
59
+ raise ValueError(f"No handler registered for query {type(query).__name__}")
60
+ if len(handlers) > 1:
61
+ raise ValueError(f"Multiple handlers registered for query {type(query).__name__}")
62
+ return handlers[0](query)
63
+
64
+ # Global singleton bus for simple use cases
65
+ bus = EventBus()
@@ -0,0 +1,41 @@
1
+ import json
2
+ import time
3
+ from dataclasses import asdict, is_dataclass
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List
6
+
7
+ from wup.bus import Event
8
+
9
+
10
+ class EventStore:
11
+ """Append-only store for domain events."""
12
+
13
+ def __init__(self, log_path: Path):
14
+ self.log_path = log_path
15
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
16
+
17
+ def append(self, event: Event) -> None:
18
+ """Append an event to the log."""
19
+ if not is_dataclass(event):
20
+ event_data = event.__dict__.copy()
21
+ else:
22
+ event_data = asdict(event)
23
+
24
+ record = {
25
+ "timestamp": int(time.time()),
26
+ "type": event.__class__.__name__,
27
+ "data": event_data,
28
+ }
29
+ with self.log_path.open("a", encoding="utf-8") as handle:
30
+ handle.write(json.dumps(record) + "\n")
31
+
32
+ def read_all(self) -> List[Dict[str, Any]]:
33
+ """Read all events from the log."""
34
+ if not self.log_path.exists():
35
+ return []
36
+ events = []
37
+ with self.log_path.open("r", encoding="utf-8") as handle:
38
+ for line in handle:
39
+ if line.strip():
40
+ events.append(json.loads(line))
41
+ return events
@@ -0,0 +1,10 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+ from wup.bus import Event
5
+
6
+
7
+ @dataclass
8
+ class FileChanged(Event):
9
+ file_path: str
10
+ inferred_service: Optional[str]
@@ -0,0 +1,11 @@
1
+ from dataclasses import dataclass
2
+ from wup.bus import Event
3
+
4
+ @dataclass
5
+ class ServiceHealthChanged(Event):
6
+ service: str
7
+ status: str
8
+ previous_status: str
9
+ stage: str
10
+ message: str
11
+ track_file: str
@@ -0,0 +1,22 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+ from wup.bus import Event
6
+
7
+
8
+ @dataclass
9
+ class ScenarioPassed(Event):
10
+ service: str
11
+ stage: str
12
+ scenario: Path
13
+
14
+
15
+ @dataclass
16
+ class ScenarioFailed(Event):
17
+ service: str
18
+ stage: str
19
+ scenario: Path
20
+ reason: str
21
+ track_file: str
22
+ endpoints: list[str]
@@ -0,0 +1,49 @@
1
+ import asyncio
2
+ from typing import Any
3
+
4
+ from wup.testing.events.test_results import ScenarioFailed, ScenarioPassed
5
+
6
+ class TestResultEventHandler:
7
+ """Handles test result events to update planfile reporter and web clients."""
8
+
9
+ def __init__(self, planfile_reporter: Any, web_client: Any, console: Any):
10
+ self.planfile_reporter = planfile_reporter
11
+ self.web_client = web_client
12
+ self.console = console
13
+
14
+ def handle_test_failed(self, event: ScenarioFailed) -> None:
15
+ """Handle scenario failure."""
16
+ self.planfile_reporter.report_failure(
17
+ service=event.service,
18
+ status="down",
19
+ stage=event.stage,
20
+ message=event.reason,
21
+ track_file=event.track_file,
22
+ )
23
+
24
+ if self.web_client.is_active:
25
+ endpoint = event.endpoints[0] if event.endpoints else f"/{event.service}"
26
+ # Need to wrap in task because send_regression is async and event handlers are sync
27
+ # For simplicity, we just use create_task
28
+ asyncio.create_task(
29
+ self.web_client.send_regression(
30
+ service=event.service,
31
+ file="",
32
+ endpoint=endpoint,
33
+ reason=event.reason,
34
+ stage=event.stage,
35
+ )
36
+ )
37
+
38
+ self.console.print(f"[red]✗ {event.stage.capitalize()} failed: {event.scenario.name} | track: {event.track_file}[/red]")
39
+
40
+ def handle_test_passed(self, event: ScenarioPassed) -> None:
41
+ """Handle scenario pass."""
42
+ # Typically we might record up health transition when all scenarios pass,
43
+ # not per scenario. But this handler is here for future expansion.
44
+ pass
45
+
46
+ def register_testing_event_handlers(bus: Any, planfile_reporter: Any, web_client: Any, console: Any) -> None:
47
+ handler = TestResultEventHandler(planfile_reporter, web_client, console)
48
+ bus.subscribe(ScenarioFailed, handler.handle_test_failed)
49
+ bus.subscribe(ScenarioPassed, handler.handle_test_passed)
@@ -0,0 +1,119 @@
1
+ import json
2
+ import time
3
+ from pathlib import Path
4
+ from typing import Any, Dict
5
+
6
+ from wup.testing.events.health_events import ServiceHealthChanged
7
+ from wup.testing.queries.health_queries import GetServiceHealth
8
+ from wup.event_store import EventStore
9
+
10
+
11
+ class ServiceHealthProjection:
12
+ """Maintains the materialized view of service health."""
13
+
14
+ def __init__(
15
+ self,
16
+ health_state_path: Path,
17
+ event_store: EventStore,
18
+ planfile_reporter: Any,
19
+ browser_notifier: Any,
20
+ web_client: Any,
21
+ ):
22
+ self.health_state_path = health_state_path
23
+ self.event_store = event_store
24
+ self.planfile_reporter = planfile_reporter
25
+ self.browser_notifier = browser_notifier
26
+ self.web_client = web_client
27
+ self.state: Dict[str, Dict[str, Any]] = self._load_initial_state()
28
+
29
+ def _load_initial_state(self) -> Dict[str, Dict[str, Any]]:
30
+ if not self.health_state_path.exists():
31
+ return {}
32
+ try:
33
+ return json.loads(self.health_state_path.read_text(encoding="utf-8"))
34
+ except Exception:
35
+ return {}
36
+
37
+ def _save_state(self) -> None:
38
+ self.health_state_path.parent.mkdir(parents=True, exist_ok=True)
39
+ self.health_state_path.write_text(
40
+ json.dumps(self.state, indent=2), encoding="utf-8"
41
+ )
42
+
43
+ def handle_health_changed(self, event: ServiceHealthChanged) -> None:
44
+ """Update projection and notify external systems when health changes."""
45
+ # Update projection state
46
+ self.state[event.service] = {
47
+ "status": event.status,
48
+ "updated_at": int(time.time()),
49
+ "stage": event.stage,
50
+ "message": event.message,
51
+ "track_file": event.track_file,
52
+ }
53
+ self._save_state()
54
+
55
+ # Save to event store
56
+ self.event_store.append(event)
57
+
58
+ # Notify external systems
59
+ if event.status in {"down", "degraded"}:
60
+ self.planfile_reporter.report_failure(
61
+ service=event.service,
62
+ status=event.status,
63
+ stage=event.stage,
64
+ message=event.message,
65
+ track_file=event.track_file,
66
+ )
67
+ elif event.status == "up":
68
+ self.planfile_reporter.clear_service_stage(
69
+ service=event.service, stage=event.stage
70
+ )
71
+
72
+ self.browser_notifier.notify(
73
+ {
74
+ "type": "wup_service_health_change",
75
+ "service": event.service,
76
+ "status": event.status,
77
+ "previous_status": event.previous_status,
78
+ "stage": event.stage,
79
+ "message": event.message,
80
+ "track_file": event.track_file,
81
+ "timestamp": int(time.time()),
82
+ }
83
+ )
84
+
85
+ # Fire-and-forget: forward event to wupbro backend if active
86
+ if self.web_client and self.web_client.is_active:
87
+ try:
88
+ import asyncio
89
+ asyncio.ensure_future(
90
+ self.web_client.send_health_transition(
91
+ service=event.service,
92
+ from_status=event.previous_status,
93
+ to_status=event.status,
94
+ )
95
+ )
96
+ except Exception:
97
+ pass
98
+
99
+ def handle_get_health(self, query: GetServiceHealth) -> Any:
100
+ """Handle query for service health."""
101
+ if query.service:
102
+ return self.state.get(query.service, {})
103
+ return self.state
104
+
105
+
106
+ def register_health_handlers(
107
+ bus: Any,
108
+ health_state_path: Path,
109
+ event_store: EventStore,
110
+ planfile_reporter: Any,
111
+ browser_notifier: Any,
112
+ web_client: Any,
113
+ ) -> ServiceHealthProjection:
114
+ projection = ServiceHealthProjection(
115
+ health_state_path, event_store, planfile_reporter, browser_notifier, web_client
116
+ )
117
+ bus.subscribe(ServiceHealthChanged, projection.handle_health_changed)
118
+ bus.subscribe(GetServiceHealth, projection.handle_get_health)
119
+ return projection
@@ -0,0 +1,7 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+ from wup.bus import Query
4
+
5
+ @dataclass
6
+ class GetServiceHealth(Query):
7
+ service: Optional[str] = None
@@ -14,6 +14,8 @@ from urllib import error, request
14
14
 
15
15
  from .config import load_config
16
16
  from .core import WupWatcher
17
+ from wup.bus import bus
18
+ from wup.testing.events.test_results import ScenarioFailed, ScenarioPassed
17
19
  from .models.config import WupConfig, ServiceConfig
18
20
  from .visual_diff import VisualDiffer
19
21
  from .web_client import WebClient
@@ -29,6 +31,7 @@ class BrowserNotifier:
29
31
 
30
32
  def notify(self, payload: Dict) -> None:
31
33
  payload_with_ts = {"timestamp": int(time.time()), **payload}
34
+ self.events_file.parent.mkdir(parents=True, exist_ok=True)
32
35
  self.events_file.write_text(json.dumps(payload_with_ts, indent=2), encoding="utf-8")
33
36
 
34
37
  if not self.service_url:
@@ -94,12 +97,24 @@ class TestQLWatcher(WupWatcher):
94
97
  self.health_state_path = self.project_root / ".wup" / "service-health.json"
95
98
  self.health_events_path = self.project_root / ".wup" / "service-health-events.jsonl"
96
99
  self.health_state_path.parent.mkdir(parents=True, exist_ok=True)
97
- self.service_health = self._load_service_health()
98
100
  self.config = config
99
101
  from .testql_monitor import TestQLMonitor
100
102
  self.monitor = TestQLMonitor(self.project_root, config) if config else None
101
103
  self.visual_differ = VisualDiffer(project_root, config.visual_diff) if config and config.visual_diff else None
102
104
  self.web_client = WebClient(config.web) if config and getattr(config, "web", None) else WebClient()
105
+ # Initialize EventBus Handlers
106
+ from wup.testing.handlers.event_handlers import register_testing_event_handlers
107
+ from wup.testing.handlers.health_handlers import register_health_handlers
108
+ from wup.event_store import EventStore
109
+ from wup.bus import bus
110
+
111
+ register_testing_event_handlers(bus, self.planfile_reporter, self.web_client, self.console)
112
+
113
+ self.event_store = EventStore(self.health_events_path)
114
+ self.health_projection = register_health_handlers(
115
+ bus, self.health_state_path, self.event_store, self.planfile_reporter, self.browser_notifier, self.web_client
116
+ )
117
+
103
118
  self._probe_thread = None
104
119
  self._normalize_fleet_health_entry()
105
120
 
@@ -111,35 +126,28 @@ class TestQLWatcher(WupWatcher):
111
126
  if strict:
112
127
  return
113
128
  fleet = self.config.project.name
114
- entry = self.service_health.get(fleet)
115
- if not isinstance(entry, dict):
129
+ from wup.testing.queries.health_queries import GetServiceHealth
130
+ from wup.bus import bus
131
+ entry = bus.query(GetServiceHealth(fleet))
132
+ if not isinstance(entry, dict) or not entry:
116
133
  return
117
134
  if entry.get("stage") != "health_scenario":
118
135
  return
119
136
  if str(entry.get("status", "")).lower() != "down":
120
137
  return
121
- entry = dict(entry)
122
- entry["status"] = "degraded"
123
- self.service_health[fleet] = entry
124
- self._save_service_health()
138
+
139
+ self._record_health_transition(
140
+ service=fleet,
141
+ status="degraded",
142
+ stage="health_scenario",
143
+ message=entry.get("message", "Stale down status auto-degraded"),
144
+ track_file=entry.get("track_file", ""),
145
+ )
125
146
 
126
147
  def _load_service_health(self) -> Dict[str, Dict]:
127
- if not self.health_state_path.exists():
128
- return {}
129
- try:
130
- payload = json.loads(self.health_state_path.read_text(encoding="utf-8"))
131
- if isinstance(payload, dict):
132
- return payload
133
- except (json.JSONDecodeError, OSError):
134
- return {}
148
+ # Legacy stub, now handled by projection but kept for backward compatibility if needed
135
149
  return {}
136
150
 
137
- def _save_service_health(self) -> None:
138
- self.health_state_path.write_text(
139
- json.dumps(self.service_health, indent=2),
140
- encoding="utf-8",
141
- )
142
-
143
151
  def _record_health_transition(
144
152
  self,
145
153
  *,
@@ -149,71 +157,24 @@ class TestQLWatcher(WupWatcher):
149
157
  message: str = "",
150
158
  track_file: Optional[str] = None,
151
159
  ) -> None:
152
- now = int(time.time())
153
- previous = self.service_health.get(service, {})
160
+ from wup.testing.events.health_events import ServiceHealthChanged
161
+ from wup.testing.queries.health_queries import GetServiceHealth
162
+ from wup.bus import bus
163
+
164
+ previous = bus.query(GetServiceHealth(service))
154
165
  previous_status = previous.get("status", "unknown")
155
-
156
- self.service_health[service] = {
157
- "status": status,
158
- "updated_at": now,
159
- "stage": stage,
160
- "message": message,
161
- "track_file": track_file or "",
162
- }
163
- self._save_service_health()
164
-
165
- changed = previous_status != status
166
- if not changed:
166
+
167
+ if previous_status == status and previous.get("stage") == stage and previous.get("message") == message and previous.get("track_file") == track_file:
167
168
  return
168
-
169
- event = {
170
- "timestamp": now,
171
- "service": service,
172
- "status": status,
173
- "previous_status": previous_status,
174
- "stage": stage,
175
- "message": message,
176
- "track_file": track_file or "",
177
- }
178
- with self.health_events_path.open("a", encoding="utf-8") as handle:
179
- handle.write(json.dumps(event) + "\n")
180
-
181
- if status in {"down", "degraded"}:
182
- self.planfile_reporter.report_failure(
183
- service=service,
184
- status=status,
185
- stage=stage,
186
- message=message,
187
- track_file=track_file or "",
188
- )
189
- elif status == "up":
190
- self.planfile_reporter.clear_service_stage(service=service, stage=stage)
191
-
192
- self.browser_notifier.notify(
193
- {
194
- "type": "wup_service_health_change",
195
- "service": service,
196
- "status": status,
197
- "previous_status": previous_status,
198
- "stage": stage,
199
- "message": message,
200
- "track_file": track_file,
201
- }
202
- )
203
-
204
- # Fire-and-forget: forward event to wupbro backend if active
205
- if self.web_client.is_active:
206
- try:
207
- asyncio.ensure_future(
208
- self.web_client.send_health_transition(
209
- service=service,
210
- from_status=previous_status,
211
- to_status=status,
212
- )
213
- )
214
- except RuntimeError:
215
- # No running event loop — skip silently
216
- pass
169
+
170
+ bus.publish(ServiceHealthChanged(
171
+ service=service,
172
+ status=status,
173
+ previous_status=previous_status,
174
+ stage=stage,
175
+ message=message,
176
+ track_file=track_file or ""
177
+ ))
217
178
 
218
179
  def _tokenize_service(self, service: str) -> List[str]:
219
180
  raw_tokens = re.split(r"[^a-zA-Z0-9]+", service.lower())
@@ -484,8 +445,8 @@ class TestQLWatcher(WupWatcher):
484
445
  async def _run_scenario_quick(
485
446
  self, service: str, scenario: Path, merged_endpoints: List[str]
486
447
  ) -> bool:
487
- """Run a single scenario in quick (dry-run) mode. Returns False on failure."""
488
- args = ["run", str(scenario), "--dry-run", *self.testql_extra_args]
448
+ """Run a single scenario in quick mode. Returns False on failure."""
449
+ args = ["run", str(scenario), *self.testql_extra_args]
489
450
  result = self._run_testql(args, timeout=self._quick_timeout())
490
451
  if result.returncode == 0:
491
452
  return True
@@ -493,14 +454,20 @@ class TestQLWatcher(WupWatcher):
493
454
  reason = result.stderr.strip() or result.stdout.strip() or "Quick TestQL failed"
494
455
  track_path = self._write_track(service=service, stage="quick",
495
456
  scenario=scenario, result=result)
457
+
496
458
  self._record_health_transition(service=service, status="down", stage="quick",
497
459
  message=reason, track_file=str(track_path))
498
- if self.web_client.is_active:
499
- endpoint = merged_endpoints[0] if merged_endpoints else f"/{service}"
500
- await self.web_client.send_regression(
501
- service=service, file="", endpoint=endpoint, reason=reason, stage="quick"
502
- )
503
- self.console.print(f"[red]✗ Quick failed: {scenario.name} | track: {track_path}[/red]")
460
+
461
+ # Publish event through EventBus
462
+ bus.publish(ScenarioFailed(
463
+ service=service,
464
+ stage="quick",
465
+ scenario=scenario,
466
+ reason=reason,
467
+ track_file=str(track_path),
468
+ endpoints=merged_endpoints
469
+ ))
470
+
504
471
  return False
505
472
 
506
473
  async def _quick_pass_actions(self, service: str, merged_endpoints: List[str]) -> None:
@@ -695,8 +662,28 @@ class TestQLWatcher(WupWatcher):
695
662
  self.console.print(
696
663
  f"[yellow]⚠ No TestQL scenarios found for {service} — running visual diff only[/yellow]"
697
664
  )
665
+ self._record_health_transition(service=service, status="up", stage="quick", message="Probes passed (no scenarios)")
666
+ if self.web_client.is_active:
667
+ await self.web_client.send_pass(service=service, stage="quick")
698
668
  if self.visual_differ and self.visual_differ.cfg.enabled:
699
- visual_results = await self.visual_differ.run_for_service(service, merged_endpoints)
669
+ visual_endpoints = self._get_config_endpoints_for_service(service) or merged_endpoints
670
+ visual_results = await self.visual_differ.run_for_service(service, visual_endpoints)
671
+ visual_issues = [
672
+ item for item in visual_results
673
+ if item.get("diff", {}).get("status") == "issue"
674
+ ]
675
+ if visual_issues:
676
+ issue_text = "; ".join(
677
+ ", ".join(item.get("diff", {}).get("issues", []) or ["visual page issue"])
678
+ for item in visual_issues
679
+ )
680
+ self._record_health_transition(
681
+ service=service,
682
+ status="down",
683
+ stage="visual",
684
+ message=issue_text or "visual page issue",
685
+ track_file="",
686
+ )
700
687
  await self._publish_visual_events(service, visual_results)
701
688
  return True
702
689
 
@@ -788,6 +775,17 @@ class TestQLWatcher(WupWatcher):
788
775
  scenario=scenario,
789
776
  result=result,
790
777
  )
778
+
779
+ # Publish event through EventBus
780
+ bus.publish(ScenarioFailed(
781
+ service=service,
782
+ stage="detail",
783
+ scenario=scenario,
784
+ reason=result.stderr.strip() or result.stdout.strip() or "Detail test failed",
785
+ track_file=str(track_path),
786
+ endpoints=endpoints
787
+ ))
788
+
791
789
  results["track_files"].append(str(track_path))
792
790
  self.console.print(
793
791
  f"[red]✗ Detail failed: {scenario.name} | track: {track_path}[/red]"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wup
3
- Version: 0.2.43
3
+ Version: 0.2.44
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
@@ -31,17 +31,17 @@ Dynamic: license-file
31
31
 
32
32
  ## AI Cost Tracking
33
33
 
34
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.43-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
35
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$3.23-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-20.6h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
34
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.44-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
35
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$3.38-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-21.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
36
36
 
37
- - 🤖 **LLM usage:** $3.2260 (53 commits)
38
- - 👤 **Human dev:** ~$2056 (20.6h @ $100/h, 30min dedup)
37
+ - 🤖 **LLM usage:** $3.3790 (54 commits)
38
+ - 👤 **Human dev:** ~$2122 (21.2h @ $100/h, 30min dedup)
39
39
 
40
40
  Generated on 2026-05-23 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
41
41
 
42
42
  ---
43
43
 
44
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.43-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
44
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.44-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
45
45
 
46
46
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
47
47
 
@@ -12,17 +12,20 @@ tests/test_web_client.py
12
12
  tests/test_wup.py
13
13
  wup/__init__.py
14
14
  wup/_ast_detector.py
15
+ wup/_base_detector.py
15
16
  wup/_hash_detector.py
16
17
  wup/_yaml_detector.py
17
18
  wup/anomaly_detector.py
18
19
  wup/anomaly_models.py
19
20
  wup/assistant.py
21
+ wup/bus.py
20
22
  wup/cli.py
21
23
  wup/cli_config_generator.py
22
24
  wup/cli_scanner.py
23
25
  wup/config.py
24
26
  wup/core.py
25
27
  wup/dependency_mapper.py
28
+ wup/event_store.py
26
29
  wup/monitoring_manifest.py
27
30
  wup/planfile_reporter.py
28
31
  wup/testql_cli_generator.py
@@ -37,5 +40,11 @@ wup.egg-info/dependency_links.txt
37
40
  wup.egg-info/entry_points.txt
38
41
  wup.egg-info/requires.txt
39
42
  wup.egg-info/top_level.txt
43
+ wup/file_watcher/events/file_events.py
40
44
  wup/models/__init__.py
41
- wup/models/config.py
45
+ wup/models/config.py
46
+ wup/testing/events/health_events.py
47
+ wup/testing/events/test_results.py
48
+ wup/testing/handlers/event_handlers.py
49
+ wup/testing/handlers/health_handlers.py
50
+ wup/testing/queries/health_queries.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