wup 0.2.76__tar.gz → 0.2.78__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 (91) hide show
  1. {wup-0.2.76/wup.egg-info → wup-0.2.78}/PKG-INFO +12 -7
  2. {wup-0.2.76 → wup-0.2.78}/README.md +6 -6
  3. {wup-0.2.76 → wup-0.2.78}/pyproject.toml +18 -2
  4. {wup-0.2.76 → wup-0.2.78}/tests/test_assistant.py +0 -1
  5. {wup-0.2.76 → wup-0.2.78}/tests/test_auto_detection.py +1 -1
  6. wup-0.2.78/tests/test_bootstrap.py +31 -0
  7. {wup-0.2.76 → wup-0.2.78}/tests/test_cli_filtering.py +5 -5
  8. {wup-0.2.76 → wup-0.2.78}/tests/test_e2e.py +1 -3
  9. {wup-0.2.76 → wup-0.2.78}/tests/test_monitoring_manifest.py +0 -1
  10. {wup-0.2.76 → wup-0.2.78}/tests/test_multi_project.py +38 -1
  11. {wup-0.2.76 → wup-0.2.78}/tests/test_service_inference.py +0 -2
  12. {wup-0.2.76 → wup-0.2.78}/tests/test_testql_monitor.py +0 -3
  13. {wup-0.2.76 → wup-0.2.78}/tests/test_visual_diff_progress.py +0 -1
  14. {wup-0.2.76 → wup-0.2.78}/tests/test_wup.py +38 -0
  15. {wup-0.2.76 → wup-0.2.78}/wup/__init__.py +1 -1
  16. {wup-0.2.76 → wup-0.2.78}/wup/aql.py +36 -34
  17. {wup-0.2.76 → wup-0.2.78}/wup/assistant.py +4 -14
  18. wup-0.2.78/wup/bootstrap.py +61 -0
  19. {wup-0.2.76 → wup-0.2.78}/wup/cli.py +9 -9
  20. wup-0.2.78/wup/cli_bridge.py +194 -0
  21. {wup-0.2.76 → wup-0.2.78}/wup/cli_config_generator.py +2 -2
  22. {wup-0.2.76 → wup-0.2.78}/wup/cli_scanner.py +1 -2
  23. {wup-0.2.76 → wup-0.2.78}/wup/config.py +178 -203
  24. {wup-0.2.76 → wup-0.2.78}/wup/control.py +28 -7
  25. {wup-0.2.76 → wup-0.2.78}/wup/core.py +2 -2
  26. {wup-0.2.76 → wup-0.2.78}/wup/testing/events/test_results.py +0 -1
  27. {wup-0.2.76 → wup-0.2.78}/wup/testql_cli_generator.py +1 -1
  28. {wup-0.2.76 → wup-0.2.78}/wup/testql_discovery.py +1 -1
  29. {wup-0.2.76 → wup-0.2.78}/wup/testql_watcher.py +2 -2
  30. {wup-0.2.76 → wup-0.2.78/wup.egg-info}/PKG-INFO +12 -7
  31. {wup-0.2.76 → wup-0.2.78}/wup.egg-info/SOURCES.txt +2 -0
  32. wup-0.2.78/wup.egg-info/entry_points.txt +2 -0
  33. {wup-0.2.76 → wup-0.2.78}/wup.egg-info/requires.txt +6 -0
  34. wup-0.2.76/wup/cli_bridge.py +0 -80
  35. wup-0.2.76/wup.egg-info/entry_points.txt +0 -2
  36. {wup-0.2.76 → wup-0.2.78}/LICENSE +0 -0
  37. {wup-0.2.76 → wup-0.2.78}/setup.cfg +0 -0
  38. {wup-0.2.76 → wup-0.2.78}/tests/test_aql.py +0 -0
  39. {wup-0.2.76 → wup-0.2.78}/tests/test_cli_bridge.py +0 -0
  40. {wup-0.2.76 → wup-0.2.78}/tests/test_control.py +0 -0
  41. {wup-0.2.76 → wup-0.2.78}/tests/test_discovery_adapters.py +0 -0
  42. {wup-0.2.76 → wup-0.2.78}/tests/test_endpoints_init_cli.py +0 -0
  43. {wup-0.2.76 → wup-0.2.78}/tests/test_genericity.py +0 -0
  44. {wup-0.2.76 → wup-0.2.78}/tests/test_health_summary_passed.py +0 -0
  45. {wup-0.2.76 → wup-0.2.78}/tests/test_oql.py +0 -0
  46. {wup-0.2.76 → wup-0.2.78}/tests/test_planfile_reporter_dedupe.py +0 -0
  47. {wup-0.2.76 → wup-0.2.78}/tests/test_probe_mutex.py +0 -0
  48. {wup-0.2.76 → wup-0.2.78}/tests/test_status_data.py +0 -0
  49. {wup-0.2.76 → wup-0.2.78}/tests/test_sync.py +0 -0
  50. {wup-0.2.76 → wup-0.2.78}/tests/test_testql_watcher.py +0 -0
  51. {wup-0.2.76 → wup-0.2.78}/tests/test_visual_diff_periodic_skip.py +0 -0
  52. {wup-0.2.76 → wup-0.2.78}/tests/test_watch_exclude.py +0 -0
  53. {wup-0.2.76 → wup-0.2.78}/tests/test_watch_no_paths_exit.py +0 -0
  54. {wup-0.2.76 → wup-0.2.78}/tests/test_web_client.py +0 -0
  55. {wup-0.2.76 → wup-0.2.78}/tests/test_wup_generate.py +0 -0
  56. {wup-0.2.76 → wup-0.2.78}/wup/_ast_detector.py +0 -0
  57. {wup-0.2.76 → wup-0.2.78}/wup/_base_detector.py +0 -0
  58. {wup-0.2.76 → wup-0.2.78}/wup/_hash_detector.py +0 -0
  59. {wup-0.2.76 → wup-0.2.78}/wup/_yaml_detector.py +0 -0
  60. {wup-0.2.76 → wup-0.2.78}/wup/anomaly_detector.py +0 -0
  61. {wup-0.2.76 → wup-0.2.78}/wup/anomaly_models.py +0 -0
  62. {wup-0.2.76 → wup-0.2.78}/wup/assistant_discovery.py +0 -0
  63. {wup-0.2.76 → wup-0.2.78}/wup/assistant_validator.py +0 -0
  64. {wup-0.2.76 → wup-0.2.78}/wup/bus.py +0 -0
  65. {wup-0.2.76 → wup-0.2.78}/wup/dependency_mapper.py +0 -0
  66. {wup-0.2.76 → wup-0.2.78}/wup/discovery.py +0 -0
  67. {wup-0.2.76 → wup-0.2.78}/wup/endpoints.py +0 -0
  68. {wup-0.2.76 → wup-0.2.78}/wup/event_store.py +0 -0
  69. {wup-0.2.76 → wup-0.2.78}/wup/file_watcher/events/file_events.py +0 -0
  70. {wup-0.2.76 → wup-0.2.78}/wup/generate.py +0 -0
  71. {wup-0.2.76 → wup-0.2.78}/wup/init_cli.py +0 -0
  72. {wup-0.2.76 → wup-0.2.78}/wup/models/__init__.py +0 -0
  73. {wup-0.2.76 → wup-0.2.78}/wup/models/config.py +0 -0
  74. {wup-0.2.76 → wup-0.2.78}/wup/models/target.py +0 -0
  75. {wup-0.2.76 → wup-0.2.78}/wup/monitoring_manifest.py +0 -0
  76. {wup-0.2.76 → wup-0.2.78}/wup/multi.py +0 -0
  77. {wup-0.2.76 → wup-0.2.78}/wup/oql.py +0 -0
  78. {wup-0.2.76 → wup-0.2.78}/wup/paths.py +0 -0
  79. {wup-0.2.76 → wup-0.2.78}/wup/planfile_reporter.py +0 -0
  80. {wup-0.2.76 → wup-0.2.78}/wup/status_data.py +0 -0
  81. {wup-0.2.76 → wup-0.2.78}/wup/sync.py +0 -0
  82. {wup-0.2.76 → wup-0.2.78}/wup/testing/events/health_events.py +0 -0
  83. {wup-0.2.76 → wup-0.2.78}/wup/testing/handlers/event_handlers.py +0 -0
  84. {wup-0.2.76 → wup-0.2.78}/wup/testing/handlers/health_handlers.py +0 -0
  85. {wup-0.2.76 → wup-0.2.78}/wup/testing/queries/health_queries.py +0 -0
  86. {wup-0.2.76 → wup-0.2.78}/wup/testql_monitor.py +0 -0
  87. {wup-0.2.76 → wup-0.2.78}/wup/validate.py +0 -0
  88. {wup-0.2.76 → wup-0.2.78}/wup/visual_diff.py +0 -0
  89. {wup-0.2.76 → wup-0.2.78}/wup/web_client.py +0 -0
  90. {wup-0.2.76 → wup-0.2.78}/wup.egg-info/dependency_links.txt +0 -0
  91. {wup-0.2.76 → wup-0.2.78}/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.76
