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.
- {wup-0.2.77/wup.egg-info → wup-0.2.79}/PKG-INFO +12 -7
- {wup-0.2.77 → wup-0.2.79}/README.md +6 -6
- {wup-0.2.77 → wup-0.2.79}/pyproject.toml +18 -2
- {wup-0.2.77 → wup-0.2.79}/tests/test_assistant.py +0 -1
- {wup-0.2.77 → wup-0.2.79}/tests/test_auto_detection.py +1 -1
- wup-0.2.79/tests/test_bootstrap.py +31 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_cli_filtering.py +5 -5
- {wup-0.2.77 → wup-0.2.79}/tests/test_e2e.py +1 -3
- {wup-0.2.77 → wup-0.2.79}/tests/test_monitoring_manifest.py +0 -1
- {wup-0.2.77 → wup-0.2.79}/tests/test_multi_project.py +29 -1
- {wup-0.2.77 → wup-0.2.79}/tests/test_service_inference.py +0 -2
- {wup-0.2.77 → wup-0.2.79}/tests/test_testql_monitor.py +0 -3
- {wup-0.2.77 → wup-0.2.79}/tests/test_visual_diff_progress.py +0 -1
- {wup-0.2.77 → wup-0.2.79}/tests/test_wup.py +38 -0
- {wup-0.2.77 → wup-0.2.79}/wup/__init__.py +1 -1
- {wup-0.2.77 → wup-0.2.79}/wup/aql.py +112 -72
- {wup-0.2.77 → wup-0.2.79}/wup/assistant.py +4 -14
- wup-0.2.79/wup/bootstrap.py +61 -0
- {wup-0.2.77 → wup-0.2.79}/wup/cli.py +9 -9
- wup-0.2.79/wup/cli_bridge.py +194 -0
- {wup-0.2.77 → wup-0.2.79}/wup/cli_config_generator.py +2 -2
- {wup-0.2.77 → wup-0.2.79}/wup/cli_scanner.py +1 -2
- {wup-0.2.77 → wup-0.2.79}/wup/config.py +178 -204
- {wup-0.2.77 → wup-0.2.79}/wup/control.py +28 -7
- {wup-0.2.77 → wup-0.2.79}/wup/core.py +2 -2
- {wup-0.2.77 → wup-0.2.79}/wup/testing/events/test_results.py +0 -1
- {wup-0.2.77 → wup-0.2.79}/wup/testql_cli_generator.py +1 -1
- {wup-0.2.77 → wup-0.2.79}/wup/testql_discovery.py +1 -1
- {wup-0.2.77 → wup-0.2.79}/wup/testql_watcher.py +2 -2
- {wup-0.2.77 → wup-0.2.79/wup.egg-info}/PKG-INFO +12 -7
- {wup-0.2.77 → wup-0.2.79}/wup.egg-info/SOURCES.txt +2 -0
- wup-0.2.79/wup.egg-info/entry_points.txt +2 -0
- {wup-0.2.77 → wup-0.2.79}/wup.egg-info/requires.txt +6 -0
- wup-0.2.77/wup/cli_bridge.py +0 -80
- wup-0.2.77/wup.egg-info/entry_points.txt +0 -2
- {wup-0.2.77 → wup-0.2.79}/LICENSE +0 -0
- {wup-0.2.77 → wup-0.2.79}/setup.cfg +0 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_aql.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_cli_bridge.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_control.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_discovery_adapters.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_endpoints_init_cli.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_genericity.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_health_summary_passed.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_oql.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_planfile_reporter_dedupe.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_probe_mutex.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_status_data.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_sync.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_testql_watcher.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_visual_diff_periodic_skip.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_watch_exclude.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_watch_no_paths_exit.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_web_client.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/tests/test_wup_generate.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/_ast_detector.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/_base_detector.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/_hash_detector.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/_yaml_detector.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/anomaly_detector.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/anomaly_models.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/assistant_discovery.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/assistant_validator.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/bus.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/dependency_mapper.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/discovery.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/endpoints.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/event_store.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/file_watcher/events/file_events.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/generate.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/init_cli.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/models/__init__.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/models/config.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/models/target.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/monitoring_manifest.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/multi.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/oql.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/paths.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/planfile_reporter.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/status_data.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/sync.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/testing/events/health_events.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/testing/handlers/event_handlers.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/testing/handlers/health_handlers.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/testing/queries/health_queries.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/testql_monitor.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/validate.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/visual_diff.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup/web_client.py +0 -0
- {wup-0.2.77 → wup-0.2.79}/wup.egg-info/dependency_links.txt +0 -0
- {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.
|
|
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
|
-
    
|
|
51
|
+
  
|
|
47
52
|
|
|
48
|
-
- 🤖 **LLM usage:** $
|
|
49
|
-
- 👤 **Human dev:** ~$
|
|
53
|
+
- 🤖 **LLM usage:** $7.6978 (109 commits)
|
|
54
|
+
- 👤 **Human dev:** ~$5461 (54.6h @ $100/h, 30min dedup)
|
|
50
55
|
|
|
51
|
-
Generated on 2026-07-
|
|
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
|
-
    
|
|
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
|
-
    
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $7.6978 (109 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$5461 (54.6h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
|
-
Generated on 2026-07-
|
|
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
|
-
    
|
|
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.
|
|
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.
|
|
110
|
+
wup = "wup.bootstrap:main"
|
|
95
111
|
|
|
96
112
|
[project.urls]
|
|
97
113
|
Homepage = "https://github.com/semcod/wup"
|
|
@@ -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
|
|
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")
|
|
262
|
-
assert watcher._scenario_matches_type(cli_scenario, "web")
|
|
263
|
-
assert watcher._scenario_matches_type(web_scenario, "shell")
|
|
264
|
-
assert watcher._scenario_matches_type(web_scenario, "web")
|
|
265
|
-
assert watcher._scenario_matches_type(cli_scenario, "auto")
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
|
|
@@ -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.
|
|
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
|
|
69
|
-
"""
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|