wup 0.2.77__tar.gz → 0.2.79__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.77/wup.egg-info → wup-0.2.79}/PKG-INFO +12 -7
  2. {wup-0.2.77 → wup-0.2.79}/README.md +6 -6
  3. {wup-0.2.77 → wup-0.2.79}/pyproject.toml +18 -2
  4. {wup-0.2.77 → wup-0.2.79}/tests/test_assistant.py +0 -1
  5. {wup-0.2.77 → wup-0.2.79}/tests/test_auto_detection.py +1 -1
  6. wup-0.2.79/tests/test_bootstrap.py +31 -0
  7. {wup-0.2.77 → wup-0.2.79}/tests/test_cli_filtering.py +5 -5
  8. {wup-0.2.77 → wup-0.2.79}/tests/test_e2e.py +1 -3
  9. {wup-0.2.77 → wup-0.2.79}/tests/test_monitoring_manifest.py +0 -1
  10. {wup-0.2.77 → wup-0.2.79}/tests/test_multi_project.py +29 -1
  11. {wup-0.2.77 → wup-0.2.79}/tests/test_service_inference.py +0 -2
  12. {wup-0.2.77 → wup-0.2.79}/tests/test_testql_monitor.py +0 -3
  13. {wup-0.2.77 → wup-0.2.79}/tests/test_visual_diff_progress.py +0 -1
  14. {wup-0.2.77 → wup-0.2.79}/tests/test_wup.py +38 -0
  15. {wup-0.2.77 → wup-0.2.79}/wup/__init__.py +1 -1
  16. {wup-0.2.77 → wup-0.2.79}/wup/aql.py +112 -72
  17. {wup-0.2.77 → wup-0.2.79}/wup/assistant.py +4 -14
  18. wup-0.2.79/wup/bootstrap.py +61 -0
  19. {wup-0.2.77 → wup-0.2.79}/wup/cli.py +9 -9
  20. wup-0.2.79/wup/cli_bridge.py +194 -0
  21. {wup-0.2.77 → wup-0.2.79}/wup/cli_config_generator.py +2 -2
  22. {wup-0.2.77 → wup-0.2.79}/wup/cli_scanner.py +1 -2
  23. {wup-0.2.77 → wup-0.2.79}/wup/config.py +178 -204
  24. {wup-0.2.77 → wup-0.2.79}/wup/control.py +28 -7
  25. {wup-0.2.77 → wup-0.2.79}/wup/core.py +2 -2
  26. {wup-0.2.77 → wup-0.2.79}/wup/testing/events/test_results.py +0 -1
  27. {wup-0.2.77 → wup-0.2.79}/wup/testql_cli_generator.py +1 -1
  28. {wup-0.2.77 → wup-0.2.79}/wup/testql_discovery.py +1 -1
  29. {wup-0.2.77 → wup-0.2.79}/wup/testql_watcher.py +2 -2
  30. {wup-0.2.77 → wup-0.2.79/wup.egg-info}/PKG-INFO +12 -7
  31. {wup-0.2.77 → wup-0.2.79}/wup.egg-info/SOURCES.txt +2 -0
  32. wup-0.2.79/wup.egg-info/entry_points.txt +2 -0
  33. {wup-0.2.77 → wup-0.2.79}/wup.egg-info/requires.txt +6 -0
  34. wup-0.2.77/wup/cli_bridge.py +0 -80
  35. wup-0.2.77/wup.egg-info/entry_points.txt +0 -2
  36. {wup-0.2.77 → wup-0.2.79}/LICENSE +0 -0
  37. {wup-0.2.77 → wup-0.2.79}/setup.cfg +0 -0
  38. {wup-0.2.77 → wup-0.2.79}/tests/test_aql.py +0 -0
  39. {wup-0.2.77 → wup-0.2.79}/tests/test_cli_bridge.py +0 -0
  40. {wup-0.2.77 → wup-0.2.79}/tests/test_control.py +0 -0
  41. {wup-0.2.77 → wup-0.2.79}/tests/test_discovery_adapters.py +0 -0
  42. {wup-0.2.77 → wup-0.2.79}/tests/test_endpoints_init_cli.py +0 -0
  43. {wup-0.2.77 → wup-0.2.79}/tests/test_genericity.py +0 -0
  44. {wup-0.2.77 → wup-0.2.79}/tests/test_health_summary_passed.py +0 -0
  45. {wup-0.2.77 → wup-0.2.79}/tests/test_oql.py +0 -0
  46. {wup-0.2.77 → wup-0.2.79}/tests/test_planfile_reporter_dedupe.py +0 -0
  47. {wup-0.2.77 → wup-0.2.79}/tests/test_probe_mutex.py +0 -0
  48. {wup-0.2.77 → wup-0.2.79}/tests/test_status_data.py +0 -0
  49. {wup-0.2.77 → wup-0.2.79}/tests/test_sync.py +0 -0
  50. {wup-0.2.77 → wup-0.2.79}/tests/test_testql_watcher.py +0 -0
  51. {wup-0.2.77 → wup-0.2.79}/tests/test_visual_diff_periodic_skip.py +0 -0
  52. {wup-0.2.77 → wup-0.2.79}/tests/test_watch_exclude.py +0 -0
  53. {wup-0.2.77 → wup-0.2.79}/tests/test_watch_no_paths_exit.py +0 -0
  54. {wup-0.2.77 → wup-0.2.79}/tests/test_web_client.py +0 -0
  55. {wup-0.2.77 → wup-0.2.79}/tests/test_wup_generate.py +0 -0
  56. {wup-0.2.77 → wup-0.2.79}/wup/_ast_detector.py +0 -0
  57. {wup-0.2.77 → wup-0.2.79}/wup/_base_detector.py +0 -0
  58. {wup-0.2.77 → wup-0.2.79}/wup/_hash_detector.py +0 -0
  59. {wup-0.2.77 → wup-0.2.79}/wup/_yaml_detector.py +0 -0
  60. {wup-0.2.77 → wup-0.2.79}/wup/anomaly_detector.py +0 -0
  61. {wup-0.2.77 → wup-0.2.79}/wup/anomaly_models.py +0 -0
  62. {wup-0.2.77 → wup-0.2.79}/wup/assistant_discovery.py +0 -0
  63. {wup-0.2.77 → wup-0.2.79}/wup/assistant_validator.py +0 -0
  64. {wup-0.2.77 → wup-0.2.79}/wup/bus.py +0 -0
  65. {wup-0.2.77 → wup-0.2.79}/wup/dependency_mapper.py +0 -0
  66. {wup-0.2.77 → wup-0.2.79}/wup/discovery.py +0 -0
  67. {wup-0.2.77 → wup-0.2.79}/wup/endpoints.py +0 -0
  68. {wup-0.2.77 → wup-0.2.79}/wup/event_store.py +0 -0
  69. {wup-0.2.77 → wup-0.2.79}/wup/file_watcher/events/file_events.py +0 -0
  70. {wup-0.2.77 → wup-0.2.79}/wup/generate.py +0 -0
  71. {wup-0.2.77 → wup-0.2.79}/wup/init_cli.py +0 -0
  72. {wup-0.2.77 → wup-0.2.79}/wup/models/__init__.py +0 -0
  73. {wup-0.2.77 → wup-0.2.79}/wup/models/config.py +0 -0
  74. {wup-0.2.77 → wup-0.2.79}/wup/models/target.py +0 -0
  75. {wup-0.2.77 → wup-0.2.79}/wup/monitoring_manifest.py +0 -0
  76. {wup-0.2.77 → wup-0.2.79}/wup/multi.py +0 -0
  77. {wup-0.2.77 → wup-0.2.79}/wup/oql.py +0 -0
  78. {wup-0.2.77 → wup-0.2.79}/wup/paths.py +0 -0
  79. {wup-0.2.77 → wup-0.2.79}/wup/planfile_reporter.py +0 -0
  80. {wup-0.2.77 → wup-0.2.79}/wup/status_data.py +0 -0
  81. {wup-0.2.77 → wup-0.2.79}/wup/sync.py +0 -0
  82. {wup-0.2.77 → wup-0.2.79}/wup/testing/events/health_events.py +0 -0
  83. {wup-0.2.77 → wup-0.2.79}/wup/testing/handlers/event_handlers.py +0 -0
  84. {wup-0.2.77 → wup-0.2.79}/wup/testing/handlers/health_handlers.py +0 -0
  85. {wup-0.2.77 → wup-0.2.79}/wup/testing/queries/health_queries.py +0 -0
  86. {wup-0.2.77 → wup-0.2.79}/wup/testql_monitor.py +0 -0
  87. {wup-0.2.77 → wup-0.2.79}/wup/validate.py +0 -0
  88. {wup-0.2.77 → wup-0.2.79}/wup/visual_diff.py +0 -0
  89. {wup-0.2.77 → wup-0.2.79}/wup/web_client.py +0 -0
  90. {wup-0.2.77 → wup-0.2.79}/wup.egg-info/dependency_links.txt +0 -0
  91. {wup-0.2.77 → wup-0.2.79}/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.77
