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.
Files changed (81) hide show
  1. {wup-0.2.69/wup.egg-info → wup-0.2.71}/PKG-INFO +31 -7
  2. {wup-0.2.69 → wup-0.2.71}/README.md +30 -6
  3. {wup-0.2.69 → wup-0.2.71}/pyproject.toml +1 -1
  4. wup-0.2.71/tests/test_genericity.py +84 -0
  5. wup-0.2.71/tests/test_multi_project.py +117 -0
  6. {wup-0.2.69 → wup-0.2.71}/wup/__init__.py +1 -1
  7. {wup-0.2.69 → wup-0.2.71}/wup/cli.py +175 -42
  8. {wup-0.2.69 → wup-0.2.71}/wup/config.py +28 -4
  9. {wup-0.2.69 → wup-0.2.71}/wup/core.py +52 -11
  10. {wup-0.2.69 → wup-0.2.71}/wup/dependency_mapper.py +56 -39
  11. {wup-0.2.69 → wup-0.2.71}/wup/models/config.py +6 -0
  12. {wup-0.2.69 → wup-0.2.71}/wup/monitoring_manifest.py +94 -44
  13. wup-0.2.71/wup/multi.py +81 -0
  14. {wup-0.2.69 → wup-0.2.71}/wup/planfile_reporter.py +33 -22
  15. {wup-0.2.69 → wup-0.2.71}/wup/status_data.py +27 -16
  16. {wup-0.2.69 → wup-0.2.71}/wup/testql_watcher.py +28 -33
  17. {wup-0.2.69 → wup-0.2.71/wup.egg-info}/PKG-INFO +31 -7
  18. {wup-0.2.69 → wup-0.2.71}/wup.egg-info/SOURCES.txt +3 -0
  19. {wup-0.2.69 → wup-0.2.71}/LICENSE +0 -0
  20. {wup-0.2.69 → wup-0.2.71}/setup.cfg +0 -0
  21. {wup-0.2.69 → wup-0.2.71}/tests/test_assistant.py +0 -0
  22. {wup-0.2.69 → wup-0.2.71}/tests/test_auto_detection.py +0 -0
  23. {wup-0.2.69 → wup-0.2.71}/tests/test_cli_bridge.py +0 -0
  24. {wup-0.2.69 → wup-0.2.71}/tests/test_cli_filtering.py +0 -0
  25. {wup-0.2.69 → wup-0.2.71}/tests/test_control.py +0 -0
  26. {wup-0.2.69 → wup-0.2.71}/tests/test_e2e.py +0 -0
  27. {wup-0.2.69 → wup-0.2.71}/tests/test_endpoints_init_cli.py +0 -0
  28. {wup-0.2.69 → wup-0.2.71}/tests/test_health_summary_passed.py +0 -0
  29. {wup-0.2.69 → wup-0.2.71}/tests/test_monitoring_manifest.py +0 -0
  30. {wup-0.2.69 → wup-0.2.71}/tests/test_planfile_reporter_dedupe.py +0 -0
  31. {wup-0.2.69 → wup-0.2.71}/tests/test_probe_mutex.py +0 -0
  32. {wup-0.2.69 → wup-0.2.71}/tests/test_service_inference.py +0 -0
  33. {wup-0.2.69 → wup-0.2.71}/tests/test_status_data.py +0 -0
  34. {wup-0.2.69 → wup-0.2.71}/tests/test_sync.py +0 -0
  35. {wup-0.2.69 → wup-0.2.71}/tests/test_testql_monitor.py +0 -0
  36. {wup-0.2.69 → wup-0.2.71}/tests/test_testql_watcher.py +0 -0
  37. {wup-0.2.69 → wup-0.2.71}/tests/test_visual_diff_periodic_skip.py +0 -0
  38. {wup-0.2.69 → wup-0.2.71}/tests/test_visual_diff_progress.py +0 -0
  39. {wup-0.2.69 → wup-0.2.71}/tests/test_watch_exclude.py +0 -0
  40. {wup-0.2.69 → wup-0.2.71}/tests/test_watch_no_paths_exit.py +0 -0
  41. {wup-0.2.69 → wup-0.2.71}/tests/test_web_client.py +0 -0
  42. {wup-0.2.69 → wup-0.2.71}/tests/test_wup.py +0 -0
  43. {wup-0.2.69 → wup-0.2.71}/tests/test_wup_generate.py +0 -0
  44. {wup-0.2.69 → wup-0.2.71}/wup/_ast_detector.py +0 -0
  45. {wup-0.2.69 → wup-0.2.71}/wup/_base_detector.py +0 -0
  46. {wup-0.2.69 → wup-0.2.71}/wup/_hash_detector.py +0 -0
  47. {wup-0.2.69 → wup-0.2.71}/wup/_yaml_detector.py +0 -0
  48. {wup-0.2.69 → wup-0.2.71}/wup/anomaly_detector.py +0 -0
  49. {wup-0.2.69 → wup-0.2.71}/wup/anomaly_models.py +0 -0
  50. {wup-0.2.69 → wup-0.2.71}/wup/assistant.py +0 -0
  51. {wup-0.2.69 → wup-0.2.71}/wup/assistant_discovery.py +0 -0
  52. {wup-0.2.69 → wup-0.2.71}/wup/assistant_validator.py +0 -0
  53. {wup-0.2.69 → wup-0.2.71}/wup/bus.py +0 -0
  54. {wup-0.2.69 → wup-0.2.71}/wup/cli_bridge.py +0 -0
  55. {wup-0.2.69 → wup-0.2.71}/wup/cli_config_generator.py +0 -0
  56. {wup-0.2.69 → wup-0.2.71}/wup/cli_scanner.py +0 -0
  57. {wup-0.2.69 → wup-0.2.71}/wup/control.py +0 -0
  58. {wup-0.2.69 → wup-0.2.71}/wup/endpoints.py +0 -0
  59. {wup-0.2.69 → wup-0.2.71}/wup/event_store.py +0 -0
  60. {wup-0.2.69 → wup-0.2.71}/wup/file_watcher/events/file_events.py +0 -0
  61. {wup-0.2.69 → wup-0.2.71}/wup/generate.py +0 -0
  62. {wup-0.2.69 → wup-0.2.71}/wup/init_cli.py +0 -0
  63. {wup-0.2.69 → wup-0.2.71}/wup/models/__init__.py +0 -0
  64. {wup-0.2.69 → wup-0.2.71}/wup/models/target.py +0 -0
  65. {wup-0.2.69 → wup-0.2.71}/wup/paths.py +0 -0
  66. {wup-0.2.69 → wup-0.2.71}/wup/sync.py +0 -0
  67. {wup-0.2.69 → wup-0.2.71}/wup/testing/events/health_events.py +0 -0
  68. {wup-0.2.69 → wup-0.2.71}/wup/testing/events/test_results.py +0 -0
  69. {wup-0.2.69 → wup-0.2.71}/wup/testing/handlers/event_handlers.py +0 -0
  70. {wup-0.2.69 → wup-0.2.71}/wup/testing/handlers/health_handlers.py +0 -0
  71. {wup-0.2.69 → wup-0.2.71}/wup/testing/queries/health_queries.py +0 -0
  72. {wup-0.2.69 → wup-0.2.71}/wup/testql_cli_generator.py +0 -0
  73. {wup-0.2.69 → wup-0.2.71}/wup/testql_discovery.py +0 -0
  74. {wup-0.2.69 → wup-0.2.71}/wup/testql_monitor.py +0 -0
  75. {wup-0.2.69 → wup-0.2.71}/wup/validate.py +0 -0
  76. {wup-0.2.69 → wup-0.2.71}/wup/visual_diff.py +0 -0
  77. {wup-0.2.69 → wup-0.2.71}/wup/web_client.py +0 -0
  78. {wup-0.2.69 → wup-0.2.71}/wup.egg-info/dependency_links.txt +0 -0
  79. {wup-0.2.69 → wup-0.2.71}/wup.egg-info/entry_points.txt +0 -0
  80. {wup-0.2.69 → wup-0.2.71}/wup.egg-info/requires.txt +0 -0
  81. {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.69
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.69-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
46
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$5.86-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-51.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
45
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.71-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
46
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.22-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-52.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
47
47
 
48
- - 🤖 **LLM usage:** $5.8610 (97 commits)
49
- - 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
48
+ - 🤖 **LLM usage:** $7.2227 (99 commits)
49
+ - 👤 **Human dev:** ~$5215 (52.1h @ $100/h, 30min dedup)
50
50
 
51
- Generated on 2026-07-16 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.69-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
55
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.71-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
56
56
 
57
57
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
58
58
 
@@ -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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.69-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$5.86-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-51.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.71-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-$7.22-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-52.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $5.8610 (97 commits)
10
- - 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $7.2227 (99 commits)
10
+ - 👤 **Human dev:** ~$5215 (52.1h @ $100/h, 30min dedup)
11
11
 
12
- Generated on 2026-07-16 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.69-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
16
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.71-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
 
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wup"
7
- version = "0.2.69"
7
+ version = "0.2.71"
8
8
  description = "WUP (What's Up) - Intelligent file watcher for regression testing in large projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,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.69"
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
- project: str = typer.Argument(".", help="Path to the project root directory"),
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 project for file changes and run regression tests.
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
- project_path = Path(project).resolve()
280
+ raw_projects = list(projects) if projects else ["."]
281
+ project_paths = _resolve_project_paths(raw_projects, discover)
159
282
 
160
- if not project_path.exists():
161
- console.print(f"[red]Error: Project path '{project}' does not exist[/red]")
283
+ if not project_paths:
284
+ console.print("[red]No projects to watch[/red]")
162
285
  raise typer.Exit(1)
163
286
 
164
- config_path = Path(config) if config else None
165
-
166
- # Auto-generate config if it doesn't exist
167
- if not find_config_file(project_path):
168
- console.print("[cyan]🔍 No wup.yaml found - auto-detecting project type...[/cyan]")
169
- _auto_generate_config(project_path, mode)
170
- console.print("[green]✓ Auto-generated wup.yaml configuration[/green]")
171
- console.print()
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
- watcher = _create_watcher(
182
- mode=mode,
183
- project_path=project_path,
184
- deps_file=deps_file,
185
- cpu_throttle=cpu_throttle,
186
- debounce=debounce,
187
- cooldown=cooldown,
188
- scenarios_dir=effective_scenarios_dir,
189
- testql_bin=testql_bin,
190
- browser_service_url=browser_service_url,
191
- track_dir=track_dir,
192
- quick_limit=quick_limit,
193
- config=wup_config,
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("[green]Starting watcher with live dashboard...[/green]")
198
- asyncio.run(watcher.run_with_dashboard())
199
- else:
200
- console.print("[green]Starting watcher...[/green]")
201
- if watcher.start_watching() is False:
202
- raise typer.Exit(1)
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=["app/**", "src/**", "routes/**"],
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,