3
+ Version: 0.2.78
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
@@ -23,6 +23,8 @@ Requires-Dist: typer>=0.9.0
23
23
  Requires-Dist: pyyaml>=6.0
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: pytest>=7.0.0; extra == "dev"
26
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
27
+ Requires-Dist: ruff>=0.8; extra == "dev"
26
28
  Requires-Dist: goal>=2.1.0; extra == "dev"
27
29
  Requires-Dist: costs>=0.1.20; extra == "dev"
28
30
  Requires-Dist: pfix>=0.1.60; extra == "dev"
@@ -35,6 +37,9 @@ Requires-Dist: rest2wup; extra == "dev"
35
37
  Requires-Dist: httpx>=0.27; extra == "dev"
36
38
  Provides-Extra: visual
37
39
  Requires-Dist: playwright<2,>=1.40; extra == "visual"
40
+ Provides-Extra: control
41
+ Requires-Dist: dsl2wup>=0.1.0; extra == "control"
42
+ Requires-Dist: uri2wup>=0.1.0; extra == "control"
38
43
  Dynamic: license-file
39
44
 
40
45
  # WUP (What's Up)
@@ -42,17 +47,17 @@ Dynamic: license-file
42
47
 
43
48
  ## AI Cost Tracking
44
49
 
45
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.76-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.77-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-53.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
50
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.78-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
51
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.12-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-54.5h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
47
52
 
