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.
Files changed (80) hide show
  1. {wup-0.2.69/wup.egg-info → wup-0.2.70}/PKG-INFO +30 -6
  2. {wup-0.2.69 → wup-0.2.70}/README.md +29 -5
  3. {wup-0.2.69 → wup-0.2.70}/pyproject.toml +1 -1
  4. wup-0.2.70/tests/test_multi_project.py +117 -0
  5. {wup-0.2.69 → wup-0.2.70}/wup/__init__.py +1 -1
  6. {wup-0.2.69 → wup-0.2.70}/wup/cli.py +175 -42
  7. {wup-0.2.69 → wup-0.2.70}/wup/config.py +24 -4
  8. {wup-0.2.69 → wup-0.2.70}/wup/core.py +52 -11
  9. {wup-0.2.69 → wup-0.2.70}/wup/monitoring_manifest.py +44 -36
  10. wup-0.2.70/wup/multi.py +81 -0
  11. {wup-0.2.69 → wup-0.2.70}/wup/planfile_reporter.py +33 -22
  12. {wup-0.2.69 → wup-0.2.70}/wup/status_data.py +27 -16
  13. {wup-0.2.69 → wup-0.2.70}/wup/testql_watcher.py +28 -33
  14. {wup-0.2.69 → wup-0.2.70/wup.egg-info}/PKG-INFO +30 -6
  15. {wup-0.2.69 → wup-0.2.70}/wup.egg-info/SOURCES.txt +2 -0
  16. {wup-0.2.69 → wup-0.2.70}/LICENSE +0 -0
  17. {wup-0.2.69 → wup-0.2.70}/setup.cfg +0 -0
  18. {wup-0.2.69 → wup-0.2.70}/tests/test_assistant.py +0 -0
  19. {wup-0.2.69 → wup-0.2.70}/tests/test_auto_detection.py +0 -0
  20. {wup-0.2.69 → wup-0.2.70}/tests/test_cli_bridge.py +0 -0
  21. {wup-0.2.69 → wup-0.2.70}/tests/test_cli_filtering.py +0 -0
  22. {wup-0.2.69 → wup-0.2.70}/tests/test_control.py +0 -0
  23. {wup-0.2.69 → wup-0.2.70}/tests/test_e2e.py +0 -0
  24. {wup-0.2.69 → wup-0.2.70}/tests/test_endpoints_init_cli.py +0 -0
  25. {wup-0.2.69 → wup-0.2.70}/tests/test_health_summary_passed.py +0 -0
  26. {wup-0.2.69 → wup-0.2.70}/tests/test_monitoring_manifest.py +0 -0
  27. {wup-0.2.69 → wup-0.2.70}/tests/test_planfile_reporter_dedupe.py +0 -0
  28. {wup-0.2.69 → wup-0.2.70}/tests/test_probe_mutex.py +0 -0
  29. {wup-0.2.69 → wup-0.2.70}/tests/test_service_inference.py +0 -0
  30. {wup-0.2.69 → wup-0.2.70}/tests/test_status_data.py +0 -0
  31. {wup-0.2.69 → wup-0.2.70}/tests/test_sync.py +0 -0
  32. {wup-0.2.69 → wup-0.2.70}/tests/test_testql_monitor.py +0 -0
  33. {wup-0.2.69 → wup-0.2.70}/tests/test_testql_watcher.py +0 -0
  34. {wup-0.2.69 → wup-0.2.70}/tests/test_visual_diff_periodic_skip.py +0 -0
  35. {wup-0.2.69 → wup-0.2.70}/tests/test_visual_diff_progress.py +0 -0
  36. {wup-0.2.69 → wup-0.2.70}/tests/test_watch_exclude.py +0 -0
  37. {wup-0.2.69 → wup-0.2.70}/tests/test_watch_no_paths_exit.py +0 -0
  38. {wup-0.2.69 → wup-0.2.70}/tests/test_web_client.py +0 -0
  39. {wup-0.2.69 → wup-0.2.70}/tests/test_wup.py +0 -0
  40. {wup-0.2.69 → wup-0.2.70}/tests/test_wup_generate.py +0 -0
  41. {wup-0.2.69 → wup-0.2.70}/wup/_ast_detector.py +0 -0
  42. {wup-0.2.69 → wup-0.2.70}/wup/_base_detector.py +0 -0
  43. {wup-0.2.69 → wup-0.2.70}/wup/_hash_detector.py +0 -0
  44. {wup-0.2.69 → wup-0.2.70}/wup/_yaml_detector.py +0 -0
  45. {wup-0.2.69 → wup-0.2.70}/wup/anomaly_detector.py +0 -0
  46. {wup-0.2.69 → wup-0.2.70}/wup/anomaly_models.py +0 -0
  47. {wup-0.2.69 → wup-0.2.70}/wup/assistant.py +0 -0
  48. {wup-0.2.69 → wup-0.2.70}/wup/assistant_discovery.py +0 -0
  49. {wup-0.2.69 → wup-0.2.70}/wup/assistant_validator.py +0 -0
  50. {wup-0.2.69 → wup-0.2.70}/wup/bus.py +0 -0
  51. {wup-0.2.69 → wup-0.2.70}/wup/cli_bridge.py +0 -0
  52. {wup-0.2.69 → wup-0.2.70}/wup/cli_config_generator.py +0 -0
  53. {wup-0.2.69 → wup-0.2.70}/wup/cli_scanner.py +0 -0
  54. {wup-0.2.69 → wup-0.2.70}/wup/control.py +0 -0
  55. {wup-0.2.69 → wup-0.2.70}/wup/dependency_mapper.py +0 -0
  56. {wup-0.2.69 → wup-0.2.70}/wup/endpoints.py +0 -0
  57. {wup-0.2.69 → wup-0.2.70}/wup/event_store.py +0 -0
  58. {wup-0.2.69 → wup-0.2.70}/wup/file_watcher/events/file_events.py +0 -0
  59. {wup-0.2.69 → wup-0.2.70}/wup/generate.py +0 -0
  60. {wup-0.2.69 → wup-0.2.70}/wup/init_cli.py +0 -0
  61. {wup-0.2.69 → wup-0.2.70}/wup/models/__init__.py +0 -0
  62. {wup-0.2.69 → wup-0.2.70}/wup/models/config.py +0 -0
  63. {wup-0.2.69 → wup-0.2.70}/wup/models/target.py +0 -0
  64. {wup-0.2.69 → wup-0.2.70}/wup/paths.py +0 -0
  65. {wup-0.2.69 → wup-0.2.70}/wup/sync.py +0 -0
  66. {wup-0.2.69 → wup-0.2.70}/wup/testing/events/health_events.py +0 -0
  67. {wup-0.2.69 → wup-0.2.70}/wup/testing/events/test_results.py +0 -0
  68. {wup-0.2.69 → wup-0.2.70}/wup/testing/handlers/event_handlers.py +0 -0
  69. {wup-0.2.69 → wup-0.2.70}/wup/testing/handlers/health_handlers.py +0 -0
  70. {wup-0.2.69 → wup-0.2.70}/wup/testing/queries/health_queries.py +0 -0
  71. {wup-0.2.69 → wup-0.2.70}/wup/testql_cli_generator.py +0 -0
  72. {wup-0.2.69 → wup-0.2.70}/wup/testql_discovery.py +0 -0
  73. {wup-0.2.69 → wup-0.2.70}/wup/testql_monitor.py +0 -0
  74. {wup-0.2.69 → wup-0.2.70}/wup/validate.py +0 -0
  75. {wup-0.2.69 → wup-0.2.70}/wup/visual_diff.py +0 -0
  76. {wup-0.2.69 → wup-0.2.70}/wup/web_client.py +0 -0
  77. {wup-0.2.69 → wup-0.2.70}/wup.egg-info/dependency_links.txt +0 -0
  78. {wup-0.2.69 → wup-0.2.70}/wup.egg-info/entry_points.txt +0 -0
  79. {wup-0.2.69 → wup-0.2.70}/wup.egg-info/requires.txt +0 -0
  80. {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.69
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
- ![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.70-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-$6.52-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-51.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
47
47
 
48
- - 🤖 **LLM usage:** $5.8610 (97 commits)
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-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.70-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.70-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-$6.52-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-51.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $5.8610 (97 commits)
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-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.70-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.70"
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,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.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
- 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):
@@ -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=["app/**", "src/**", "routes/**"],
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 start_watching(self, watch_paths: Optional[List[str]] = None) -> bool:
569
+ def start_background_tasks(self) -> None:
570
570
  """
571
- Start watching for file changes.
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 source directories)
588
+ watch_paths: List of paths to watch (default: from config or common
589
+ source directories)
575
590
 
576
591
  Returns:
577
- False when no valid paths could be watched (configuration error —
578
- CLI turns this into a non-zero exit so supervisors notice instead
579
- of treating a watcher that never watched as a clean run),
580
- True after a graceful interrupt.
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("[red]No valid paths to watch[/red]")
590
- return False
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(f"[green]🕵️ Watching: {', '.join(watch_paths)}[/green]")
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