3
+ Version: 0.2.79
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.77-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.76-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.79-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.70-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-54.6h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
47
52
 
48
- - 🤖 **LLM usage:** $6.7608 (106 commits)
49
- - 👤 **Human dev:** ~$5301 (53.0h @ $100/h, 30min dedup)
53
+ - 🤖 **LLM usage:** $7.6978 (109 commits)
54
+ - 👤 **Human dev:** ~$5461 (54.6h @ $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.77-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.79-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.77-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.76-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.79-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.70-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-54.6h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $6.7608 (106 commits)
10
- - 👤 **Human dev:** ~$5301 (53.0h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $7.6978 (109 commits)
10
+ - 👤 **Human dev:** ~$5461 (54.6h @ $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.77-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.79-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.77"
7
+ version = "0.2.79"
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
@@ -63,6 +64,33 @@ def test_default_config_watches_only_real_dirs(tmp_path: Path) -> None:
63
64
  assert cfg.watch.paths == ["services/**"]
64
65
 
65
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
+
66
94
  def test_discover_finds_subprojects(tmp_path: Path) -> None:
67
95
  _make_project(tmp_path, "alpha")
68
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.77"
10
+ __version__ = "0.2.79"
11
11
  __author__ = "Tom Sapletta"
12
12
 
13
13
  from .config import load_config, save_config, get_default_config
@@ -65,36 +65,43 @@ class AQLRule:
65
65
 
66
66
  # --- path resolution ------------------------------------------------------
67
67
 
68
- def _resolve_path(data: Any, path: str) -> Any:
69
- """Resolve a dotted/indexed path (e.g. ``.a.b[0]``) or return ``_MISSING``."""
70
- if path in ("", "."):
71
- return data
72
- # Split ".a.b[0].c" into ['a','b',0,'c'].
68
+ def _path_tokens(path: str) -> List[Any] | None:
69
+ """Split ``.a.b[0].c`` into ``['a', 'b', 0, 'c']``."""
73
70
  tokens: List[Any] = []
74
71
  for part in path.lstrip(".").split("."):
75
72
  if not part:
76
73
  continue
77
74
  match = re.match(r"^([^\[\]]*)((?:\[\d+\])*)$", part)
78
75
  if not match:
79
- return _MISSING
76
+ return None
80
77
  key, indices = match.group(1), match.group(2)
81
78
  if key:
82
79
  tokens.append(key)
83
80
  for idx in re.findall(r"\[(\d+)\]", indices):
84
81
  tokens.append(int(idx))
82
+ return tokens
83
+
84
+
85
+ def _path_child(current: Any, token: Any) -> Any:
86
+ if isinstance(token, int) and isinstance(current, (list, tuple)) and 0 <= token < len(current):
87
+ return current[token]
88
+ if isinstance(token, str) and isinstance(current, dict) and token in current:
89
+ return current[token]
90
+ return _MISSING
85
91
 
92
+
93
+ def _resolve_path(data: Any, path: str) -> Any:
94
+ """Resolve a dotted/indexed path (e.g. ``.a.b[0]``) or return ``_MISSING``."""
95
+ if path in ("", "."):
96
+ return data
97
+ tokens = _path_tokens(path)
98
+ if tokens is None:
99
+ return _MISSING
86
100
  current = data
87
101
  for token in tokens:
88
- if isinstance(token, int):
89
- if isinstance(current, (list, tuple)) and 0 <= token < len(current):
90
- current = current[token]
91
- else:
92
- return _MISSING
93
- else:
94
- if isinstance(current, dict) and token in current:
95
- current = current[token]
96
- else:
97
- return _MISSING
102
+ current = _path_child(current, token)
103
+ if current is _MISSING:
104
+ return _MISSING
98
105
  return current
99
106
 
100
107
 
@@ -130,51 +137,67 @@ def _tokenize(rule: str) -> List[str]:
130
137
  return [placeholder.get(t, t) for t in tokens]
131
138
 
132
139
 
140
+ def _rule_selector(tokens: List[str]) -> Tuple[str, str]:
141
+ selector = tokens.pop(0).lower()
142
+ if selector not in _SELECTORS:
143
+ raise AQLError(f"unknown selector {selector!r}; expected one of {', '.join(_SELECTORS)}")
144
+ if selector == "text":
145
+ return selector, ""
146
+ if not tokens:
147
+ raise AQLError(f"selector {selector!r} requires a path")
148
+ return selector, tokens.pop(0)
149
+
150
+
151
+ def _predicate_value(tokens: List[str], message: str) -> str:
152
+ if tokens:
153
+ return tokens.pop(0)
154
+ raise AQLError(message)
155
+
156
+
157
+ def _length_rule(selector: str, path: str, tokens: List[str], severity: str, raw: str) -> AQLRule:
158
+ if len(tokens) < 2 or tokens[0] not in _COMPARATORS:
159
+ raise AQLError("'length' requires an operator and number, e.g. length > 0")
160
+ operator, number = tokens[0], tokens[1]
161
+ if not number.lstrip("-").isdigit():
162
+ raise AQLError(f"'length' expects an integer, got {number!r}")
163
+ return AQLRule(selector, path, "length", number, length_op=operator, severity=severity, raw=raw)
164
+
165
+
166
+ def _type_rule(selector: str, path: str, tokens: List[str], severity: str, raw: str) -> AQLRule:
167
+ value = _predicate_value(tokens, f"'type' requires one of {', '.join(sorted(_TYPE_NAMES))}").lower()
168
+ if value not in _TYPE_NAMES:
169
+ raise AQLError(f"'type' requires one of {', '.join(sorted(_TYPE_NAMES))}")
170
+ return AQLRule(selector, path, "type", value, severity=severity, raw=raw)
171
+
172
+
173
+ def _predicate_rule(selector: str, path: str, keyword: str, tokens: List[str], severity: str, raw: str) -> AQLRule:
174
+ lower = keyword.lower()
175
+ if lower in ("exists", "missing"):
176
+ return AQLRule(selector, path, lower, severity=severity, raw=raw)
177
+ if lower == "matches":
178
+ return AQLRule(selector, path, "matches", _predicate_value(tokens, "'matches' requires a regex"), severity=severity, raw=raw)
179
+ if lower == "type":
180
+ return _type_rule(selector, path, tokens, severity, raw)
181
+ if lower == "length":
182
+ return _length_rule(selector, path, tokens, severity, raw)
183
+ if keyword in _COMPARATORS:
184
+ return AQLRule(selector, path, keyword, _predicate_value(tokens, f"operator {keyword!r} requires a value"), severity=severity, raw=raw)
185
+ raise AQLError(f"unknown predicate {keyword!r}")
186
+
187
+
133
188
  def parse_rule(rule: str) -> AQLRule:
134
189
  """Parse a single AQL rule string into an :class:`AQLRule`."""
135
190
  tokens, severity = _split_severity(_tokenize(rule))
136
191
  if not tokens:
137
192
  raise AQLError("empty rule")
138
193
 
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)
194
+ selector, path = _rule_selector(tokens)
148
195
 
149
196
  if not tokens:
150
197
  raise AQLError("rule requires a predicate (exists, =, length, type, …)")
151
198
 
152
199
  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}")
200
+ return _predicate_rule(selector, path, keyword, tokens, severity, rule)
178
201
 
179
202
 
180
203
  # --- evaluation -----------------------------------------------------------
@@ -220,26 +243,40 @@ def _type_name(value: Any) -> str:
220
243
  return "unknown"
221
244
 
222
245
 
223
- def _passes(rule: AQLRule, value: Any) -> bool:
224
- present = value is not _MISSING
225
- if rule.op == "exists":
226
- return present
227
- if rule.op == "missing":
228
- return not present
229
- if not present:
230
- return False # every other predicate needs the value to exist
231
-
246
+ def _matches_value(rule: AQLRule, value: Any) -> bool:
232
247
  if rule.op == "matches":
233
248
  return re.search(rule.value, str(value)) is not None
234
249
  if rule.op == "type":
235
250
  wanted = "bool" if rule.value in ("bool", "boolean") else rule.value
236
251
  return _type_name(value) == wanted
252
+ return False
253
+
254
+
255
+ def _collection_passes(rule: AQLRule, value: Any) -> bool | None:
237
256
  if rule.op == "length":
238
257
  length = _length_of(value)
239
258
  return length is not None and _compare(length, rule.length_op, rule.value)
240
259
  if rule.op in ("~", "!~"):
241
260
  contains = rule.value.lower() in str(value).lower()
242
261
  return contains if rule.op == "~" else not contains
262
+ return None
263
+
264
+
265
+ def _passes(rule: AQLRule, value: Any) -> bool:
266
+ present = value is not _MISSING
267
+ if rule.op == "exists":
268
+ return present
269
+ if rule.op == "missing":
270
+ return not present
271
+ if not present:
272
+ return False # every other predicate needs the value to exist
273
+
274
+ if rule.op in ("matches", "type"):
275
+ return _matches_value(rule, value)
276
+ if result := _collection_passes(rule, value):
277
+ return result
278
+ if rule.op in ("length", "~", "!~"):
279
+ return False
243
280
  return _compare(value, rule.op, rule.value)
244
281
 
245
282
 
@@ -259,6 +296,20 @@ class AQLEngine:
259
296
  except json.JSONDecodeError:
260
297
  return yaml.safe_load(text)
261
298
 
299
+ def _value_for_rule(self, file_path: Path, rule: AQLRule) -> Any:
300
+ data = self._load(file_path, rule.selector)
301
+ value = data if rule.selector == "text" else _resolve_path(data, rule.path)
302
+ return sorted(value.keys()) if rule.selector == "keys" and isinstance(value, dict) else value
303
+
304
+ @staticmethod
305
+ def _violation(file_path: Path, rule: AQLRule, value: Any) -> AnomalyResult:
306
+ shown = "(missing)" if value is _MISSING else repr(value)[:60]
307
+ return AnomalyResult(
308
+ detector="aql", file_path=str(file_path), anomaly_type="assertion", severity=rule.severity,
309
+ message=f"assertion failed: {rule.raw.strip()} (actual: {shown})",
310
+ details={"rule": rule.raw.strip(), "actual": None if value is _MISSING else value},
311
+ )
312
+
262
313
  def check_file(self, file_path: Path | str, rules: List[str | AQLRule]) -> List[AnomalyResult]:
263
314
  """Evaluate rules against one file; return a violation per failing rule."""
264
315
  file_path = Path(file_path)
@@ -269,24 +320,13 @@ class AQLEngine:
269
320
  for rule in rules:
270
321
  parsed = rule if isinstance(rule, AQLRule) else parse_rule(rule)
271
322
  try:
272
- data = self._load(file_path, parsed.selector)
273
- value = data if parsed.selector == "text" else _resolve_path(data, parsed.path)
274
- if parsed.selector == "keys":
275
- value = sorted(value.keys()) if isinstance(value, dict) else value
323
+ value = self._value_for_rule(file_path, parsed)
276
324
  except (OSError, yaml.YAMLError) as exc:
277
325
  violations.append(AnomalyResult("aql", str(file_path), "error", "high", f"could not read: {exc}"))
278
326
  continue
279
327
 
280
328
  if not _passes(parsed, value):
281
- shown = "(missing)" if value is _MISSING else repr(value)[:60]
282
- violations.append(AnomalyResult(
283
- detector="aql",
284
- file_path=str(file_path),
285
- anomaly_type="assertion",
286
- severity=parsed.severity,
287
- message=f"assertion failed: {parsed.raw.strip()} (actual: {shown})",
288
- details={"rule": parsed.raw.strip(), "actual": None if value is _MISSING else value},
289
- ))
329
+ violations.append(self._violation(file_path, parsed, value))
290
330
  return violations
291
331
 
292
332