wup 0.2.34__tar.gz → 0.2.37__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.34/wup.egg-info → wup-0.2.37}/PKG-INFO +7 -7
- {wup-0.2.34 → wup-0.2.37}/README.md +6 -6
- {wup-0.2.34 → wup-0.2.37}/pyproject.toml +1 -1
- {wup-0.2.34 → wup-0.2.37}/wup/__init__.py +9 -2
- {wup-0.2.34 → wup-0.2.37}/wup/cli.py +81 -0
- wup-0.2.37/wup/cli_config_generator.py +223 -0
- wup-0.2.37/wup/cli_scanner.py +302 -0
- {wup-0.2.34 → wup-0.2.37}/wup/planfile_reporter.py +37 -0
- wup-0.2.37/wup/testql_cli_generator.py +215 -0
- {wup-0.2.34 → wup-0.2.37}/wup/testql_monitor.py +54 -18
- {wup-0.2.34 → wup-0.2.37}/wup/testql_watcher.py +1 -1
- {wup-0.2.34 → wup-0.2.37/wup.egg-info}/PKG-INFO +7 -7
- {wup-0.2.34 → wup-0.2.37}/wup.egg-info/SOURCES.txt +3 -0
- {wup-0.2.34 → wup-0.2.37}/LICENSE +0 -0
- {wup-0.2.34 → wup-0.2.37}/setup.cfg +0 -0
- {wup-0.2.34 → wup-0.2.37}/tests/test_e2e.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/tests/test_monitoring_manifest.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/tests/test_testql_monitor.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/tests/test_testql_watcher.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/tests/test_web_client.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/tests/test_wup.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/wup/_ast_detector.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/wup/_hash_detector.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/wup/_yaml_detector.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/wup/anomaly_detector.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/wup/anomaly_models.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/wup/assistant.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/wup/config.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/wup/core.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/wup/dependency_mapper.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/wup/models/__init__.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/wup/models/config.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/wup/monitoring_manifest.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/wup/testql_discovery.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/wup/visual_diff.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/wup/web_client.py +0 -0
- {wup-0.2.34 → wup-0.2.37}/wup.egg-info/dependency_links.txt +0 -0
- {wup-0.2.34 → wup-0.2.37}/wup.egg-info/entry_points.txt +0 -0
- {wup-0.2.34 → wup-0.2.37}/wup.egg-info/requires.txt +0 -0
- {wup-0.2.34 → wup-0.2.37}/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.37
|
|
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
|
|
@@ -31,17 +31,17 @@ Dynamic: license-file
|
|
|
31
31
|
|
|
32
32
|
## AI Cost Tracking
|
|
33
33
|
|
|
34
|
-
    
|
|
35
|
+
  
|
|
36
36
|
|
|
37
|
-
- 🤖 **LLM usage:** $2.
|
|
38
|
-
- 👤 **Human dev:** ~$
|
|
37
|
+
- 🤖 **LLM usage:** $2.7096 (47 commits)
|
|
38
|
+
- 👤 **Human dev:** ~$1947 (19.5h @ $100/h, 30min dedup)
|
|
39
39
|
|
|
40
|
-
Generated on 2026-05-
|
|
40
|
+
Generated on 2026-05-22 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
41
41
|
|
|
42
42
|
---
|
|
43
43
|
|
|
44
|
-
    
