wup 0.2.69__tar.gz → 0.2.70__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.
- {wup-0.2.69/wup.egg-info → wup-0.2.70}/PKG-INFO +30 -6
- {wup-0.2.69 → wup-0.2.70}/README.md +29 -5
- {wup-0.2.69 → wup-0.2.70}/pyproject.toml +1 -1
- wup-0.2.70/tests/test_multi_project.py +117 -0
- {wup-0.2.69 → wup-0.2.70}/wup/__init__.py +1 -1
- {wup-0.2.69 → wup-0.2.70}/wup/cli.py +175 -42
- {wup-0.2.69 → wup-0.2.70}/wup/config.py +24 -4
- {wup-0.2.69 → wup-0.2.70}/wup/core.py +52 -11
- {wup-0.2.69 → wup-0.2.70}/wup/monitoring_manifest.py +44 -36
- wup-0.2.70/wup/multi.py +81 -0
- {wup-0.2.69 → wup-0.2.70}/wup/planfile_reporter.py +33 -22
- {wup-0.2.69 → wup-0.2.70}/wup/status_data.py +27 -16
- {wup-0.2.69 → wup-0.2.70}/wup/testql_watcher.py +28 -33
- {wup-0.2.69 → wup-0.2.70/wup.egg-info}/PKG-INFO +30 -6
- {wup-0.2.69 → wup-0.2.70}/wup.egg-info/SOURCES.txt +2 -0
- {wup-0.2.69 → wup-0.2.70}/LICENSE +0 -0
- {wup-0.2.69 → wup-0.2.70}/setup.cfg +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_assistant.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_auto_detection.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_cli_bridge.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_cli_filtering.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_control.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_e2e.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_endpoints_init_cli.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_health_summary_passed.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_monitoring_manifest.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_planfile_reporter_dedupe.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_probe_mutex.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_service_inference.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_status_data.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_sync.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_testql_monitor.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_testql_watcher.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_visual_diff_periodic_skip.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_visual_diff_progress.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_watch_exclude.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_watch_no_paths_exit.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_web_client.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_wup.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/tests/test_wup_generate.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/_ast_detector.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/_base_detector.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/_hash_detector.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/_yaml_detector.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/anomaly_detector.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/anomaly_models.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/assistant.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/assistant_discovery.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/assistant_validator.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/bus.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/cli_bridge.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/cli_config_generator.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/cli_scanner.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/control.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/dependency_mapper.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/endpoints.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/event_store.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/file_watcher/events/file_events.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/generate.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/init_cli.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/models/__init__.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/models/config.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/models/target.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/paths.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/sync.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/testing/events/health_events.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/testing/events/test_results.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/testing/handlers/event_handlers.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/testing/handlers/health_handlers.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/testing/queries/health_queries.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/testql_cli_generator.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/testql_discovery.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/testql_monitor.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/validate.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/visual_diff.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup/web_client.py +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup.egg-info/dependency_links.txt +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup.egg-info/entry_points.txt +0 -0
- {wup-0.2.69 → wup-0.2.70}/wup.egg-info/requires.txt +0 -0
- {wup-0.2.69 → wup-0.2.70}/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.
|
|
3
|
+
Version: 0.2.70
|
|
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
|
-
    
|
|
46
|
+
  
|
|
47
47
|
|
|
48
|
-
- 🤖 **LLM usage:** $
|
|
48
|
+
- 🤖 **LLM usage:** $6.5152 (98 commits)
|
|
49
49
|
- 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
|
|
50
50
|
|
|
51
|
-
Generated on 2026-07-
|
|
51
|
+
Generated on 2026-07-17 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
52
52
|
|
|
53
53
|
---
|
|
54
54
|
|
|
55
|
-
    
