wup 0.2.69__tar.gz → 0.2.71__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.71}/PKG-INFO +31 -7
- {wup-0.2.69 → wup-0.2.71}/README.md +30 -6
- {wup-0.2.69 → wup-0.2.71}/pyproject.toml +1 -1
- wup-0.2.71/tests/test_genericity.py +84 -0
- wup-0.2.71/tests/test_multi_project.py +117 -0
- {wup-0.2.69 → wup-0.2.71}/wup/__init__.py +1 -1
- {wup-0.2.69 → wup-0.2.71}/wup/cli.py +175 -42
- {wup-0.2.69 → wup-0.2.71}/wup/config.py +28 -4
- {wup-0.2.69 → wup-0.2.71}/wup/core.py +52 -11
- {wup-0.2.69 → wup-0.2.71}/wup/dependency_mapper.py +56 -39
- {wup-0.2.69 → wup-0.2.71}/wup/models/config.py +6 -0
- {wup-0.2.69 → wup-0.2.71}/wup/monitoring_manifest.py +94 -44
- wup-0.2.71/wup/multi.py +81 -0
- {wup-0.2.69 → wup-0.2.71}/wup/planfile_reporter.py +33 -22
- {wup-0.2.69 → wup-0.2.71}/wup/status_data.py +27 -16
- {wup-0.2.69 → wup-0.2.71}/wup/testql_watcher.py +28 -33
- {wup-0.2.69 → wup-0.2.71/wup.egg-info}/PKG-INFO +31 -7
- {wup-0.2.69 → wup-0.2.71}/wup.egg-info/SOURCES.txt +3 -0
- {wup-0.2.69 → wup-0.2.71}/LICENSE +0 -0
- {wup-0.2.69 → wup-0.2.71}/setup.cfg +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_assistant.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_auto_detection.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_cli_bridge.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_cli_filtering.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_control.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_e2e.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_endpoints_init_cli.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_health_summary_passed.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_monitoring_manifest.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_planfile_reporter_dedupe.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_probe_mutex.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_service_inference.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_status_data.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_sync.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_testql_monitor.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_testql_watcher.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_visual_diff_periodic_skip.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_visual_diff_progress.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_watch_exclude.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_watch_no_paths_exit.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_web_client.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_wup.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/tests/test_wup_generate.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/_ast_detector.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/_base_detector.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/_hash_detector.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/_yaml_detector.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/anomaly_detector.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/anomaly_models.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/assistant.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/assistant_discovery.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/assistant_validator.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/bus.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/cli_bridge.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/cli_config_generator.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/cli_scanner.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/control.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/endpoints.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/event_store.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/file_watcher/events/file_events.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/generate.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/init_cli.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/models/__init__.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/models/target.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/paths.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/sync.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/testing/events/health_events.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/testing/events/test_results.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/testing/handlers/event_handlers.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/testing/handlers/health_handlers.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/testing/queries/health_queries.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/testql_cli_generator.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/testql_discovery.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/testql_monitor.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/validate.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/visual_diff.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup/web_client.py +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup.egg-info/dependency_links.txt +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup.egg-info/entry_points.txt +0 -0
- {wup-0.2.69 → wup-0.2.71}/wup.egg-info/requires.txt +0 -0
- {wup-0.2.69 → wup-0.2.71}/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.71
|
|
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:** $
|
|
49
|
-
- 👤 **Human dev:** ~$
|
|
48
|
+
- 🤖 **LLM usage:** $7.2227 (99 commits)
|
|
49
|
+
- 👤 **Human dev:** ~$5215 (52.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:** $
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $7.2227 (99 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$5215 (52.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,84 @@
|
|
|
1
|
+
"""Tests for language-agnostic deps.json discovery and config-driven docker mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from wup.config import validate_config
|
|
8
|
+
from wup.dependency_mapper import DependencyMapper
|
|
9
|
+
from wup.monitoring_manifest import DockerComposeService, _map_docker_to_wup_service
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _dc(compose: str, container: str = "") -> DockerComposeService:
|
|
13
|
+
return DockerComposeService(compose_service=compose, container_name=container)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# --- deps.json genericity -------------------------------------------------
|
|
17
|
+
|
|
18
|
+
def test_express_ts_endpoints_discovered(tmp_path: Path) -> None:
|
|
19
|
+
"""A TypeScript Express project yields a non-empty deps map (regression: rglob brace bug)."""
|
|
20
|
+
svc = tmp_path / "services" / "users"
|
|
21
|
+
svc.mkdir(parents=True)
|
|
22
|
+
(svc / "routes.ts").write_text('router.get("/api/users", h)\nrouter.post("/api/users", h)\n')
|
|
23
|
+
|
|
24
|
+
mapper = DependencyMapper(str(tmp_path))
|
|
25
|
+
result = mapper.build_from_codebase()
|
|
26
|
+
|
|
27
|
+
assert mapper._detect_framework() == "express"
|
|
28
|
+
assert "services/users" in result["services"]
|
|
29
|
+
assert "/api/users" in result["services"]["services/users"]["endpoints"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_infer_service_uses_services_dir(tmp_path: Path) -> None:
|
|
33
|
+
mapper = DependencyMapper(str(tmp_path))
|
|
34
|
+
assert mapper._infer_service("services/payments/handler.ts") == "services/payments"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_to_dict_handles_service_without_endpoints(tmp_path: Path) -> None:
|
|
38
|
+
"""Services with files but no endpoints must not corrupt the map (regression: fragile zip)."""
|
|
39
|
+
mapper = DependencyMapper(str(tmp_path))
|
|
40
|
+
mapper.service_to_files["orphan"].add("a.py")
|
|
41
|
+
mapper.service_to_endpoints["web"].append("/x")
|
|
42
|
+
|
|
43
|
+
d = mapper.to_dict()
|
|
44
|
+
assert d["services"]["orphan"]["files"] == ["a.py"]
|
|
45
|
+
assert d["services"]["orphan"]["endpoints"] == []
|
|
46
|
+
assert d["services"]["web"]["endpoints"] == ["/x"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- config-driven docker mapping ----------------------------------------
|
|
50
|
+
|
|
51
|
+
def test_docker_map_default_is_generic() -> None:
|
|
52
|
+
"""Without a profile, project-specific fleet literals do not auto-map."""
|
|
53
|
+
services = ["backend", "frontend", "payments"]
|
|
54
|
+
# 'connect-scenario' carries no generic token for these services -> unmapped.
|
|
55
|
+
assert _map_docker_to_wup_service(_dc("connect-scenario"), services) is None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_docker_map_connect_profile_opt_in() -> None:
|
|
59
|
+
services = ["backend", "frontend"]
|
|
60
|
+
assert _map_docker_to_wup_service(_dc("connect-scenario"), services, profile="connect") == "backend"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_docker_map_user_rules() -> None:
|
|
64
|
+
services = ["payments", "web"]
|
|
65
|
+
got = _map_docker_to_wup_service(_dc("stripe-worker"), services, user_map={"stripe": "payments"})
|
|
66
|
+
assert got == "payments"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_docker_map_generic_token_match() -> None:
|
|
70
|
+
services = ["payments", "web"]
|
|
71
|
+
assert _map_docker_to_wup_service(_dc("payments-api"), services) == "payments"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_config_roundtrips_docker_service_map() -> None:
|
|
75
|
+
raw = {
|
|
76
|
+
"project": {"name": "p"},
|
|
77
|
+
"testql": {
|
|
78
|
+
"docker_service_map": {"stripe": "payments"},
|
|
79
|
+
"service_map_profile": "connect",
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
cfg = validate_config(raw)
|
|
83
|
+
assert cfg.testql.docker_service_map == {"stripe": "payments"}
|
|
84
|
+
assert cfg.testql.service_map_profile == "connect"
|
|
@@ -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.71"
|
|
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):
|
|
@@ -230,6 +230,8 @@ def _parse_testql_config(raw: dict) -> TestQLConfig:
|
|
|
230
230
|
health_scenario_strict=bool(testql_raw.get("health_scenario_strict", False)),
|
|
231
231
|
quick_smoke_only=bool(testql_raw.get("quick_smoke_only", False)),
|
|
232
232
|
service_map_globs=testql_raw.get("service_map_globs", []),
|
|
233
|
+
docker_service_map=testql_raw.get("docker_service_map", {}),
|
|
234
|
+
service_map_profile=testql_raw.get("service_map_profile", ""),
|
|
233
235
|
base_url=testql_raw.get("base_url", ""),
|
|
234
236
|
api_base_url=testql_raw.get("api_base_url", ""),
|
|
235
237
|
base_url_env=testql_raw.get("base_url_env", "WUP_BASE_URL"),
|
|
@@ -389,25 +391,45 @@ def validate_config(raw: dict) -> WupConfig:
|
|
|
389
391
|
)
|
|
390
392
|
|
|
391
393
|
|
|
394
|
+
# Common source-directory names, in priority order, used to seed watch paths
|
|
395
|
+
# for an auto-generated config. Only directories that actually exist are kept,
|
|
396
|
+
# so the generated wup.yaml doesn't point at paths that were never there.
|
|
397
|
+
_DEFAULT_SOURCE_DIRS = [
|
|
398
|
+
"app", "src", "routes", "services", "lib", "packages", "pkg", "internal", "cmd",
|
|
399
|
+
]
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def detect_watch_paths(project_root: Path) -> list:
|
|
403
|
+
"""
|
|
404
|
+
Pick watch-path globs for a project by probing for common source directories.
|
|
405
|
+
|
|
406
|
+
Returns ``["<dir>/**", ...]`` for every well-known source directory that
|
|
407
|
+
exists under ``project_root``. Falls back to the classic app/src/routes
|
|
408
|
+
guess only when none are found, so a brand-new project still gets a config.
|
|
409
|
+
"""
|
|
410
|
+
found = [f"{name}/**" for name in _DEFAULT_SOURCE_DIRS if (project_root / name).is_dir()]
|
|
411
|
+
return found or ["app/**", "src/**", "routes/**"]
|
|
412
|
+
|
|
413
|
+
|
|
392
414
|
def get_default_config(project_root: Path) -> WupConfig:
|
|
393
415
|
"""
|
|
394
416
|
Get default configuration when no config file exists.
|
|
395
|
-
|
|
417
|
+
|
|
396
418
|
Args:
|
|
397
419
|
project_root: Path to project root directory
|
|
398
|
-
|
|
420
|
+
|
|
399
421
|
Returns:
|
|
400
422
|
Default WupConfig object
|
|
401
423
|
"""
|
|
402
424
|
project_name = project_root.name
|
|
403
|
-
|
|
425
|
+
|
|
404
426
|
return WupConfig(
|
|
405
427
|
project=ProjectConfig(
|
|
406
428
|
name=project_name,
|
|
407
429
|
description=f"Auto-generated config for {project_name}"
|
|
408
430
|
),
|
|
409
431
|
watch=WatchConfig(
|
|
410
|
-
paths=
|
|
432
|
+
paths=detect_watch_paths(project_root),
|
|
411
433
|
exclude_patterns=["*.md", "*.txt", "tests/**", "node_modules/**"]
|
|
412
434
|
),
|
|
413
435
|
services=[],
|
|
@@ -473,6 +495,8 @@ def save_config(config: WupConfig, output_path: Path):
|
|
|
473
495
|
"health_scenario": config.testql.health_scenario,
|
|
474
496
|
"health_scenario_strict": config.testql.health_scenario_strict,
|
|
475
497
|
"service_map_globs": config.testql.service_map_globs,
|
|
498
|
+
"docker_service_map": config.testql.docker_service_map,
|
|
499
|
+
"service_map_profile": config.testql.service_map_profile,
|
|
476
500
|
"base_url": config.testql.base_url,
|
|
477
501
|
"api_base_url": config.testql.api_base_url,
|
|
478
502
|
"base_url_env": config.testql.base_url_env,
|