|
|
45
45
|
|
|
46
46
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
47
47
|
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
## AI Cost Tracking
|
|
5
5
|
|
|
6
|
-
    
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $2.
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $2.7096 (47 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$1947 (19.5h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
|
-
Generated on 2026-05-
|
|
12
|
+
Generated on 2026-05-22 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
|
|
|
@@ -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.37"
|
|
11
11
|
__author__ = "Tom Sapletta"
|
|
12
12
|
|
|
13
13
|
from .config import load_config, save_config, get_default_config
|
|
@@ -21,7 +21,6 @@ from .models.config import (
|
|
|
21
21
|
TestQLConfig,
|
|
22
22
|
NotifyConfig,
|
|
23
23
|
)
|
|
24
|
-
from .testql_watcher import TestQLWatcher
|
|
25
24
|
|
|
26
25
|
__all__ = [
|
|
27
26
|
"WupWatcher",
|
|
@@ -37,3 +36,11 @@ __all__ = [
|
|
|
37
36
|
"TestQLConfig",
|
|
38
37
|
"NotifyConfig",
|
|
39
38
|
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def __getattr__(name: str):
|
|
42
|
+
"""Lazy import for TestQLWatcher to avoid circular dependency."""
|
|
43
|
+
if name == "TestQLWatcher":
|
|
44
|
+
from .testql_watcher import TestQLWatcher
|
|
45
|
+
return TestQLWatcher
|
|
46
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -678,5 +678,86 @@ def version():
|
|
|
678
678
|
console.print(f"[bold cyan]WUP[/bold cyan] version [green]{__version__}[/green]")
|
|
679
679
|
|
|
680
680
|
|
|
681
|
+
@app.command("init-cli")
|
|
682
|
+
def init_cli(
|
|
683
|
+
project: str = typer.Argument(".", help="Path to the project root directory"),
|
|
684
|
+
output_config: Optional[str] = typer.Option(None, "--output-config", "-c", help="Path for wup.yaml output"),
|
|
685
|
+
output_scenarios: Optional[str] = typer.Option(None, "--output-scenarios", "-s", help="Path for testql-scenarios directory"),
|
|
686
|
+
merge: bool = typer.Option(False, "--merge", "-m", help="Merge with existing wup.yaml"),
|
|
687
|
+
infer_args: bool = typer.Option(True, "--infer-args/--no-infer-args", help="Infer command arguments by inspection"),
|
|
688
|
+
):
|
|
689
|
+
"""
|
|
690
|
+
Automatically generate wup.yaml configuration and TestQL scenarios for CLI/shell services.
|
|
691
|
+
|
|
692
|
+
Scans the project for CLI commands (entry points, setup.py, pyproject.toml) and generates:
|
|
693
|
+
- wup.yaml with shell service configuration
|
|
694
|
+
- TestQL scenarios in testql-scenarios/ directory
|
|
695
|
+
|
|
696
|
+
Example:
|
|
697
|
+
wup init-cli ./my-project
|
|
698
|
+
wup init-cli ./my-project --merge
|
|
699
|
+
"""
|
|
700
|
+
from pathlib import Path
|
|
701
|
+
from .cli_scanner import CLIScanner
|
|
702
|
+
from .cli_config_generator import CLIConfigGenerator
|
|
703
|
+
from .testql_cli_generator import TestQLCLIGenerator
|
|
704
|
+
|
|
705
|
+
project_path = Path(project).resolve()
|
|
706
|
+
|
|
707
|
+
if not project_path.exists():
|
|
708
|
+
console.print(f"[red]Error: Project path '{project}' does not exist[/red]")
|
|
709
|
+
raise typer.Exit(1)
|
|
710
|
+
|
|
711
|
+
console.print(f"[cyan]🔍 Scanning project for CLI commands...[/cyan]")
|
|
712
|
+
console.print(f"[dim]Project: {project_path}[/dim]\n")
|
|
713
|
+
|
|
714
|
+
try:
|
|
715
|
+
# Scan for CLI commands
|
|
716
|
+
scanner = CLIScanner(str(project_path))
|
|
717
|
+
packages = scanner.scan()
|
|
718
|
+
|
|
719
|
+
if not packages:
|
|
720
|
+
console.print("[yellow]⚠ No CLI packages found in project[/yellow]")
|
|
721
|
+
console.print("[dim]Looking for: setup.py, pyproject.toml, or packages with __main__.py[/dim]")
|
|
722
|
+
raise typer.Exit(1)
|
|
723
|
+
|
|
724
|
+
console.print(f"[green]✓ Found {len(packages)} package(s)[/green]")
|
|
725
|
+
for pkg in packages:
|
|
726
|
+
console.print(f" [cyan]{pkg.name}[/cyan]: {len(pkg.commands)} command(s)")
|
|
727
|
+
for cmd in pkg.commands:
|
|
728
|
+
console.print(f" - {cmd.name} -> {cmd.entry_point}")
|
|
729
|
+
console.print()
|
|
730
|
+
|
|
731
|
+
# Generate wup.yaml
|
|
732
|
+
config_generator = CLIConfigGenerator(str(project_path))
|
|
733
|
+
config_output = Path(output_config) if output_config else None
|
|
734
|
+
config = config_generator.generate(output_path=config_output, merge_existing=merge)
|
|
735
|
+
config_generator.print_summary(config)
|
|
736
|
+
|
|
737
|
+
# Generate TestQL scenarios
|
|
738
|
+
console.print()
|
|
739
|
+
console.print(f"[cyan]🧪 Generating TestQL scenarios...[/cyan]")
|
|
740
|
+
scenarios_output = Path(output_scenarios) if output_scenarios else None
|
|
741
|
+
testql_generator = TestQLCLIGenerator(str(project_path))
|
|
742
|
+
generated_files = testql_generator.generate(
|
|
743
|
+
output_dir=scenarios_output,
|
|
744
|
+
infer_args=infer_args,
|
|
745
|
+
)
|
|
746
|
+
testql_generator.print_summary(generated_files)
|
|
747
|
+
|
|
748
|
+
console.print()
|
|
749
|
+
console.print("[bold green]✅ CLI testing setup complete![/bold green]")
|
|
750
|
+
console.print()
|
|
751
|
+
console.print("[dim]Next steps:[/dim]")
|
|
752
|
+
console.print(" 1. Review generated wup.yaml")
|
|
753
|
+
console.print(" 2. Review testql-scenarios/*.testql.toon.yaml")
|
|
754
|
+
console.print(" 3. Run: wup watch . --mode testql")
|
|
755
|
+
console.print(" 4. Or run individual scenario: testql run testql-scenarios/cli-smoke.testql.toon.yaml")
|
|
756
|
+
|
|
757
|
+
except Exception as e:
|
|
758
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
759
|
+
raise typer.Exit(1)
|
|
760
|
+
|
|
761
|
+
|
|
681
762
|
if __name__ == "__main__":
|
|
682
763
|
app()
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Generator for wup.yaml configuration for CLI/shell services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from .cli_scanner import CLIScanner, CLIPackage, CLICommand
|
|
11
|
+
from .models.config import (
|
|
12
|
+
ProjectConfig,
|
|
13
|
+
ServiceConfig,
|
|
14
|
+
ServiceTestConfig,
|
|
15
|
+
TestQLConfig,
|
|
16
|
+
TestStrategyConfig,
|
|
17
|
+
WatchConfig,
|
|
18
|
+
WupConfig,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CLIConfigGenerator:
|
|
23
|
+
"""Generate wup.yaml configuration for CLI/shell services."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, project_root: str):
|
|
26
|
+
self.project_root = Path(project_root).resolve()
|
|
27
|
+
self.scanner = CLIScanner(project_root)
|
|
28
|
+
|
|
29
|
+
def generate(
|
|
30
|
+
self,
|
|
31
|
+
output_path: Optional[Path] = None,
|
|
32
|
+
merge_existing: bool = False,
|
|
33
|
+
) -> WupConfig:
|
|
34
|
+
"""Generate wup.yaml configuration for CLI services.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
output_path: Path to save the configuration (default: wup.yaml)
|
|
38
|
+
merge_existing: If True, merge with existing wup.yaml
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Generated WupConfig object
|
|
42
|
+
"""
|
|
43
|
+
# Scan for CLI commands
|
|
44
|
+
packages = self.scanner.scan()
|
|
45
|
+
|
|
46
|
+
if not packages:
|
|
47
|
+
raise ValueError("No CLI packages found in project")
|
|
48
|
+
|
|
49
|
+
# Load existing config if merging
|
|
50
|
+
existing_config = None
|
|
51
|
+
if merge_existing:
|
|
52
|
+
from .config import load_config
|
|
53
|
+
existing_config = load_config(self.project_root)
|
|
54
|
+
|
|
55
|
+
# Generate configuration
|
|
56
|
+
config = self._generate_config(packages, existing_config)
|
|
57
|
+
|
|
58
|
+
# Save configuration
|
|
59
|
+
if output_path is None:
|
|
60
|
+
output_path = self.project_root / "wup.yaml"
|
|
61
|
+
|
|
62
|
+
self._save_config(config, output_path)
|
|
63
|
+
|
|
64
|
+
return config
|
|
65
|
+
|
|
66
|
+
def _generate_config(
|
|
67
|
+
self,
|
|
68
|
+
packages: List[CLIPackage],
|
|
69
|
+
existing_config: Optional[WupConfig] = None,
|
|
70
|
+
) -> WupConfig:
|
|
71
|
+
"""Generate WupConfig from scanned packages."""
|
|
72
|
+
if existing_config:
|
|
73
|
+
config = existing_config
|
|
74
|
+
else:
|
|
75
|
+
config = WupConfig(
|
|
76
|
+
project=ProjectConfig(
|
|
77
|
+
name=self.project_root.name,
|
|
78
|
+
description=f"CLI testing for {self.project_root.name}",
|
|
79
|
+
),
|
|
80
|
+
watch=WatchConfig(
|
|
81
|
+
paths=["**/*.py"],
|
|
82
|
+
exclude_patterns=["*.md", "tests/**", ".venv/**", "venv/**"],
|
|
83
|
+
file_types=[".py"],
|
|
84
|
+
),
|
|
85
|
+
test_strategy=TestStrategyConfig(
|
|
86
|
+
quick={"debounce_s": 2, "max_queue": 5, "timeout_s": 30},
|
|
87
|
+
detail={"debounce_s": 10, "max_queue": 1, "timeout_s": 60},
|
|
88
|
+
),
|
|
89
|
+
testql=TestQLConfig(
|
|
90
|
+
scenario_dir="testql-scenarios",
|
|
91
|
+
smoke_scenario="cli-smoke.testql.toon.yaml",
|
|
92
|
+
output_format="json",
|
|
93
|
+
probe_interval_s=60,
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Add shell services for each package
|
|
98
|
+
for package in packages:
|
|
99
|
+
service = self._create_shell_service(package)
|
|
100
|
+
|
|
101
|
+
# Check if service already exists
|
|
102
|
+
existing_service = next(
|
|
103
|
+
(s for s in config.services if s.name == service.name),
|
|
104
|
+
None,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if existing_service:
|
|
108
|
+
# Update existing service
|
|
109
|
+
existing_service.type = service.type
|
|
110
|
+
existing_service.quick_tests = service.quick_tests
|
|
111
|
+
existing_service.detail_tests = service.detail_tests
|
|
112
|
+
else:
|
|
113
|
+
# Add new service
|
|
114
|
+
config.services.append(service)
|
|
115
|
+
|
|
116
|
+
return config
|
|
117
|
+
|
|
118
|
+
def _create_shell_service(self, package: CLIPackage) -> ServiceConfig:
|
|
119
|
+
"""Create a shell service configuration from a CLI package."""
|
|
120
|
+
# Calculate max_endpoints based on number of commands
|
|
121
|
+
command_count = len(package.commands)
|
|
122
|
+
quick_max = min(command_count, 5)
|
|
123
|
+
detail_max = min(command_count * 2, 20)
|
|
124
|
+
|
|
125
|
+
service = ServiceConfig(
|
|
126
|
+
name=f"{package.name}-shell",
|
|
127
|
+
type="shell",
|
|
128
|
+
paths=[], # Auto-detect
|
|
129
|
+
root=str(self.project_root),
|
|
130
|
+
quick_tests=ServiceTestConfig(
|
|
131
|
+
scope="all",
|
|
132
|
+
max_endpoints=quick_max,
|
|
133
|
+
),
|
|
134
|
+
detail_tests=ServiceTestConfig(
|
|
135
|
+
scope="all",
|
|
136
|
+
max_endpoints=detail_max,
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return service
|
|
141
|
+
|
|
142
|
+
def _save_config(self, config: WupConfig, output_path: Path) -> None:
|
|
143
|
+
"""Save configuration to YAML file."""
|
|
144
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
|
|
146
|
+
# Convert to dict
|
|
147
|
+
config_dict = {
|
|
148
|
+
"project": {
|
|
149
|
+
"name": config.project.name,
|
|
150
|
+
"description": config.project.description,
|
|
151
|
+
},
|
|
152
|
+
"watch": {
|
|
153
|
+
"paths": config.watch.paths,
|
|
154
|
+
"exclude_patterns": config.watch.exclude_patterns,
|
|
155
|
+
"file_types": config.watch.file_types,
|
|
156
|
+
},
|
|
157
|
+
"services": [
|
|
158
|
+
{
|
|
159
|
+
"name": svc.name,
|
|
160
|
+
"type": svc.type,
|
|
161
|
+
"paths": svc.paths,
|
|
162
|
+
"root": svc.root,
|
|
163
|
+
"quick_tests": {
|
|
164
|
+
"scope": svc.quick_tests.scope,
|
|
165
|
+
"max_endpoints": svc.quick_tests.max_endpoints,
|
|
166
|
+
},
|
|
167
|
+
"detail_tests": {
|
|
168
|
+
"scope": svc.detail_tests.scope,
|
|
169
|
+
"max_endpoints": svc.detail_tests.max_endpoints,
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
for svc in config.services
|
|
173
|
+
],
|
|
174
|
+
"test_strategy": {
|
|
175
|
+
"quick": config.test_strategy.quick,
|
|
176
|
+
"detail": config.test_strategy.detail,
|
|
177
|
+
},
|
|
178
|
+
"testql": {
|
|
179
|
+
"scenario_dir": config.testql.scenario_dir,
|
|
180
|
+
"smoke_scenario": config.testql.smoke_scenario,
|
|
181
|
+
"output_format": config.testql.output_format,
|
|
182
|
+
"probe_interval_s": config.testql.probe_interval_s,
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
# Add header
|
|
187
|
+
header = f"""# WUP Configuration for CLI Testing
|
|
188
|
+
# Generated automatically by wup init-cli
|
|
189
|
+
# Project: {config.project.name}
|
|
190
|
+
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
194
|
+
f.write(header)
|
|
195
|
+
yaml.dump(config_dict, f, default_flow_style=False, sort_keys=False)
|
|
196
|
+
|
|
197
|
+
def print_summary(self, config: WupConfig) -> None:
|
|
198
|
+
"""Print summary of generated configuration."""
|
|
199
|
+
from rich.console import Console
|
|
200
|
+
from rich.table import Table
|
|
201
|
+
|
|
202
|
+
console = Console()
|
|
203
|
+
|
|
204
|
+
console.print("\n[bold green]✓ Generated wup.yaml configuration[/bold green]\n")
|
|
205
|
+
|
|
206
|
+
table = Table(title="Shell Services")
|
|
207
|
+
table.add_column("Service", style="cyan")
|
|
208
|
+
table.add_column("Type", style="green")
|
|
209
|
+
table.add_column("Quick Tests", style="yellow")
|
|
210
|
+
table.add_column("Detail Tests", style="yellow")
|
|
211
|
+
|
|
212
|
+
for svc in config.services:
|
|
213
|
+
if svc.type == "shell":
|
|
214
|
+
table.add_row(
|
|
215
|
+
svc.name,
|
|
216
|
+
svc.type,
|
|
217
|
+
str(svc.quick_tests.max_endpoints),
|
|
218
|
+
str(svc.detail_tests.max_endpoints),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
console.print(table)
|
|
222
|
+
console.print(f"\nTestQL scenarios directory: {config.testql.scenario_dir}")
|
|
223
|
+
console.print(f"Smoke scenario: {config.testql.smoke_scenario}")
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""CLI scanner for detecting CLI commands and entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, List, Optional, Set
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class CLICommand:
|
|
16
|
+
"""Represents a detected CLI command."""
|
|
17
|
+
name: str
|
|
18
|
+
entry_point: str
|
|
19
|
+
module: str
|
|
20
|
+
function: str
|
|
21
|
+
description: str = ""
|
|
22
|
+
args: List[str] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class CLIPackage:
|
|
27
|
+
"""Represents a detected CLI package."""
|
|
28
|
+
name: str
|
|
29
|
+
commands: List[CLICommand] = field(default_factory=list)
|
|
30
|
+
entry_points: Dict[str, str] = field(default_factory=dict)
|
|
31
|
+
setup_files: List[Path] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CLIScanner:
|
|
35
|
+
"""Scanner for detecting CLI commands in a project."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, project_root: str):
|
|
38
|
+
self.project_root = Path(project_root).resolve()
|
|
39
|
+
self.packages: List[CLIPackage] = []
|
|
40
|
+
|
|
41
|
+
def scan(self) -> List[CLIPackage]:
|
|
42
|
+
"""Scan project for CLI packages and commands."""
|
|
43
|
+
self.packages = []
|
|
44
|
+
|
|
45
|
+
# Scan for setup.py
|
|
46
|
+
setup_py = self.project_root / "setup.py"
|
|
47
|
+
if setup_py.exists():
|
|
48
|
+
self._scan_setup_py(setup_py)
|
|
49
|
+
|
|
50
|
+
# Scan for setup.cfg
|
|
51
|
+
setup_cfg = self.project_root / "setup.cfg"
|
|
52
|
+
if setup_cfg.exists():
|
|
53
|
+
self._scan_setup_cfg(setup_cfg)
|
|
54
|
+
|
|
55
|
+
# Scan for pyproject.toml
|
|
56
|
+
pyproject_toml = self.project_root / "pyproject.toml"
|
|
57
|
+
if pyproject_toml.exists():
|
|
58
|
+
self._scan_pyproject_toml(pyproject_toml)
|
|
59
|
+
|
|
60
|
+
# Scan for package directory with __main__.py
|
|
61
|
+
self._scan_main_modules()
|
|
62
|
+
|
|
63
|
+
return self.packages
|
|
64
|
+
|
|
65
|
+
def _scan_setup_py(self, setup_py: Path) -> None:
|
|
66
|
+
"""Scan setup.py for entry points."""
|
|
67
|
+
try:
|
|
68
|
+
content = setup_py.read_text(encoding="utf-8")
|
|
69
|
+
|
|
70
|
+
# Extract entry_points from setup() call
|
|
71
|
+
entry_points_match = re.search(
|
|
72
|
+
r'entry_points\s*=\s*{([^}]+)}',
|
|
73
|
+
content,
|
|
74
|
+
re.DOTALL
|
|
75
|
+
)
|
|
76
|
+
if entry_points_match:
|
|
77
|
+
self._parse_entry_points_dict(entry_points_match.group(1), setup_py)
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
def _scan_setup_cfg(self, setup_cfg: Path) -> None:
|
|
82
|
+
"""Scan setup.cfg for entry points."""
|
|
83
|
+
try:
|
|
84
|
+
content = setup_cfg.read_text(encoding="utf-8")
|
|
85
|
+
|
|
86
|
+
# Parse [options.entry_points] section
|
|
87
|
+
in_entry_points = False
|
|
88
|
+
current_section = None
|
|
89
|
+
|
|
90
|
+
for line in content.splitlines():
|
|
91
|
+
line = line.strip()
|
|
92
|
+
|
|
93
|
+
if line.startswith("[options.entry_points"):
|
|
94
|
+
in_entry_points = True
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
if in_entry_points:
|
|
98
|
+
if line.startswith("[") and not line.startswith("[options.entry_points"):
|
|
99
|
+
in_entry_points = False
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
if "=" in line and not line.startswith("#"):
|
|
103
|
+
entry_point, value = line.split("=", 1)
|
|
104
|
+
entry_point = entry_point.strip()
|
|
105
|
+
value = value.strip()
|
|
106
|
+
|
|
107
|
+
if current_section is None:
|
|
108
|
+
current_section = "console_scripts"
|
|
109
|
+
|
|
110
|
+
self._add_entry_point(entry_point, value, current_section, setup_cfg)
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
def _scan_pyproject_toml(self, pyproject_toml: Path) -> None:
|
|
115
|
+
"""Scan pyproject.toml for entry points."""
|
|
116
|
+
try:
|
|
117
|
+
content = pyproject_toml.read_text(encoding="utf-8")
|
|
118
|
+
|
|
119
|
+
# Parse [project.scripts] section (PEP 621)
|
|
120
|
+
scripts_match = re.search(
|
|
121
|
+
r'\[project\.scripts\](.*?)(?=\n\[|\Z)',
|
|
122
|
+
content,
|
|
123
|
+
re.DOTALL
|
|
124
|
+
)
|
|
125
|
+
if scripts_match:
|
|
126
|
+
scripts_section = scripts_match.group(1)
|
|
127
|
+
for line in scripts_section.splitlines():
|
|
128
|
+
line = line.strip()
|
|
129
|
+
if "=" in line and not line.startswith("#"):
|
|
130
|
+
name, value = line.split("=", 1)
|
|
131
|
+
name = name.strip()
|
|
132
|
+
value = value.strip().strip('"').strip("'")
|
|
133
|
+
self._add_entry_point(name, value, "console_scripts", pyproject_toml)
|
|
134
|
+
|
|
135
|
+
# Also check for [tool.setuptools.dynamic] dependencies
|
|
136
|
+
# This handles newer setuptools configurations
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
def _scan_main_modules(self) -> None:
|
|
141
|
+
"""Scan for packages with __main__.py files."""
|
|
142
|
+
for pkg_dir in self.project_root.iterdir():
|
|
143
|
+
if pkg_dir.is_dir() and not pkg_dir.name.startswith("_"):
|
|
144
|
+
main_py = pkg_dir / "__main__.py"
|
|
145
|
+
if main_py.exists():
|
|
146
|
+
# This package can be run as python -m package
|
|
147
|
+
self._add_entry_point(
|
|
148
|
+
pkg_dir.name,
|
|
149
|
+
f"{pkg_dir.name}.__main__:main",
|
|
150
|
+
"console_scripts",
|
|
151
|
+
main_py
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def _parse_entry_points_dict(self, dict_str: str, source: Path) -> None:
|
|
155
|
+
"""Parse entry points dictionary string."""
|
|
156
|
+
try:
|
|
157
|
+
# Simple parsing for entry points like:
|
|
158
|
+
# 'console_scripts': ['cmd1 = module:function', 'cmd2 = module2:function']
|
|
159
|
+
|
|
160
|
+
# Extract console_scripts section
|
|
161
|
+
console_match = re.search(
|
|
162
|
+
r'["\']console_scripts["\']\s*:\s*\[([^\]]+)\]',
|
|
163
|
+
dict_str,
|
|
164
|
+
re.DOTALL
|
|
165
|
+
)
|
|
166
|
+
if console_match:
|
|
167
|
+
entries = console_match.group(1)
|
|
168
|
+
for entry in re.findall(r'["\']([^"\']+)["\']\s*=\s*["\']([^"\']+)["\']', entries):
|
|
169
|
+
name, value = entry
|
|
170
|
+
self._add_entry_point(name, value, "console_scripts", source)
|
|
171
|
+
except Exception:
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
def _add_entry_point(self, name: str, value: str, section: str, source: Path) -> None:
|
|
175
|
+
"""Add an entry point to the packages list."""
|
|
176
|
+
if section != "console_scripts":
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
# Parse module:function
|
|
180
|
+
if ":" in value:
|
|
181
|
+
module, function = value.rsplit(":", 1)
|
|
182
|
+
else:
|
|
183
|
+
module = value
|
|
184
|
+
function = "main"
|
|
185
|
+
|
|
186
|
+
# Find or create package
|
|
187
|
+
package_name = self.project_root.name
|
|
188
|
+
package = next((p for p in self.packages if p.name == package_name), None)
|
|
189
|
+
|
|
190
|
+
if package is None:
|
|
191
|
+
package = CLIPackage(name=package_name)
|
|
192
|
+
package.setup_files.append(source)
|
|
193
|
+
self.packages.append(package)
|
|
194
|
+
|
|
195
|
+
# Add command
|
|
196
|
+
command = CLICommand(
|
|
197
|
+
name=name,
|
|
198
|
+
entry_point=value,
|
|
199
|
+
module=module,
|
|
200
|
+
function=function
|
|
201
|
+
)
|
|
202
|
+
package.commands.append(command)
|
|
203
|
+
package.entry_points[name] = value
|
|
204
|
+
|
|
205
|
+
def infer_command_args(self, command: CLICommand) -> List[str]:
|
|
206
|
+
"""Infer command arguments by inspecting the module."""
|
|
207
|
+
try:
|
|
208
|
+
module_path = self._find_module_path(command.module)
|
|
209
|
+
if not module_path:
|
|
210
|
+
return []
|
|
211
|
+
|
|
212
|
+
# Try to parse the module and extract function arguments
|
|
213
|
+
content = module_path.read_text(encoding="utf-8")
|
|
214
|
+
tree = ast.parse(content)
|
|
215
|
+
|
|
216
|
+
for node in ast.walk(tree):
|
|
217
|
+
if isinstance(node, ast.FunctionDef) and node.name == command.function:
|
|
218
|
+
args = []
|
|
219
|
+
for arg in node.args.args:
|
|
220
|
+
args.append(arg.arg)
|
|
221
|
+
return args
|
|
222
|
+
|
|
223
|
+
# Try running --help to get arguments
|
|
224
|
+
return self._get_help_arguments(command.name)
|
|
225
|
+
except Exception:
|
|
226
|
+
return []
|
|
227
|
+
|
|
228
|
+
def _find_module_path(self, module: str) -> Optional[Path]:
|
|
229
|
+
"""Find the Python file for a given module."""
|
|
230
|
+
parts = module.replace(".", "/")
|
|
231
|
+
|
|
232
|
+
# Try .py file
|
|
233
|
+
py_path = self.project_root / f"{parts}.py"
|
|
234
|
+
if py_path.exists():
|
|
235
|
+
return py_path
|
|
236
|
+
|
|
237
|
+
# Try __init__.py in package
|
|
238
|
+
init_path = self.project_root / parts / "__init__.py"
|
|
239
|
+
if init_path.exists():
|
|
240
|
+
return init_path
|
|
241
|
+
|
|
242
|
+
# Try in package directories
|
|
243
|
+
for pkg_dir in self.project_root.iterdir():
|
|
244
|
+
if pkg_dir.is_dir() and not pkg_dir.name.startswith("_"):
|
|
245
|
+
py_path = pkg_dir / f"{parts}.py"
|
|
246
|
+
if py_path.exists():
|
|
247
|
+
return py_path
|
|
248
|
+
|
|
249
|
+
init_path = pkg_dir / parts / "__init__.py"
|
|
250
|
+
if init_path.exists():
|
|
251
|
+
return init_path
|
|
252
|
+
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
def _get_help_arguments(self, command_name: str) -> List[str]:
|
|
256
|
+
"""Get command arguments by running --help."""
|
|
257
|
+
try:
|
|
258
|
+
result = subprocess.run(
|
|
259
|
+
[command_name, "--help"],
|
|
260
|
+
capture_output=True,
|
|
261
|
+
text=True,
|
|
262
|
+
timeout=5,
|
|
263
|
+
cwd=str(self.project_root)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if result.returncode == 0:
|
|
267
|
+
# Parse help output for arguments
|
|
268
|
+
args = []
|
|
269
|
+
for line in result.stdout.splitlines():
|
|
270
|
+
line = line.strip()
|
|
271
|
+
if line.startswith("--") or line.startswith("-"):
|
|
272
|
+
# Extract argument name
|
|
273
|
+
arg_match = re.match(r'^(-{1,2}[\w-]+)', line)
|
|
274
|
+
if arg_match:
|
|
275
|
+
args.append(arg_match.group(1))
|
|
276
|
+
return args
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
return []
|
|
281
|
+
|
|
282
|
+
def to_dict(self) -> Dict:
|
|
283
|
+
"""Convert scan results to dictionary."""
|
|
284
|
+
return {
|
|
285
|
+
"packages": [
|
|
286
|
+
{
|
|
287
|
+
"name": pkg.name,
|
|
288
|
+
"commands": [
|
|
289
|
+
{
|
|
290
|
+
"name": cmd.name,
|
|
291
|
+
"entry_point": cmd.entry_point,
|
|
292
|
+
"module": cmd.module,
|
|
293
|
+
"function": cmd.function,
|
|
294
|
+
"args": cmd.args,
|
|
295
|
+
}
|
|
296
|
+
for cmd in pkg.commands
|
|
297
|
+
],
|
|
298
|
+
"entry_points": pkg.entry_points,
|
|
299
|
+
}
|
|
300
|
+
for pkg in self.packages
|
|
301
|
+
]
|
|
302
|
+
}
|
|
@@ -6,9 +6,11 @@ import hashlib
|
|
|
6
6
|
import json
|
|
7
7
|
import re
|
|
8
8
|
import subprocess
|
|
9
|
+
import time
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
from typing import Any, Optional
|
|
11
12
|
|
|
13
|
+
import yaml
|
|
12
14
|
from rich.console import Console
|
|
13
15
|
|
|
14
16
|
from .models.config import PlanfileConfig
|
|
@@ -85,6 +87,9 @@ class PlanfileReporter:
|
|
|
85
87
|
self._save_dedupe(remaining)
|
|
86
88
|
|
|
87
89
|
def _create_ticket(self, *, name: str, description: str, track_file: str = "") -> Optional[tuple[str, str]]:
|
|
90
|
+
if not self._wait_for_planfile_store_ready():
|
|
91
|
+
return None
|
|
92
|
+
|
|
88
93
|
cmd = [
|
|
89
94
|
self.config.command,
|
|
90
95
|
"ticket",
|
|
@@ -123,9 +128,41 @@ class PlanfileReporter:
|
|
|
123
128
|
self.console.print(f"[yellow]planfile ticket creation failed: {detail}[/yellow]")
|
|
124
129
|
return None
|
|
125
130
|
|
|
131
|
+
if not self._wait_for_planfile_store_ready(timeout_s=10.0):
|
|
132
|
+
self.console.print("[yellow]planfile ticket created, but sprint YAML did not become readable[/yellow]")
|
|
133
|
+
return None
|
|
134
|
+
|
|
126
135
|
ticket_id = self._parse_ticket_id(stdout) or self._parse_ticket_id(stderr) or "unknown"
|
|
127
136
|
return ticket_id, stdout
|
|
128
137
|
|
|
138
|
+
def _wait_for_planfile_store_ready(self, timeout_s: float = 30.0) -> bool:
|
|
139
|
+
"""Wait until the current sprint YAML is readable and not mid-write."""
|
|
140
|
+
sprint_path = self.project_root / ".planfile" / "sprints" / f"{self.config.sprint}.yaml"
|
|
141
|
+
if not sprint_path.exists():
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
deadline = time.time() + timeout_s
|
|
145
|
+
last_signature: tuple[int, int] | None = None
|
|
146
|
+
while time.time() < deadline:
|
|
147
|
+
try:
|
|
148
|
+
stat = sprint_path.stat()
|
|
149
|
+
signature = (stat.st_size, stat.st_mtime_ns)
|
|
150
|
+
yaml.safe_load(sprint_path.read_text(encoding="utf-8")) or {}
|
|
151
|
+
except (OSError, yaml.YAMLError):
|
|
152
|
+
time.sleep(0.25)
|
|
153
|
+
last_signature = None
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
if signature == last_signature:
|
|
157
|
+
return True
|
|
158
|
+
last_signature = signature
|
|
159
|
+
time.sleep(0.25)
|
|
160
|
+
|
|
161
|
+
self.console.print(
|
|
162
|
+
f"[yellow]planfile ticket creation skipped: {sprint_path} is not stable/readable[/yellow]"
|
|
163
|
+
)
|
|
164
|
+
return False
|
|
165
|
+
|
|
129
166
|
def _load_dedupe(self) -> dict[str, dict[str, Any]]:
|
|
130
167
|
if not self.dedupe_path.exists():
|
|
131
168
|
return {}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Generator for TestQL scenarios for CLI testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from .cli_scanner import CLIScanner, CLIPackage, CLICommand
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestQLCLIGenerator:
|
|
12
|
+
"""Generate TestQL scenarios for CLI command testing."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, project_root: str):
|
|
15
|
+
self.project_root = Path(project_root).resolve()
|
|
16
|
+
self.scanner = CLIScanner(project_root)
|
|
17
|
+
|
|
18
|
+
def generate(
|
|
19
|
+
self,
|
|
20
|
+
output_dir: Optional[Path] = None,
|
|
21
|
+
infer_args: bool = True,
|
|
22
|
+
) -> List[Path]:
|
|
23
|
+
"""Generate TestQL scenarios for CLI commands.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
output_dir: Directory to save scenarios (default: testql-scenarios/)
|
|
27
|
+
infer_args: If True, infer command arguments by inspection
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
List of generated scenario file paths
|
|
31
|
+
"""
|
|
32
|
+
# Scan for CLI commands
|
|
33
|
+
packages = self.scanner.scan()
|
|
34
|
+
|
|
35
|
+
if not packages:
|
|
36
|
+
raise ValueError("No CLI packages found in project")
|
|
37
|
+
|
|
38
|
+
# Set output directory
|
|
39
|
+
if output_dir is None:
|
|
40
|
+
output_dir = self.project_root / "testql-scenarios"
|
|
41
|
+
|
|
42
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
generated_files = []
|
|
45
|
+
|
|
46
|
+
# Generate smoke test scenario
|
|
47
|
+
smoke_file = self._generate_smoke_scenario(packages, output_dir)
|
|
48
|
+
generated_files.append(smoke_file)
|
|
49
|
+
|
|
50
|
+
# Generate individual command scenarios
|
|
51
|
+
for package in packages:
|
|
52
|
+
for command in package.commands:
|
|
53
|
+
if infer_args:
|
|
54
|
+
command.args = self.scanner.infer_command_args(command)
|
|
55
|
+
|
|
56
|
+
command_file = self._generate_command_scenario(
|
|
57
|
+
package, command, output_dir
|
|
58
|
+
)
|
|
59
|
+
generated_files.append(command_file)
|
|
60
|
+
|
|
61
|
+
return generated_files
|
|
62
|
+
|
|
63
|
+
def _generate_smoke_scenario(
|
|
64
|
+
self, packages: List[CLIPackage], output_dir: Path
|
|
65
|
+
) -> Path:
|
|
66
|
+
"""Generate smoke test scenario for all commands."""
|
|
67
|
+
output_path = output_dir / "cli-smoke.testql.toon.yaml"
|
|
68
|
+
|
|
69
|
+
lines = [
|
|
70
|
+
"# SCENARIO: CLI Smoke Tests",
|
|
71
|
+
"# TYPE: cli",
|
|
72
|
+
"# GENERATED: true",
|
|
73
|
+
"",
|
|
74
|
+
"CONFIG[2]{key, value}:",
|
|
75
|
+
f" cli_command, {packages[0].commands[0].name if packages and packages[0].commands else 'python -m'}",
|
|
76
|
+
" timeout_ms, 15000",
|
|
77
|
+
"",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
# Add basic tests for each command
|
|
81
|
+
for package in packages:
|
|
82
|
+
for command in package.commands:
|
|
83
|
+
lines.extend([
|
|
84
|
+
"",
|
|
85
|
+
f"# Test: {command.name} --help",
|
|
86
|
+
f'SHELL "{command.name} --help" 5000',
|
|
87
|
+
"ASSERT_EXIT_CODE 0",
|
|
88
|
+
"ASSERT_STDOUT_CONTAINS \"usage\"",
|
|
89
|
+
])
|
|
90
|
+
|
|
91
|
+
lines.extend([
|
|
92
|
+
"",
|
|
93
|
+
f"# Test: {command.name} --version",
|
|
94
|
+
f'SHELL "{command.name} --version" 5000',
|
|
95
|
+
"ASSERT_EXIT_CODE 0",
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
output_path.write_text("\n".join(lines), encoding="utf-8")
|
|
99
|
+
return output_path
|
|
100
|
+
|
|
101
|
+
def _generate_command_scenario(
|
|
102
|
+
self, package: CLIPackage, command: CLICommand, output_dir: Path
|
|
103
|
+
) -> Path:
|
|
104
|
+
"""Generate detailed test scenario for a single command."""
|
|
105
|
+
safe_name = command.name.replace("-", "_").replace(" ", "_")
|
|
106
|
+
output_path = output_dir / f"cli-{safe_name}.testql.toon.yaml"
|
|
107
|
+
|
|
108
|
+
lines = [
|
|
109
|
+
f"# SCENARIO: {command.name} Command Tests",
|
|
110
|
+
"# TYPE: cli",
|
|
111
|
+
"# GENERATED: true",
|
|
112
|
+
"",
|
|
113
|
+
"CONFIG[2]{key, value}:",
|
|
114
|
+
f" cli_command, {command.name}",
|
|
115
|
+
" timeout_ms, 30000",
|
|
116
|
+
"",
|
|
117
|
+
f"# Test 1: {command.name} --help",
|
|
118
|
+
f'SHELL "{command.name} --help" 5000',
|
|
119
|
+
"ASSERT_EXIT_CODE 0",
|
|
120
|
+
"ASSERT_STDOUT_CONTAINS \"usage\"",
|
|
121
|
+
"",
|
|
122
|
+
f"# Test 2: {command.name} --version",
|
|
123
|
+
f'SHELL "{command.name} --version" 5000',
|
|
124
|
+
"ASSERT_EXIT_CODE 0",
|
|
125
|
+
"",
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
# Add tests for inferred arguments
|
|
129
|
+
if command.args:
|
|
130
|
+
lines.extend([
|
|
131
|
+
f"# Test 3: {command.name} with arguments",
|
|
132
|
+
])
|
|
133
|
+
|
|
134
|
+
# Test with first few arguments
|
|
135
|
+
for arg in command.args[:3]:
|
|
136
|
+
if arg.startswith("-"):
|
|
137
|
+
lines.extend([
|
|
138
|
+
f'SHELL "{command.name} {arg}" 10000',
|
|
139
|
+
"ASSERT_EXIT_CODE 0",
|
|
140
|
+
])
|
|
141
|
+
|
|
142
|
+
# Add test for invalid flag (should fail)
|
|
143
|
+
lines.extend([
|
|
144
|
+
"",
|
|
145
|
+
f"# Test: {command.name} with invalid flag (should fail)",
|
|
146
|
+
f'SHELL "{command.name} --invalid-flag-xyz123" 5000',
|
|
147
|
+
"ASSERT_EXIT_CODE != 0",
|
|
148
|
+
])
|
|
149
|
+
|
|
150
|
+
output_path.write_text("\n".join(lines), encoding="utf-8")
|
|
151
|
+
return output_path
|
|
152
|
+
|
|
153
|
+
def generate_custom_scenario(
|
|
154
|
+
self,
|
|
155
|
+
commands: List[str],
|
|
156
|
+
scenario_name: str = "custom-cli",
|
|
157
|
+
output_dir: Optional[Path] = None,
|
|
158
|
+
) -> Path:
|
|
159
|
+
"""Generate custom TestQL scenario for specific commands.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
commands: List of command strings to test
|
|
163
|
+
scenario_name: Name for the scenario file
|
|
164
|
+
output_dir: Directory to save scenario
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Path to generated scenario file
|
|
168
|
+
"""
|
|
169
|
+
if output_dir is None:
|
|
170
|
+
output_dir = self.project_root / "testql-scenarios"
|
|
171
|
+
|
|
172
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
output_path = output_dir / f"{scenario_name}.testql.toon.yaml"
|
|
174
|
+
|
|
175
|
+
lines = [
|
|
176
|
+
f"# SCENARIO: {scenario_name}",
|
|
177
|
+
"# TYPE: cli",
|
|
178
|
+
"# GENERATED: true",
|
|
179
|
+
"",
|
|
180
|
+
"CONFIG[2]{key, value}:",
|
|
181
|
+
" cli_command, custom",
|
|
182
|
+
" timeout_ms, 30000",
|
|
183
|
+
"",
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
for i, cmd in enumerate(commands, 1):
|
|
187
|
+
lines.extend([
|
|
188
|
+
"",
|
|
189
|
+
f"# Test {i}: {cmd}",
|
|
190
|
+
f'SHELL "{cmd}" 15000',
|
|
191
|
+
"ASSERT_EXIT_CODE 0",
|
|
192
|
+
])
|
|
193
|
+
|
|
194
|
+
output_path.write_text("\n".join(lines), encoding="utf-8")
|
|
195
|
+
return output_path
|
|
196
|
+
|
|
197
|
+
def print_summary(self, generated_files: List[Path]) -> None:
|
|
198
|
+
"""Print summary of generated scenarios."""
|
|
199
|
+
from rich.console import Console
|
|
200
|
+
from rich.table import Table
|
|
201
|
+
|
|
202
|
+
console = Console()
|
|
203
|
+
|
|
204
|
+
console.print("\n[bold green]✓ Generated TestQL scenarios[/bold green]\n")
|
|
205
|
+
|
|
206
|
+
table = Table(title="Generated Scenarios")
|
|
207
|
+
table.add_column("File", style="cyan")
|
|
208
|
+
table.add_column("Type", style="green")
|
|
209
|
+
|
|
210
|
+
for file_path in generated_files:
|
|
211
|
+
file_type = "Smoke" if "smoke" in file_path.name else "Command"
|
|
212
|
+
table.add_row(file_path.name, file_type)
|
|
213
|
+
|
|
214
|
+
console.print(table)
|
|
215
|
+
console.print(f"\nOutput directory: {generated_files[0].parent if generated_files else 'N/A'}")
|
|
@@ -179,33 +179,69 @@ def _service_path_patterns(services: Sequence[ServiceConfig]) -> Dict[str, List[
|
|
|
179
179
|
return patterns
|
|
180
180
|
|
|
181
181
|
|
|
182
|
+
def _find_service_by_name(services: Sequence[ServiceConfig], name: str) -> Optional[str]:
|
|
183
|
+
"""Find a service by case-insensitive name match."""
|
|
184
|
+
name_lower = name.lower()
|
|
185
|
+
for svc in services:
|
|
186
|
+
if svc.name.lower() == name_lower:
|
|
187
|
+
return svc.name
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _find_service_by_token(services: Sequence[ServiceConfig], token: str) -> Optional[str]:
|
|
192
|
+
"""Find a service by checking if token is in its name."""
|
|
193
|
+
token_lower = token.lower()
|
|
194
|
+
for svc in services:
|
|
195
|
+
if token_lower in svc.name.lower():
|
|
196
|
+
return svc.name
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _assign_by_port_8101(services: Sequence[ServiceConfig]) -> Optional[str]:
|
|
201
|
+
"""Assign probe to backend service for port 8101."""
|
|
202
|
+
return _find_service_by_name(services, "backend")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _assign_by_port_8202(services: Sequence[ServiceConfig]) -> Optional[str]:
|
|
206
|
+
"""Assign probe to firmware service for port 8202."""
|
|
207
|
+
return _find_service_by_token(services, "firmware")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _assign_by_port_8100(
|
|
211
|
+
services: Sequence[ServiceConfig], path_lower: str
|
|
212
|
+
) -> Optional[str]:
|
|
213
|
+
"""Assign probe for port 8100 (frontend proxy)."""
|
|
214
|
+
if path_lower.startswith("/firmware"):
|
|
215
|
+
return _find_service_by_token(services, "firmware")
|
|
216
|
+
return _find_service_by_name(services, "frontend")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _assign_by_connect_backend(
|
|
220
|
+
services: Sequence[ServiceConfig], path_lower: str
|
|
221
|
+
) -> Optional[str]:
|
|
222
|
+
"""Assign probe to connect-* backend services."""
|
|
223
|
+
for svc in services:
|
|
224
|
+
token = svc.name.lower().replace("_", "-")
|
|
225
|
+
if token.startswith("connect-") and token.replace("connect-", "") in path_lower:
|
|
226
|
+
return svc.name
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
182
230
|
def _assign_http_probe(
|
|
183
231
|
probe: ProbeTarget, services: Sequence[ServiceConfig], path_lower: str
|
|
184
232
|
) -> Optional[str]:
|
|
185
233
|
"""Map an HTTP probe to a service based on port and path."""
|
|
186
|
-
wup_names = {s.name.lower() for s in services}
|
|
187
234
|
parsed = urlparse(probe.url)
|
|
188
235
|
port = parsed.port
|
|
189
236
|
|
|
190
|
-
if port == 8101
|
|
191
|
-
return
|
|
237
|
+
if port == 8101:
|
|
238
|
+
return _assign_by_port_8101(services)
|
|
192
239
|
if port == 8202:
|
|
193
|
-
|
|
194
|
-
if "firmware" in svc.name.lower():
|
|
195
|
-
return svc.name
|
|
240
|
+
return _assign_by_port_8202(services)
|
|
196
241
|
if port == 8100:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return svc.name
|
|
201
|
-
if "frontend" in wup_names:
|
|
202
|
-
return next(s.name for s in services if s.name.lower() == "frontend")
|
|
203
|
-
# Connect-* backends on 8103+ — only if a matching WUP service exists
|
|
204
|
-
for svc in services:
|
|
205
|
-
token = svc.name.lower().replace("_", "-")
|
|
206
|
-
if token.startswith("connect-") and token.replace("connect-", "") in path_lower:
|
|
207
|
-
return svc.name
|
|
208
|
-
return None
|
|
242
|
+
return _assign_by_port_8100(services, path_lower)
|
|
243
|
+
|
|
244
|
+
return _assign_by_connect_backend(services, path_lower)
|
|
209
245
|
|
|
210
246
|
|
|
211
247
|
def _assign_by_longest_token(
|
|
@@ -15,7 +15,6 @@ from urllib import error, request
|
|
|
15
15
|
from .config import load_config
|
|
16
16
|
from .core import WupWatcher
|
|
17
17
|
from .models.config import WupConfig, ServiceConfig
|
|
18
|
-
from .testql_monitor import TestQLMonitor
|
|
19
18
|
from .visual_diff import VisualDiffer
|
|
20
19
|
from .web_client import WebClient
|
|
21
20
|
|
|
@@ -97,6 +96,7 @@ class TestQLWatcher(WupWatcher):
|
|
|
97
96
|
self.health_state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
97
|
self.service_health = self._load_service_health()
|
|
99
98
|
self.config = config
|
|
99
|
+
from .testql_monitor import TestQLMonitor
|
|
100
100
|
self.monitor = TestQLMonitor(self.project_root, config) if config else None
|
|
101
101
|
self.visual_differ = VisualDiffer(project_root, config.visual_diff) if config and config.visual_diff else None
|
|
102
102
|
self.web_client = WebClient(config.web) if config and getattr(config, "web", None) else WebClient()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wup
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.37
|
|
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
|
|
@@ -31,17 +31,17 @@ Dynamic: license-file
|
|
|
31
31
|
|
|
32
32
|
## AI Cost Tracking
|
|
33
33
|
|
|
34
|
-
    
|
|
35
|
+
  
|
|
36
36
|
|
|
37
|
-
- 🤖 **LLM usage:** $2.
|
|
38
|
-
- 👤 **Human dev:** ~$
|
|
37
|
+
- 🤖 **LLM usage:** $2.7096 (47 commits)
|
|
38
|
+
- 👤 **Human dev:** ~$1947 (19.5h @ $100/h, 30min dedup)
|
|
39
39
|
|
|
40
|
-
Generated on 2026-05-
|
|
40
|
+
Generated on 2026-05-22 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
41
41
|
|
|
42
42
|
---
|
|
43
43
|
|
|
44
|
-
    
|
|
45
45
|
|
|
46
46
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
47
47
|
|
|
@@ -15,11 +15,14 @@ wup/anomaly_detector.py
|
|
|
15
15
|
wup/anomaly_models.py
|
|
16
16
|
wup/assistant.py
|
|
17
17
|
wup/cli.py
|
|
18
|
+
wup/cli_config_generator.py
|
|
19
|
+
wup/cli_scanner.py
|
|
18
20
|
wup/config.py
|
|
19
21
|
wup/core.py
|
|
20
22
|
wup/dependency_mapper.py
|
|
21
23
|
wup/monitoring_manifest.py
|
|
22
24
|
wup/planfile_reporter.py
|
|
25
|
+
wup/testql_cli_generator.py
|
|
23
26
|
wup/testql_discovery.py
|
|
24
27
|
wup/testql_monitor.py
|
|
25
28
|
wup/testql_watcher.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|