|
|
56
56
|
|
|
57
57
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
58
58
|
|
|
@@ -147,6 +147,30 @@ wup watch ./my-project --config custom-config.yaml
|
|
|
147
147
|
wup testql-endpoints /path/to/scenarios --output testql-deps.json
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
+
### Watch Multiple Projects Simultaneously
|
|
151
|
+
|
|
152
|
+
Pass several project roots to watch them all at once in a single process. Each
|
|
153
|
+
project keeps its own `wup.yaml`, dependency map, file observer and test queue.
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
# Watch several projects at the same time
|
|
157
|
+
wup watch ./service-a ./service-b ./service-c
|
|
158
|
+
|
|
159
|
+
# Discover: expand a monorepo root into every sub-project that has a wup.yaml
|
|
160
|
+
wup watch --discover ./my-monorepo
|
|
161
|
+
|
|
162
|
+
# Discover from the current directory
|
|
163
|
+
wup watch -D .
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`--discover` walks the immediate sub-directories of each given path and watches
|
|
167
|
+
those that already contain a `wup.yaml` (hidden and vendor folders such as
|
|
168
|
+
`node_modules`, `.venv`, `dist` and `build` are skipped). A relative `--deps`
|
|
169
|
+
path is resolved per project, so each project uses its own `deps.json`.
|
|
170
|
+
|
|
171
|
+
> The live `--dashboard` and single `--config` options apply to single-project
|
|
172
|
+
> runs only; when watching multiple projects each uses its own `wup.yaml`.
|
|
173
|
+
|
|
150
174
|
### Initialize Configuration
|
|
151
175
|
|
|
152
176
|
```bash
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
## AI Cost Tracking
|
|
5
5
|
|
|
6
|
-
    
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $
|
|
9
|
+
- 🤖 **LLM usage:** $6.5152 (98 commits)
|
|
10
10
|
- 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
|
-
Generated on 2026-07-
|
|
12
|
+
Generated on 2026-07-17 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
    
|
|
17
17
|
|
|
18
18
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
19
19
|
|
|
@@ -108,6 +108,30 @@ wup watch ./my-project --config custom-config.yaml
|
|
|
108
108
|
wup testql-endpoints /path/to/scenarios --output testql-deps.json
|
|
109
109
|
```
|
|
110
110
|
|
|
111
|
+
### Watch Multiple Projects Simultaneously
|
|
112
|
+
|
|
113
|
+
Pass several project roots to watch them all at once in a single process. Each
|
|
114
|
+
project keeps its own `wup.yaml`, dependency map, file observer and test queue.
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Watch several projects at the same time
|
|
118
|
+
wup watch ./service-a ./service-b ./service-c
|
|
119
|
+
|
|
120
|
+
# Discover: expand a monorepo root into every sub-project that has a wup.yaml
|
|
121
|
+
wup watch --discover ./my-monorepo
|
|
122
|
+
|
|
123
|
+
# Discover from the current directory
|
|
124
|
+
wup watch -D .
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`--discover` walks the immediate sub-directories of each given path and watches
|
|
128
|
+
those that already contain a `wup.yaml` (hidden and vendor folders such as
|
|
129
|
+
`node_modules`, `.venv`, `dist` and `build` are skipped). A relative `--deps`
|
|
130
|
+
path is resolved per project, so each project uses its own `deps.json`.
|
|
131
|
+
|
|
132
|
+
> The live `--dashboard` and single `--config` options apply to single-project
|
|
133
|
+
> runs only; when watching multiple projects each uses its own `wup.yaml`.
|
|
134
|
+
|
|
111
135
|
### Initialize Configuration
|
|
112
136
|
|
|
113
137
|
```bash
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Tests for simultaneous multi-project watching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from wup.cli import _discover_projects, _resolve_project_paths
|
|
8
|
+
from wup.config import detect_watch_paths, get_default_config
|
|
9
|
+
from wup.core import WupWatcher
|
|
10
|
+
from wup.models.config import ProjectConfig, WatchConfig, WupConfig
|
|
11
|
+
from wup.multi import MultiProjectWatcher
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _make_project(root: Path, name: str, src: str = "src") -> Path:
|
|
15
|
+
"""Create a project dir with a wup.yaml and one watchable source folder."""
|
|
16
|
+
proj = root / name
|
|
17
|
+
(proj / src).mkdir(parents=True)
|
|
18
|
+
(proj / src / "main.py").write_text("print('hi')\n")
|
|
19
|
+
(proj / "wup.yaml").write_text(
|
|
20
|
+
f"project:\n name: {name}\nwatch:\n paths:\n - {src}/**\n"
|
|
21
|
+
)
|
|
22
|
+
return proj
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _watcher(project_root: Path, paths: list[str]) -> WupWatcher:
|
|
26
|
+
cfg = WupConfig(
|
|
27
|
+
project=ProjectConfig(name=project_root.name),
|
|
28
|
+
watch=WatchConfig(paths=paths),
|
|
29
|
+
)
|
|
30
|
+
return WupWatcher(
|
|
31
|
+
project_root=str(project_root),
|
|
32
|
+
deps_file=str(project_root / "deps.json"),
|
|
33
|
+
config=cfg,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_detect_watch_paths_uses_existing_dirs(tmp_path: Path) -> None:
|
|
38
|
+
(tmp_path / "services").mkdir()
|
|
39
|
+
(tmp_path / "lib").mkdir()
|
|
40
|
+
paths = detect_watch_paths(tmp_path)
|
|
41
|
+
assert "services/**" in paths
|
|
42
|
+
assert "lib/**" in paths
|
|
43
|
+
# A non-existent common dir is not emitted.
|
|
44
|
+
assert "app/**" not in paths
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_detect_watch_paths_falls_back_when_nothing_matches(tmp_path: Path) -> None:
|
|
48
|
+
assert detect_watch_paths(tmp_path) == ["app/**", "src/**", "routes/**"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_default_config_watches_only_real_dirs(tmp_path: Path) -> None:
|
|
52
|
+
(tmp_path / "services").mkdir()
|
|
53
|
+
cfg = get_default_config(tmp_path)
|
|
54
|
+
assert cfg.watch.paths == ["services/**"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_discover_finds_subprojects(tmp_path: Path) -> None:
|
|
58
|
+
_make_project(tmp_path, "alpha")
|
|
59
|
+
_make_project(tmp_path, "beta")
|
|
60
|
+
(tmp_path / "not_a_project").mkdir() # no wup.yaml -> skipped
|
|
61
|
+
|
|
62
|
+
discovered = {p.name for p in _discover_projects(tmp_path)}
|
|
63
|
+
assert discovered == {"alpha", "beta"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_discover_skips_vendor_and_hidden(tmp_path: Path) -> None:
|
|
67
|
+
_make_project(tmp_path, "alpha")
|
|
68
|
+
_make_project(tmp_path, "node_modules") # vendored -> skipped
|
|
69
|
+
_make_project(tmp_path, ".hidden") # hidden -> skipped
|
|
70
|
+
|
|
71
|
+
discovered = {p.name for p in _discover_projects(tmp_path)}
|
|
72
|
+
assert discovered == {"alpha"}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_resolve_paths_dedupes(tmp_path: Path) -> None:
|
|
76
|
+
_make_project(tmp_path, "alpha")
|
|
77
|
+
resolved = _resolve_project_paths(
|
|
78
|
+
[str(tmp_path / "alpha"), str(tmp_path / "alpha")], discover=False
|
|
79
|
+
)
|
|
80
|
+
assert len(resolved) == 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_prepare_observer_none_when_no_valid_paths(tmp_path: Path) -> None:
|
|
84
|
+
watcher = _watcher(tmp_path, paths=["does-not-exist/**"])
|
|
85
|
+
assert watcher.prepare_observer() is None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_multi_watcher_returns_false_when_all_invalid(tmp_path: Path) -> None:
|
|
89
|
+
w1 = _watcher(tmp_path, paths=["missing-a/**"])
|
|
90
|
+
w2 = _watcher(tmp_path, paths=["missing-b/**"])
|
|
91
|
+
assert MultiProjectWatcher([w1, w2]).start_watching() is False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_multi_watcher_starts_observers_for_valid_projects(tmp_path: Path) -> None:
|
|
95
|
+
alpha = _make_project(tmp_path, "alpha")
|
|
96
|
+
beta = _make_project(tmp_path, "beta")
|
|
97
|
+
w1 = _watcher(alpha, paths=["src/**"])
|
|
98
|
+
w2 = _watcher(beta, paths=["src/**"])
|
|
99
|
+
|
|
100
|
+
multi = MultiProjectWatcher([w1, w2])
|
|
101
|
+
observers = []
|
|
102
|
+
active = []
|
|
103
|
+
for watcher in multi.watchers:
|
|
104
|
+
watcher.start_background_tasks()
|
|
105
|
+
obs = watcher.prepare_observer()
|
|
106
|
+
if obs is not None:
|
|
107
|
+
observers.append(obs)
|
|
108
|
+
active.append(watcher)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
assert len(active) == 2
|
|
112
|
+
assert all(obs.is_alive() for obs in observers)
|
|
113
|
+
finally:
|
|
114
|
+
for obs in observers:
|
|
115
|
+
obs.stop()
|
|
116
|
+
for obs in observers:
|
|
117
|
+
obs.join()
|
|
@@ -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.
|
|
10
|
+
__version__ = "0.2.70"
|
|
11
11
|
__author__ = "Tom Sapletta"
|
|
12
12
|
|
|
13
13
|
from .config import load_config, save_config, get_default_config
|
|
@@ -4,7 +4,7 @@ CLI interface for WUP (What's Up) - Intelligent file watcher for regression test
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from typing import Optional
|
|
7
|
+
from typing import List, Optional
|
|
8
8
|
|
|
9
9
|
import typer
|
|
10
10
|
from rich.console import Console
|
|
@@ -13,6 +13,7 @@ from .config import find_config_file, load_config
|
|
|
13
13
|
from .core import WupWatcher
|
|
14
14
|
from .dependency_mapper import DependencyMapper
|
|
15
15
|
from .models.config import WupConfig
|
|
16
|
+
from .multi import MultiProjectWatcher
|
|
16
17
|
from .testql_watcher import TestQLWatcher
|
|
17
18
|
|
|
18
19
|
app = typer.Typer(
|
|
@@ -117,9 +118,119 @@ def _create_watcher(
|
|
|
117
118
|
)
|
|
118
119
|
|
|
119
120
|
|
|
121
|
+
def _is_project_dir(path: Path) -> bool:
|
|
122
|
+
"""A directory is a WUP project if it already carries a wup.yaml/.wup.yaml."""
|
|
123
|
+
return path.is_dir() and find_config_file(path) is not None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _discover_projects(root: Path) -> List[Path]:
|
|
127
|
+
"""
|
|
128
|
+
Expand a monorepo root into its immediate sub-projects.
|
|
129
|
+
|
|
130
|
+
Returns child directories that already have a wup.yaml, sorted by name. The
|
|
131
|
+
root itself is included when it, too, has a config. Hidden directories and
|
|
132
|
+
common vendor/build folders are skipped.
|
|
133
|
+
"""
|
|
134
|
+
skip = {"node_modules", "__pycache__", ".git", ".venv", "venv", "dist", "build"}
|
|
135
|
+
found: List[Path] = []
|
|
136
|
+
if _is_project_dir(root):
|
|
137
|
+
found.append(root)
|
|
138
|
+
for child in sorted(root.iterdir(), key=lambda p: p.name):
|
|
139
|
+
if child.name in skip or child.name.startswith("."):
|
|
140
|
+
continue
|
|
141
|
+
if _is_project_dir(child):
|
|
142
|
+
found.append(child)
|
|
143
|
+
return found
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _resolve_project_paths(projects: List[str], discover: bool) -> List[Path]:
|
|
147
|
+
"""
|
|
148
|
+
Turn raw CLI project arguments into a de-duplicated list of project roots.
|
|
149
|
+
|
|
150
|
+
With ``discover`` each argument is expanded into its sub-projects (see
|
|
151
|
+
:func:`_discover_projects`); without it each argument is taken as-is.
|
|
152
|
+
"""
|
|
153
|
+
resolved: List[Path] = []
|
|
154
|
+
seen = set()
|
|
155
|
+
for raw in projects:
|
|
156
|
+
base = Path(raw).resolve()
|
|
157
|
+
if not base.exists():
|
|
158
|
+
console.print(f"[red]Error: Project path '{raw}' does not exist[/red]")
|
|
159
|
+
raise typer.Exit(1)
|
|
160
|
+
candidates = _discover_projects(base) if discover else [base]
|
|
161
|
+
if discover and not candidates:
|
|
162
|
+
console.print(
|
|
163
|
+
f"[yellow]No wup.yaml projects discovered under '{raw}'[/yellow]"
|
|
164
|
+
)
|
|
165
|
+
for path in candidates:
|
|
166
|
+
if path not in seen:
|
|
167
|
+
seen.add(path)
|
|
168
|
+
resolved.append(path)
|
|
169
|
+
return resolved
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _build_project_watcher(
|
|
173
|
+
project_path: Path,
|
|
174
|
+
config_path: Optional[Path],
|
|
175
|
+
*,
|
|
176
|
+
mode: str,
|
|
177
|
+
deps_file: str,
|
|
178
|
+
cpu_throttle: float,
|
|
179
|
+
debounce: int,
|
|
180
|
+
cooldown: int,
|
|
181
|
+
scenarios_dir: Optional[str],
|
|
182
|
+
testql_bin: str,
|
|
183
|
+
browser_service_url: Optional[str],
|
|
184
|
+
track_dir: str,
|
|
185
|
+
quick_limit: int,
|
|
186
|
+
probe_interval: Optional[int],
|
|
187
|
+
print_header: bool = True,
|
|
188
|
+
) -> WupWatcher:
|
|
189
|
+
"""Prepare a single project: auto-config, load, refresh manifest, build watcher."""
|
|
190
|
+
# Auto-generate config if it doesn't exist
|
|
191
|
+
if not find_config_file(project_path) and not (config_path and config_path.exists()):
|
|
192
|
+
console.print(
|
|
193
|
+
f"[cyan]🔍 No wup.yaml in {project_path.name} - auto-detecting project type...[/cyan]"
|
|
194
|
+
)
|
|
195
|
+
_auto_generate_config(project_path, mode)
|
|
196
|
+
console.print(f"[green]✓ Auto-generated wup.yaml for {project_path.name}[/green]")
|
|
197
|
+
console.print()
|
|
198
|
+
|
|
199
|
+
wup_config = _load_watch_config(project_path, config_path, probe_interval, mode)
|
|
200
|
+
effective_scenarios_dir = scenarios_dir or wup_config.testql.scenario_dir
|
|
201
|
+
|
|
202
|
+
if print_header:
|
|
203
|
+
_print_watch_header(wup_config, cpu_throttle, debounce, cooldown, config_path)
|
|
204
|
+
|
|
205
|
+
cfg_path = config_path if config_path and config_path.exists() else find_config_file(project_path)
|
|
206
|
+
_refresh_monitoring_manifest(project_path, wup_config, cfg_path)
|
|
207
|
+
|
|
208
|
+
# Resolve a relative deps file against the project root so each project keeps
|
|
209
|
+
# its own dependency map (for `wup watch .` this equals the CWD, unchanged).
|
|
210
|
+
deps_path = deps_file if Path(deps_file).is_absolute() else str(project_path / deps_file)
|
|
211
|
+
|
|
212
|
+
return _create_watcher(
|
|
213
|
+
mode=mode,
|
|
214
|
+
project_path=project_path,
|
|
215
|
+
deps_file=deps_path,
|
|
216
|
+
cpu_throttle=cpu_throttle,
|
|
217
|
+
debounce=debounce,
|
|
218
|
+
cooldown=cooldown,
|
|
219
|
+
scenarios_dir=effective_scenarios_dir,
|
|
220
|
+
testql_bin=testql_bin,
|
|
221
|
+
browser_service_url=browser_service_url,
|
|
222
|
+
track_dir=track_dir,
|
|
223
|
+
quick_limit=quick_limit,
|
|
224
|
+
config=wup_config,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
120
228
|
@app.command()
|
|
121
229
|
def watch(
|
|
122
|
-
|
|
230
|
+
projects: Optional[List[str]] = typer.Argument(
|
|
231
|
+
None,
|
|
232
|
+
help="One or more project root directories (default: current directory)",
|
|
233
|
+
),
|
|
123
234
|
deps_file: str = typer.Option("deps.json", "--deps", "-d", help="Path to dependency map file"),
|
|
124
235
|
cpu_throttle: float = typer.Option(0.8, "--cpu-throttle", "-c", help="CPU usage threshold (0.0-1.0)"),
|
|
125
236
|
debounce: int = typer.Option(2, "--debounce", "-b", help="Debounce time in seconds"),
|
|
@@ -144,10 +255,21 @@ def watch(
|
|
|
144
255
|
"--probe-interval",
|
|
145
256
|
help="Periodic live HTTP probes in seconds (default: 60 in testql mode, or testql.probe_interval_s from wup.yaml; use 0 to disable)",
|
|
146
257
|
),
|
|
258
|
+
discover: bool = typer.Option(
|
|
259
|
+
False,
|
|
260
|
+
"--discover",
|
|
261
|
+
"-D",
|
|
262
|
+
help="Treat each given path as a monorepo root and watch every sub-directory that has a wup.yaml",
|
|
263
|
+
),
|
|
147
264
|
config: Optional[str] = typer.Option(None, "--config", "-C", help="Path to wup.yaml config file"),
|
|
148
265
|
):
|
|
149
266
|
"""
|
|
150
|
-
Watch
|
|
267
|
+
Watch one or more projects for file changes and run regression tests.
|
|
268
|
+
|
|
269
|
+
Pass several project directories to test them **simultaneously**
|
|
270
|
+
(``wup watch proj-a proj-b``), or use ``--discover`` to expand a monorepo
|
|
271
|
+
root into every sub-directory that already has a wup.yaml. With no argument
|
|
272
|
+
the current directory is watched.
|
|
151
273
|
|
|
152
274
|
Defaults (no extra flags): ``--mode testql`` and live probes every **60s**
|
|
153
275
|
(unless ``testql.probe_interval_s`` is set in wup.yaml). Use
|
|
@@ -155,51 +277,62 @@ def watch(
|
|
|
155
277
|
|
|
156
278
|
If wup.yaml doesn't exist, it will be auto-generated based on project detection.
|
|
157
279
|
"""
|
|
158
|
-
|
|
280
|
+
raw_projects = list(projects) if projects else ["."]
|
|
281
|
+
project_paths = _resolve_project_paths(raw_projects, discover)
|
|
159
282
|
|
|
160
|
-
if not
|
|
161
|
-
console.print(
|
|
283
|
+
if not project_paths:
|
|
284
|
+
console.print("[red]No projects to watch[/red]")
|
|
162
285
|
raise typer.Exit(1)
|
|
163
286
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if not
|
|
168
|
-
console.print(
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
wup_config = _load_watch_config(project_path, config_path, probe_interval, mode)
|
|
174
|
-
effective_scenarios_dir = scenarios_dir or wup_config.testql.scenario_dir
|
|
175
|
-
|
|
176
|
-
_print_watch_header(wup_config, cpu_throttle, debounce, cooldown, config_path)
|
|
177
|
-
|
|
178
|
-
cfg_path = config_path if config_path and config_path.exists() else find_config_file(project_path)
|
|
179
|
-
_refresh_monitoring_manifest(project_path, wup_config, cfg_path)
|
|
287
|
+
# --config only applies to a single explicit project.
|
|
288
|
+
single = len(project_paths) == 1
|
|
289
|
+
config_path = Path(config) if (config and single) else None
|
|
290
|
+
if config and not single:
|
|
291
|
+
console.print(
|
|
292
|
+
"[yellow]--config is ignored when watching multiple projects; "
|
|
293
|
+
"each project uses its own wup.yaml[/yellow]"
|
|
294
|
+
)
|
|
180
295
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
296
|
+
watchers = [
|
|
297
|
+
_build_project_watcher(
|
|
298
|
+
project_path,
|
|
299
|
+
config_path,
|
|
300
|
+
mode=mode,
|
|
301
|
+
deps_file=deps_file,
|
|
302
|
+
cpu_throttle=cpu_throttle,
|
|
303
|
+
debounce=debounce,
|
|
304
|
+
cooldown=cooldown,
|
|
305
|
+
scenarios_dir=scenarios_dir,
|
|
306
|
+
testql_bin=testql_bin,
|
|
307
|
+
browser_service_url=browser_service_url,
|
|
308
|
+
track_dir=track_dir,
|
|
309
|
+
quick_limit=quick_limit,
|
|
310
|
+
probe_interval=probe_interval,
|
|
311
|
+
)
|
|
312
|
+
for project_path in project_paths
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
if single:
|
|
316
|
+
watcher = watchers[0]
|
|
317
|
+
if dashboard:
|
|
318
|
+
console.print("[green]Starting watcher with live dashboard...[/green]")
|
|
319
|
+
asyncio.run(watcher.run_with_dashboard())
|
|
320
|
+
else:
|
|
321
|
+
console.print("[green]Starting watcher...[/green]")
|
|
322
|
+
if watcher.start_watching() is False:
|
|
323
|
+
raise typer.Exit(1)
|
|
324
|
+
return
|
|
195
325
|
|
|
196
326
|
if dashboard:
|
|
197
|
-
console.print(
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
327
|
+
console.print(
|
|
328
|
+
"[yellow]--dashboard is not supported with multiple projects; "
|
|
329
|
+
"running without dashboard[/yellow]"
|
|
330
|
+
)
|
|
331
|
+
console.print(
|
|
332
|
+
f"[green]Starting {len(watchers)} watchers simultaneously...[/green]"
|
|
333
|
+
)
|
|
334
|
+
if MultiProjectWatcher(watchers, console=console).start_watching() is False:
|
|
335
|
+
raise typer.Exit(1)
|
|
203
336
|
|
|
204
337
|
|
|
205
338
|
def _auto_generate_config(project_path: Path, mode: str):
|
|
@@ -389,25 +389,45 @@ def validate_config(raw: dict) -> WupConfig:
|
|
|
389
389
|
)
|
|
390
390
|
|
|
391
391
|
|
|
392
|
+
# Common source-directory names, in priority order, used to seed watch paths
|
|
393
|
+
# for an auto-generated config. Only directories that actually exist are kept,
|
|
394
|
+
# so the generated wup.yaml doesn't point at paths that were never there.
|
|
395
|
+
_DEFAULT_SOURCE_DIRS = [
|
|
396
|
+
"app", "src", "routes", "services", "lib", "packages", "pkg", "internal", "cmd",
|
|
397
|
+
]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def detect_watch_paths(project_root: Path) -> list:
|
|
401
|
+
"""
|
|
402
|
+
Pick watch-path globs for a project by probing for common source directories.
|
|
403
|
+
|
|
404
|
+
Returns ``["<dir>/**", ...]`` for every well-known source directory that
|
|
405
|
+
exists under ``project_root``. Falls back to the classic app/src/routes
|
|
406
|
+
guess only when none are found, so a brand-new project still gets a config.
|
|
407
|
+
"""
|
|
408
|
+
found = [f"{name}/**" for name in _DEFAULT_SOURCE_DIRS if (project_root / name).is_dir()]
|
|
409
|
+
return found or ["app/**", "src/**", "routes/**"]
|
|
410
|
+
|
|
411
|
+
|
|
392
412
|
def get_default_config(project_root: Path) -> WupConfig:
|
|
393
413
|
"""
|
|
394
414
|
Get default configuration when no config file exists.
|
|
395
|
-
|
|
415
|
+
|
|
396
416
|
Args:
|
|
397
417
|
project_root: Path to project root directory
|
|
398
|
-
|
|
418
|
+
|
|
399
419
|
Returns:
|
|
400
420
|
Default WupConfig object
|
|
401
421
|
"""
|
|
402
422
|
project_name = project_root.name
|
|
403
|
-
|
|
423
|
+
|
|
404
424
|
return WupConfig(
|
|
405
425
|
project=ProjectConfig(
|
|
406
426
|
name=project_name,
|
|
407
427
|
description=f"Auto-generated config for {project_name}"
|
|
408
428
|
),
|
|
409
429
|
watch=WatchConfig(
|
|
410
|
-
paths=
|
|
430
|
+
paths=detect_watch_paths(project_root),
|
|
411
431
|
exclude_patterns=["*.md", "*.txt", "tests/**", "node_modules/**"]
|
|
412
432
|
),
|
|
413
433
|
services=[],
|
|
@@ -566,18 +566,31 @@ class WupWatcher:
|
|
|
566
566
|
return observer
|
|
567
567
|
raise
|
|
568
568
|
|
|
569
|
-
def
|
|
569
|
+
def start_background_tasks(self) -> None:
|
|
570
570
|
"""
|
|
571
|
-
|
|
571
|
+
Hook for subclasses to start background threads before watching begins.
|
|
572
|
+
|
|
573
|
+
The base watcher has nothing to start; TestQLWatcher overrides this to
|
|
574
|
+
launch its periodic live-probe thread. Coordinators that run several
|
|
575
|
+
watchers at once (see :class:`~wup.multi.MultiProjectWatcher`) call this
|
|
576
|
+
on each watcher so background probes fire regardless of who owns the
|
|
577
|
+
main loop.
|
|
578
|
+
"""
|
|
579
|
+
|
|
580
|
+
def prepare_observer(self, watch_paths: Optional[List[str]] = None):
|
|
581
|
+
"""
|
|
582
|
+
Set up and start the filesystem observer without entering a blocking loop.
|
|
583
|
+
|
|
584
|
+
Splitting this out of :meth:`start_watching` lets a coordinator drive
|
|
585
|
+
several watchers from a single loop (multi-project mode).
|
|
572
586
|
|
|
573
587
|
Args:
|
|
574
|
-
watch_paths: List of paths to watch (default: from config or common
|
|
588
|
+
watch_paths: List of paths to watch (default: from config or common
|
|
589
|
+
source directories)
|
|
575
590
|
|
|
576
591
|
Returns:
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
of treating a watcher that never watched as a clean run),
|
|
580
|
-
True after a graceful interrupt.
|
|
592
|
+
The started observer, or ``None`` when no valid paths could be
|
|
593
|
+
watched (configuration error).
|
|
581
594
|
"""
|
|
582
595
|
if watch_paths is None:
|
|
583
596
|
watch_paths = self.build_watched_paths()
|
|
@@ -586,12 +599,38 @@ class WupWatcher:
|
|
|
586
599
|
watch_paths = [p for p in watch_paths if Path(p).exists()]
|
|
587
600
|
|
|
588
601
|
if not watch_paths:
|
|
589
|
-
self.console.print(
|
|
590
|
-
|
|
602
|
+
self.console.print(
|
|
603
|
+
f"[red]No valid paths to watch[/red] "
|
|
604
|
+
f"[dim](project: {self.config.project.name})[/dim]"
|
|
605
|
+
)
|
|
606
|
+
return None
|
|
591
607
|
|
|
592
608
|
event_handler = WupEventHandler(self)
|
|
593
609
|
observer = self._create_and_start_observer(event_handler, watch_paths)
|
|
594
|
-
self.console.print(
|
|
610
|
+
self.console.print(
|
|
611
|
+
f"[green]🕵️ Watching ({self.config.project.name}): "
|
|
612
|
+
f"{', '.join(watch_paths)}[/green]"
|
|
613
|
+
)
|
|
614
|
+
return observer
|
|
615
|
+
|
|
616
|
+
def start_watching(self, watch_paths: Optional[List[str]] = None) -> bool:
|
|
617
|
+
"""
|
|
618
|
+
Start watching for file changes.
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
watch_paths: List of paths to watch (default: from config or common source directories)
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
False when no valid paths could be watched (configuration error —
|
|
625
|
+
CLI turns this into a non-zero exit so supervisors notice instead
|
|
626
|
+
of treating a watcher that never watched as a clean run),
|
|
627
|
+
True after a graceful interrupt.
|
|
628
|
+
"""
|
|
629
|
+
self.start_background_tasks()
|
|
630
|
+
|
|
631
|
+
observer = self.prepare_observer(watch_paths)
|
|
632
|
+
if observer is None:
|
|
633
|
+
return False
|
|
595
634
|
|
|
596
635
|
try:
|
|
597
636
|
while True:
|
|
@@ -636,9 +675,11 @@ class WupWatcher:
|
|
|
636
675
|
|
|
637
676
|
async def run_with_dashboard(self):
|
|
638
677
|
"""Run watcher with live dashboard."""
|
|
678
|
+
self.start_background_tasks()
|
|
679
|
+
|
|
639
680
|
watch_paths = self.build_watched_paths()
|
|
640
681
|
watch_paths = [p for p in watch_paths if Path(p).exists()]
|
|
641
|
-
|
|
682
|
+
|
|
642
683
|
event_handler = WupEventHandler(self)
|
|
643
684
|
observer = self._create_and_start_observer(event_handler, watch_paths)
|
|
644
685
|
|