48
- - 🤖 **LLM usage:** $6.7652 (105 commits)
49
- - 👤 **Human dev:** ~$5295 (53.0h @ $100/h, 30min dedup)
53
+ - 🤖 **LLM usage:** $7.1247 (108 commits)
54
+ - 👤 **Human dev:** ~$5451 (54.5h @ $100/h, 30min dedup)
50
55
 
51
- Generated on 2026-07-17 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
56
+ Generated on 2026-07-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
52
57
 
53
58
  ---
54
59
 
55
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.76-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
60
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.78-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
56
61
 
57
62
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
58
63
 
@@ -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.76-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.77-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-53.0h-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.78-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.12-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-54.5h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $6.7652 (105 commits)
10
- - 👤 **Human dev:** ~$5295 (53.0h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $7.1247 (108 commits)
10
+ - 👤 **Human dev:** ~$5451 (54.5h @ $100/h, 30min dedup)
11
11
 
12
- Generated on 2026-07-17 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
12
+ Generated on 2026-07-29 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.76-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.78-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
17
17
 
18
18
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
19
19
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wup"
7
- version = "0.2.76"
7
+ version = "0.2.78"
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"
@@ -32,6 +32,8 @@ license = "Apache-2.0"
32
32
  [project.optional-dependencies]
33
33
  dev = [
34
34
  "pytest>=7.0.0",
35
+ "pytest-cov>=5.0",
36
+ "ruff>=0.8",
35
37
  "goal>=2.1.0",
36
38
  "costs>=0.1.20",
37
39
  "pfix>=0.1.60",
@@ -46,10 +48,16 @@ dev = [
46
48
  visual = [
47
49
  "playwright>=1.40,<2",
48
50
  ]
51
+ control = [
52
+ "dsl2wup>=0.1.0",
53
+ "uri2wup>=0.1.0",
54
+ ]
49
55
 
50
56
  [dependency-groups]
51
57
  dev = [
52
58
  "pytest>=7.0.0",
59
+ "pytest-cov>=5.0",
60
+ "ruff>=0.8",
53
61
  "goal>=2.1.0",
54
62
  "costs>=0.1.20",
55
63
  "pfix>=0.1.60",
@@ -87,11 +95,19 @@ testpaths = ["tests", "packages"]
87
95
  python_files = ["test_*.py"]
88
96
  addopts = "-ra --tb=short"
89
97
 
98
+ [tool.ruff.lint]
99
+ # Start with Ruff's stable Pyflakes/pycodestyle correctness baseline. Broader
100
+ # modernization and complexity families can be enabled incrementally.
101
+ select = ["E4", "E7", "E9", "F"]
102
+
103
+ [tool.ruff.lint.per-file-ignores]
104
+ "packages/dsl2wup/src/dsl2wup/v1/*_pb2.py" = ["E402", "F401"]
105
+
90
106
  [tool.setuptools.packages.find]
91
107
  include = ["wup*"]
92
108
 
93
109
  [project.scripts]
94
- wup = "wup.cli:app"
110
+ wup = "wup.bootstrap:main"
95
111
 
96
112
  [project.urls]
97
113
  Homepage = "https://github.com/semcod/wup"
@@ -1,7 +1,6 @@
1
1
  """Unit tests for WupAssistant."""
2
2
  import tempfile
3
3
  from pathlib import Path
4
- import pytest
5
4
 
6
5
  from wup.assistant import WupAssistant
7
6
  from wup.models.config import WupConfig, ProjectConfig, ServiceConfig
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
 
5
5
  from wup.cli_scanner import CLIScanner
6
6
  from wup.cli_config_generator import CLIConfigGenerator
7
- from wup.config import save_config, load_config
7
+ from wup.config import load_config
8
8
 
9
9
 
10
10
  def test_cli_scanner_detects_from_pyproject_toml():
@@ -0,0 +1,31 @@
1
+ """Tests for the crash-safe console bootstrap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import Mock
6
+
7
+ from wup import bootstrap
8
+
9
+
10
+ def test_watchdog_preflight_reports_signal(monkeypatch) -> None:
11
+ monkeypatch.setattr(
12
+ bootstrap.subprocess,
13
+ "run",
14
+ Mock(return_value=Mock(returncode=-11, stderr="")),
15
+ )
16
+
17
+ ok, detail = bootstrap._watchdog_preflight()
18
+
19
+ assert ok is False
20
+ assert "signal 11" in detail
21
+
22
+
23
+ def test_main_stops_before_importing_cli_on_failed_watch_preflight(
24
+ monkeypatch, capsys
25
+ ) -> None:
26
+ monkeypatch.setattr(bootstrap, "_watchdog_preflight", lambda: (False, "signal 11"))
27
+
28
+ code = bootstrap.main(["watch", "."])
29
+
30
+ assert code == 70
31
+ assert "ABI preflight" in capsys.readouterr().err
@@ -258,8 +258,8 @@ def test_scenario_matches_type():
258
258
  )
259
259
 
260
260
  # Test type matching
261
- assert watcher._scenario_matches_type(cli_scenario, "shell") == True
262
- assert watcher._scenario_matches_type(cli_scenario, "web") == False
263
- assert watcher._scenario_matches_type(web_scenario, "shell") == False
264
- assert watcher._scenario_matches_type(web_scenario, "web") == True
265
- assert watcher._scenario_matches_type(cli_scenario, "auto") == True
261
+ assert watcher._scenario_matches_type(cli_scenario, "shell") is True
262
+ assert watcher._scenario_matches_type(cli_scenario, "web") is False
263
+ assert watcher._scenario_matches_type(web_scenario, "shell") is False
264
+ assert watcher._scenario_matches_type(web_scenario, "web") is True
265
+ assert watcher._scenario_matches_type(cli_scenario, "auto") is True
@@ -7,9 +7,7 @@ import sys
7
7
  import tempfile
8
8
  import time
9
9
  from pathlib import Path
10
- from typing import List
11
10
 
12
- import pytest
13
11
 
14
12
 
15
13
  def run_wup_command(args, cwd=None, timeout=30, capture_output=True, text=True):
@@ -115,7 +113,7 @@ def get_users():
115
113
  "files": {"app/users/routes.py": ["/users"]}
116
114
  }))
117
115
 
118
- result = run_wup_command(
116
+ run_wup_command(
119
117
  [sys.executable, "-m", "wup.cli", "status", "--deps", str(deps_file)],
120
118
  cwd=tmpdir,
121
119
  capture_output=True,
@@ -1,7 +1,6 @@
1
1
  import tempfile
2
2
  from pathlib import Path
3
3
 
4
- import yaml
5
4
 
6
5
  from wup.models.config import ProjectConfig, ServiceConfig, TestQLConfig, WupConfig, WatchConfig
7
6
  from wup.monitoring_manifest import (
@@ -2,10 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  from pathlib import Path
6
7
 
7
8
  from wup.cli import _discover_projects, _resolve_project_paths
8
- from wup.config import detect_watch_paths, get_default_config
9
+ from wup.config import detect_watch_paths, get_default_config, load_config
9
10
  from wup.core import WupWatcher
10
11
  from wup.models.config import ProjectConfig, WatchConfig, WupConfig
11
12
  from wup.multi import MultiProjectWatcher
@@ -48,12 +49,48 @@ def test_detect_watch_paths_falls_back_when_nothing_matches(tmp_path: Path) -> N
48
49
  assert detect_watch_paths(tmp_path) == ["app/**", "src/**", "routes/**"]
49
50
 
50
51
 
52
+ def test_detect_watch_paths_backend_frontend(tmp_path: Path) -> None:
53
+ # c2004-style module layout (backend/ + frontend/, no app/src).
54
+ (tmp_path / "backend").mkdir()
55
+ (tmp_path / "frontend").mkdir()
56
+ paths = detect_watch_paths(tmp_path)
57
+ assert "backend/**" in paths and "frontend/**" in paths
58
+ assert "app/**" not in paths
59
+
60
+
51
61
  def test_default_config_watches_only_real_dirs(tmp_path: Path) -> None:
52
62
  (tmp_path / "services").mkdir()
53
63
  cfg = get_default_config(tmp_path)
54
64
  assert cfg.watch.paths == ["services/**"]
55
65
 
56
66
 
67
+ def test_project_dotenv_is_resolved_without_cross_project_leakage(
68
+ tmp_path: Path, monkeypatch
69
+ ) -> None:
70
+ monkeypatch.delenv("WUP_BASE_URL", raising=False)
71
+ monkeypatch.delenv("WUPBRO_ENDPOINT", raising=False)
72
+ alpha = _make_project(tmp_path, "alpha")
73
+ beta = _make_project(tmp_path, "beta")
74
+ (alpha / ".wup.env").write_text(
75
+ "WUP_BASE_URL=http://alpha.invalid\nWUPBRO_ENDPOINT=http://alpha-web.invalid\n",
76
+ encoding="utf-8",
77
+ )
78
+ (beta / ".wup.env").write_text(
79
+ "WUP_BASE_URL=http://beta.invalid\nWUPBRO_ENDPOINT=http://beta-web.invalid\n",
80
+ encoding="utf-8",
81
+ )
82
+
83
+ alpha_cfg = load_config(alpha)
84
+ beta_cfg = load_config(beta)
85
+
86
+ assert alpha_cfg.testql.base_url == "http://alpha.invalid"
87
+ assert beta_cfg.testql.base_url == "http://beta.invalid"
88
+ assert alpha_cfg.web.endpoint == "http://alpha-web.invalid"
89
+ assert beta_cfg.web.endpoint == "http://beta-web.invalid"
90
+ assert "WUP_BASE_URL" not in os.environ
91
+ assert "WUPBRO_ENDPOINT" not in os.environ
92
+
93
+
57
94
  def test_discover_finds_subprojects(tmp_path: Path) -> None:
58
95
  _make_project(tmp_path, "alpha")
59
96
  _make_project(tmp_path, "beta")
@@ -1,7 +1,6 @@
1
1
  """Unit tests for service inference logic."""
2
2
  import tempfile
3
3
  from pathlib import Path
4
- from unittest.mock import Mock
5
4
 
6
5
  from wup.core import WupWatcher
7
6
  from wup.models.config import (
@@ -201,7 +200,6 @@ def test_file_change_uses_configured_services_when_inference_fails():
201
200
 
202
201
  # Mock schedule_quick_test to track which services are tested
203
202
  tested_services = []
204
- original_schedule = watcher.schedule_quick_test
205
203
  watcher.schedule_quick_test = lambda s: tested_services.append(s)
206
204
 
207
205
  # Trigger file change
@@ -11,7 +11,6 @@ from wup.testql_monitor import (
11
11
  assign_probe_to_service,
12
12
  is_monitoring_probe,
13
13
  parse_scenario_probes,
14
- parse_service_map_probes,
15
14
  )
16
15
  from wup.testql_watcher import TestQLWatcher
17
16
 
@@ -206,8 +205,6 @@ def test_live_probe_failure_updates_health():
206
205
  config=cfg,
207
206
  )
208
207
 
209
- failing = ProbeTarget(url="http://localhost:8100/firmware/api/v1/health")
210
-
211
208
  def fake_probe(self, timeout_s=10.0):
212
209
  return False, "HTTP 500 (expected 200)"
213
210
 
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import io
6
- import os
7
6
 
8
7
  from rich.console import Console
9
8
 
@@ -23,6 +23,7 @@ from wup.models.config import (
23
23
  ProjectConfig,
24
24
  PlanfileConfig,
25
25
  VisualDiffConfig,
26
+ AnomalyDetectionConfig,
26
27
  )
27
28
  from wup.testql_watcher import TestQLWatcher
28
29
  from wup.visual_diff import (
@@ -1313,6 +1314,43 @@ class TestConfigLoader:
1313
1314
  assert loaded_config.project.name == "test-project"
1314
1315
  assert len(loaded_config.services) == 1
1315
1316
  assert loaded_config.services[0].name == "users"
1317
+
1318
+ def test_save_and_load_preserves_every_non_default_section(self):
1319
+ """Fields added to the dataclass schema must not disappear on save."""
1320
+ with tempfile.TemporaryDirectory() as tmpdir:
1321
+ config = WupConfig(
1322
+ project=ProjectConfig(name="roundtrip", description="all fields"),
1323
+ watch=WatchConfig(
1324
+ paths=["src/**"],
1325
+ exclude_patterns=["dist/**"],
1326
+ file_types=[".py", ".ts"],
1327
+ ),
1328
+ services=[ServiceConfig(
1329
+ name="worker",
1330
+ type="shell",
1331
+ cpu_throttle=0.25,
1332
+ notify=NotifyConfig(type="http", url="http://notify.invalid"),
1333
+ )],
1334
+ testql=TestQLConfig(quick_smoke_only=True),
1335
+ visual_diff=VisualDiffConfig(run_on_periodic_probe=True),
1336
+ anomaly_detection=AnomalyDetectionConfig(
1337
+ enabled=False,
1338
+ methods=["ast"],
1339
+ strict_mode=True,
1340
+ watch_paths=["schemas/**"],
1341
+ severity_threshold="high",
1342
+ ),
1343
+ )
1344
+ config_path = Path(tmpdir) / "wup.yaml"
1345
+
1346
+ save_config(config, config_path)
1347
+ loaded = load_config(Path(tmpdir), config_path)
1348
+
1349
+ assert loaded.watch.file_types == [".py", ".ts"]
1350
+ assert loaded.services[0].type == "shell"
1351
+ assert loaded.testql.quick_smoke_only is True
1352
+ assert loaded.visual_diff.run_on_periodic_probe is True
1353
+ assert loaded.anomaly_detection == config.anomaly_detection
1316
1354
 
1317
1355
  def test_load_config_from_yaml(self):
1318
1356
  """Test loading configuration from YAML file."""
@@ -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.76"
10
+ __version__ = "0.2.78"
11
11
  __author__ = "Tom Sapletta"
12
12
 
13
13
  from .config import load_config, save_config, get_default_config
@@ -130,51 +130,53 @@ def _tokenize(rule: str) -> List[str]:
130
130
  return [placeholder.get(t, t) for t in tokens]
131
131
 
132
132
 
133
+ def _rule_selector(tokens: List[str]) -> Tuple[str, str]:
134
+ selector = tokens.pop(0).lower()
135
+ if selector not in _SELECTORS:
136
+ raise AQLError(f"unknown selector {selector!r}; expected one of {', '.join(_SELECTORS)}")
137
+ if selector == "text":
138
+ return selector, ""
139
+ if not tokens:
140
+ raise AQLError(f"selector {selector!r} requires a path")
141
+ return selector, tokens.pop(0)
142
+
143
+
144
+ def _predicate_rule(selector: str, path: str, keyword: str, tokens: List[str], severity: str, raw: str) -> AQLRule:
145
+ lower = keyword.lower()
146
+ if lower in ("exists", "missing"):
147
+ return AQLRule(selector, path, lower, severity=severity, raw=raw)
148
+ if lower == "matches":
149
+ if tokens:
150
+ return AQLRule(selector, path, "matches", tokens.pop(0), severity=severity, raw=raw)
151
+ raise AQLError("'matches' requires a regex")
152
+ if lower == "type":
153
+ if tokens and tokens[0].lower() in _TYPE_NAMES:
154
+ return AQLRule(selector, path, "type", tokens.pop(0).lower(), severity=severity, raw=raw)
155
+ raise AQLError(f"'type' requires one of {', '.join(sorted(_TYPE_NAMES))}")
156
+ if lower == "length":
157
+ if len(tokens) >= 2 and tokens[0] in _COMPARATORS and tokens[1].lstrip("-").isdigit():
158
+ return AQLRule(selector, path, "length", tokens[1], length_op=tokens[0], severity=severity, raw=raw)
159
+ raise AQLError("'length' requires an operator and number, e.g. length > 0")
160
+ if keyword in _COMPARATORS and tokens:
161
+ return AQLRule(selector, path, keyword, tokens.pop(0), severity=severity, raw=raw)
162
+ if keyword in _COMPARATORS:
163
+ raise AQLError(f"operator {keyword!r} requires a value")
164
+ raise AQLError(f"unknown predicate {keyword!r}")
165
+
166
+
133
167
  def parse_rule(rule: str) -> AQLRule:
134
168
  """Parse a single AQL rule string into an :class:`AQLRule`."""
135
169
  tokens, severity = _split_severity(_tokenize(rule))
136
170
  if not tokens:
137
171
  raise AQLError("empty rule")
138
172
 
139
- selector = tokens.pop(0).lower()
140
- if selector not in _SELECTORS:
141
- raise AQLError(f"unknown selector {selector!r}; expected one of {', '.join(_SELECTORS)}")
142
-
143
- path = ""
144
- if selector != "text":
145
- if not tokens:
146
- raise AQLError(f"selector {selector!r} requires a path")
147
- path = tokens.pop(0)
173
+ selector, path = _rule_selector(tokens)
148
174
 
149
175
  if not tokens:
150
176
  raise AQLError("rule requires a predicate (exists, =, length, type, …)")
151
177
 
152
178
  keyword = tokens.pop(0)
153
- kw_lower = keyword.lower()
154
-
155
- if kw_lower in ("exists", "missing"):
156
- return AQLRule(selector, path, kw_lower, severity=severity, raw=rule)
157
- if kw_lower == "matches":
158
- if not tokens:
159
- raise AQLError("'matches' requires a regex")
160
- return AQLRule(selector, path, "matches", tokens.pop(0), severity=severity, raw=rule)
161
- if kw_lower == "type":
162
- if not tokens or tokens[0].lower() not in _TYPE_NAMES:
163
- raise AQLError(f"'type' requires one of {', '.join(sorted(_TYPE_NAMES))}")
164
- return AQLRule(selector, path, "type", tokens.pop(0).lower(), severity=severity, raw=rule)
165
- if kw_lower == "length":
166
- if len(tokens) < 2 or tokens[0] not in _COMPARATORS:
167
- raise AQLError("'length' requires an operator and number, e.g. length > 0")
168
- length_op, number = tokens[0], tokens[1]
169
- if not number.lstrip("-").isdigit():
170
- raise AQLError(f"'length' expects an integer, got {number!r}")
171
- return AQLRule(selector, path, "length", number, length_op=length_op, severity=severity, raw=rule)
172
- if keyword in _COMPARATORS:
173
- if not tokens:
174
- raise AQLError(f"operator {keyword!r} requires a value")
175
- return AQLRule(selector, path, keyword, tokens.pop(0), severity=severity, raw=rule)
176
-
177
- raise AQLError(f"unknown predicate {keyword!r}")
179
+ return _predicate_rule(selector, path, keyword, tokens, severity, rule)
178
180
 
179
181
 
180
182
  # --- evaluation -----------------------------------------------------------
@@ -11,12 +11,9 @@ Usage:
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- import json
15
- import re
16
- import sys
17
- from dataclasses import asdict, field
14
+ from dataclasses import asdict
18
15
  from pathlib import Path
19
- from typing import Any, Dict, List, Optional, Tuple
16
+ from typing import Dict, List, Optional
20
17
 
21
18
  import yaml
22
19
 
@@ -24,19 +21,12 @@ from rich.console import Console
24
21
  from rich.panel import Panel
25
22
  from rich.prompt import Confirm, IntPrompt, Prompt
26
23
  from rich.table import Table
27
- from rich.tree import Tree
28
24
 
29
25
  from .models.config import (
30
26
  AnomalyDetectionConfig,
31
- NotifyConfig,
32
27
  ProjectConfig,
33
28
  ServiceConfig,
34
29
  ServiceType,
35
- TestQLConfig,
36
- TestStrategyConfig,
37
- VisualDiffConfig,
38
- WatchConfig,
39
- WebConfig,
40
30
  WupConfig,
41
31
  )
42
32
  from .assistant_discovery import (
@@ -52,7 +42,7 @@ from .assistant_validator import (
52
42
 
53
43
  # Import ServiceType for type checking
54
44
  if False: # TYPE_CHECKING
55
- from .models.config import ServiceType as ServiceTypeLiteral
45
+ pass
56
46
 
57
47
  console = Console()
58
48
 
@@ -513,7 +503,7 @@ class WupAssistant:
513
503
  yaml.dump(config_dict, default_flow_style=False, sort_keys=False, allow_unicode=True)
514
504
  )
515
505
 
516
- console.print(f"\n[bold green]✓ Configuration saved to wup.yaml[/bold green]")
506
+ console.print("\n[bold green]✓ Configuration saved to wup.yaml[/bold green]")
517
507
 
518
508
  # Clean up draft
519
509
  if self.draft_path.exists():
@@ -0,0 +1,61 @@
1
+ """Minimal console bootstrap with a crash-safe watcher dependency preflight."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from collections.abc import Sequence
9
+
10
+ _WATCH_PREFLIGHT = """
11
+ try:
12
+ import resource
13
+ resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
14
+ except (ImportError, OSError, ValueError):
15
+ pass
16
+ import ctypes
17
+ from watchdog.observers import Observer
18
+ assert Observer
19
+ """
20
+
21
+
22
+ def _watchdog_preflight() -> tuple[bool, str]:
23
+ """Probe native/stdlib imports in a child so a segfault is reportable."""
24
+ try:
25
+ completed = subprocess.run(
26
+ [sys.executable, "-c", _WATCH_PREFLIGHT],
27
+ stdin=subprocess.DEVNULL,
28
+ stdout=subprocess.DEVNULL,
29
+ stderr=subprocess.PIPE,
30
+ text=True,
31
+ timeout=15,
32
+ check=False,
33
+ )
34
+ except (OSError, subprocess.TimeoutExpired) as exc:
35
+ return False, str(exc)
36
+ if completed.returncode == 0:
37
+ return True, ""
38
+ if completed.returncode < 0:
39
+ detail = f"child terminated by signal {-completed.returncode}"
40
+ else:
41
+ detail = completed.stderr.strip() or f"child exited with {completed.returncode}"
42
+ return False, detail
43
+
44
+
45
+ def main(argv: Sequence[str] | None = None) -> int | None:
46
+ args = list(sys.argv[1:] if argv is None else argv)
47
+ if args and args[0] == "watch" and not os.environ.get("WUP_SKIP_RUNTIME_PREFLIGHT"):
48
+ ok, detail = _watchdog_preflight()
49
+ if not ok:
50
+ print(
51
+ "WUP cannot start the file watcher because this Python environment "
52
+ "failed its ctypes/watchdog ABI preflight. Recreate the virtualenv "
53
+ f"with one interpreter ({sys.executable}). Details: {detail}",
54
+ file=sys.stderr,
55
+ )
56
+ return 70
57
+
58
+ from .cli import app
59
+
60
+ app(args=args, prog_name="wup")
61
+ return None