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.
Files changed (40) hide show
  1. {wup-0.2.34/wup.egg-info → wup-0.2.37}/PKG-INFO +7 -7
  2. {wup-0.2.34 → wup-0.2.37}/README.md +6 -6
  3. {wup-0.2.34 → wup-0.2.37}/pyproject.toml +1 -1
  4. {wup-0.2.34 → wup-0.2.37}/wup/__init__.py +9 -2
  5. {wup-0.2.34 → wup-0.2.37}/wup/cli.py +81 -0
  6. wup-0.2.37/wup/cli_config_generator.py +223 -0
  7. wup-0.2.37/wup/cli_scanner.py +302 -0
  8. {wup-0.2.34 → wup-0.2.37}/wup/planfile_reporter.py +37 -0
  9. wup-0.2.37/wup/testql_cli_generator.py +215 -0
  10. {wup-0.2.34 → wup-0.2.37}/wup/testql_monitor.py +54 -18
  11. {wup-0.2.34 → wup-0.2.37}/wup/testql_watcher.py +1 -1
  12. {wup-0.2.34 → wup-0.2.37/wup.egg-info}/PKG-INFO +7 -7
  13. {wup-0.2.34 → wup-0.2.37}/wup.egg-info/SOURCES.txt +3 -0
  14. {wup-0.2.34 → wup-0.2.37}/LICENSE +0 -0
  15. {wup-0.2.34 → wup-0.2.37}/setup.cfg +0 -0
  16. {wup-0.2.34 → wup-0.2.37}/tests/test_e2e.py +0 -0
  17. {wup-0.2.34 → wup-0.2.37}/tests/test_monitoring_manifest.py +0 -0
  18. {wup-0.2.34 → wup-0.2.37}/tests/test_testql_monitor.py +0 -0
  19. {wup-0.2.34 → wup-0.2.37}/tests/test_testql_watcher.py +0 -0
  20. {wup-0.2.34 → wup-0.2.37}/tests/test_web_client.py +0 -0
  21. {wup-0.2.34 → wup-0.2.37}/tests/test_wup.py +0 -0
  22. {wup-0.2.34 → wup-0.2.37}/wup/_ast_detector.py +0 -0
  23. {wup-0.2.34 → wup-0.2.37}/wup/_hash_detector.py +0 -0
  24. {wup-0.2.34 → wup-0.2.37}/wup/_yaml_detector.py +0 -0
  25. {wup-0.2.34 → wup-0.2.37}/wup/anomaly_detector.py +0 -0
  26. {wup-0.2.34 → wup-0.2.37}/wup/anomaly_models.py +0 -0
  27. {wup-0.2.34 → wup-0.2.37}/wup/assistant.py +0 -0
  28. {wup-0.2.34 → wup-0.2.37}/wup/config.py +0 -0
  29. {wup-0.2.34 → wup-0.2.37}/wup/core.py +0 -0
  30. {wup-0.2.34 → wup-0.2.37}/wup/dependency_mapper.py +0 -0
  31. {wup-0.2.34 → wup-0.2.37}/wup/models/__init__.py +0 -0
  32. {wup-0.2.34 → wup-0.2.37}/wup/models/config.py +0 -0
  33. {wup-0.2.34 → wup-0.2.37}/wup/monitoring_manifest.py +0 -0
  34. {wup-0.2.34 → wup-0.2.37}/wup/testql_discovery.py +0 -0
  35. {wup-0.2.34 → wup-0.2.37}/wup/visual_diff.py +0 -0
  36. {wup-0.2.34 → wup-0.2.37}/wup/web_client.py +0 -0
  37. {wup-0.2.34 → wup-0.2.37}/wup.egg-info/dependency_links.txt +0 -0
  38. {wup-0.2.34 → wup-0.2.37}/wup.egg-info/entry_points.txt +0 -0
  39. {wup-0.2.34 → wup-0.2.37}/wup.egg-info/requires.txt +0 -0
  40. {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.34
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.34-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
35
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.68-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-18.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
34
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.37-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
35
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.71-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-19.5h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
36
36
 
37
- - 🤖 **LLM usage:** $2.6840 (44 commits)
38
- - 👤 **Human dev:** ~$1867 (18.7h @ $100/h, 30min dedup)
37
+ - 🤖 **LLM usage:** $2.7096 (47 commits)
38
+ - 👤 **Human dev:** ~$1947 (19.5h @ $100/h, 30min dedup)
39
39
 
40
- Generated on 2026-05-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.34-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
44
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.37-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.34-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-$2.68-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-18.7h-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.37-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-$2.71-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-19.5h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $2.6840 (44 commits)
10
- - 👤 **Human dev:** ~$1867 (18.7h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $2.7096 (47 commits)
10
+ - 👤 **Human dev:** ~$1947 (19.5h @ $100/h, 30min dedup)
11
11
 
12
- Generated on 2026-05-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.34-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.37-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.34"
7
+ version = "0.2.37"
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.9"
@@ -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.34"
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 and "backend" in wup_names:
191
- return next(s.name for s in services if s.name.lower() == "backend")
237
+ if port == 8101:
238
+ return _assign_by_port_8101(services)
192
239
  if port == 8202:
193
- for svc in services:
194
- if "firmware" in svc.name.lower():
195
- return svc.name
240
+ return _assign_by_port_8202(services)
196
241
  if port == 8100:
197
- if path_lower.startswith("/firmware"):
198
- for svc in services:
199
- if "firmware" in svc.name.lower():
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.34
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.34-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
35
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.68-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-18.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
34
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.37-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
35
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.71-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-19.5h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
36
36
 
37
- - 🤖 **LLM usage:** $2.6840 (44 commits)
38
- - 👤 **Human dev:** ~$1867 (18.7h @ $100/h, 30min dedup)
37
+ - 🤖 **LLM usage:** $2.7096 (47 commits)
38
+ - 👤 **Human dev:** ~$1947 (19.5h @ $100/h, 30min dedup)
39
39
 
40
- Generated on 2026-05-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.34-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
44
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.37-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
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