wup 0.1.8__tar.gz → 0.1.10__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wup
3
- Version: 0.1.8
3
+ Version: 0.1.10
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
@@ -28,17 +28,17 @@ Dynamic: license-file
28
28
 
29
29
  ## AI Cost Tracking
30
30
 
31
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.1.8-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
32
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$0.30-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-2.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
31
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.1.10-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
32
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$0.60-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-2.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
33
33
 
34
- - 🤖 **LLM usage:** $0.3000 (2 commits)
34
+ - 🤖 **LLM usage:** $0.6000 (4 commits)
35
35
  - 👤 **Human dev:** ~$200 (2.0h @ $100/h, 30min dedup)
36
36
 
37
37
  Generated on 2026-04-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
38
38
 
39
39
  ---
40
40
 
41
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.1.8-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
41
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.1.10-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
42
42
 
43
43
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
44
44
 
@@ -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.1.8-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-$0.30-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-2.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.1.10-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-$0.60-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-2.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $0.3000 (2 commits)
9
+ - 🤖 **LLM usage:** $0.6000 (4 commits)
10
10
  - 👤 **Human dev:** ~$200 (2.0h @ $100/h, 30min dedup)
11
11
 
12
12
  Generated on 2026-04-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
13
13
 
14
14
  ---
15
15
 
16
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.1.8-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.1.10-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.1.8"
7
+ version = "0.1.10"
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.1.8"
10
+ __version__ = "0.1.10"
11
11
  __author__ = "Tom Sapletta"
12
12
 
13
13
  from .core import WupWatcher
@@ -67,6 +67,13 @@ class WupWatcher:
67
67
  self.console.print(f"[yellow]Building dependency map...[/yellow]")
68
68
  self.dependency_mapper.build_from_codebase()
69
69
  self.dependency_mapper.save(deps_file)
70
+
71
+ def _to_relative_path(self, file_path: str) -> Path:
72
+ file_path_obj = Path(file_path)
73
+ try:
74
+ return file_path_obj.relative_to(self.project_root)
75
+ except ValueError:
76
+ return file_path_obj
70
77
 
71
78
  def infer_service(self, file_path: str) -> Optional[str]:
72
79
  """
@@ -76,7 +83,7 @@ class WupWatcher:
76
83
  app/users/routes.py → "app/users"
77
84
  src/components/auth.ts → "src/components"
78
85
  """
79
- rel_path = Path(file_path).relative_to(self.project_root)
86
+ rel_path = self._to_relative_path(file_path)
80
87
  parts = rel_path.parts
81
88
 
82
89
  # Use dependency mapper if available
@@ -112,10 +119,8 @@ class WupWatcher:
112
119
  service: Service name to test
113
120
  """
114
121
  endpoints = self.dependency_mapper.get_endpoints_for_service(service)
115
- if endpoints:
116
- # Limit to 3 endpoints for quick test
117
- self.test_queue.append(("quick", service, endpoints[:3]))
118
- self.last_test_times[service] = time.time()
122
+ self.test_queue.append(("quick", service, endpoints[:3]))
123
+ self.last_test_times[service] = time.time()
119
124
 
120
125
  def schedule_detail_test(self, service: str):
121
126
  """
@@ -125,8 +130,23 @@ class WupWatcher:
125
130
  service: Service name to test
126
131
  """
127
132
  endpoints = self.dependency_mapper.get_endpoints_for_service(service)
128
- if endpoints:
129
- self.test_queue.appendleft(("detail", service, endpoints))
133
+ self.test_queue.appendleft(("detail", service, endpoints))
134
+
135
+ async def process_test_queue_once(self):
136
+ if not self.test_queue or not await self.cpu_ok():
137
+ return
138
+
139
+ test_type, service, endpoints = self.test_queue.popleft()
140
+
141
+ try:
142
+ if test_type == "quick":
143
+ passed = await self.run_quick_test(service, endpoints)
144
+ if not passed:
145
+ self.schedule_detail_test(service)
146
+ elif test_type == "detail":
147
+ await self.run_detail_test(service, endpoints)
148
+ except Exception as e:
149
+ self.console.print(f"[red]Error testing {service}: {e}[/red]")
130
150
 
131
151
  async def cpu_ok(self) -> bool:
132
152
  """
@@ -212,20 +232,7 @@ class WupWatcher:
212
232
  async def test_loop(self):
213
233
  """Main test execution loop."""
214
234
  while True:
215
- if self.test_queue and await self.cpu_ok():
216
- test_type, service, endpoints = self.test_queue.popleft()
217
-
218
- try:
219
- if test_type == "quick":
220
- passed = await self.run_quick_test(service, endpoints)
221
- if not passed:
222
- # Escalate to detail test
223
- self.schedule_detail_test(service)
224
- elif test_type == "detail":
225
- await self.run_detail_test(service, endpoints)
226
- except Exception as e:
227
- self.console.print(f"[red]Error testing {service}: {e}[/red]")
228
-
235
+ await self.process_test_queue_once()
229
236
  await asyncio.sleep(self.debounce_seconds)
230
237
 
231
238
  def on_file_change(self, file_path: str):
@@ -236,7 +243,7 @@ class WupWatcher:
236
243
  file_path: Path to the changed file
237
244
  """
238
245
  # Only watch relevant directories
239
- rel_path = Path(file_path).relative_to(self.project_root)
246
+ rel_path = self._to_relative_path(file_path)
240
247
  parts = rel_path.parts
241
248
 
242
249
  # Skip certain directories
@@ -284,6 +291,7 @@ class WupWatcher:
284
291
 
285
292
  try:
286
293
  while True:
294
+ asyncio.run(self.process_test_queue_once())
287
295
  time.sleep(1)
288
296
  except KeyboardInterrupt:
289
297
  observer.stop()
@@ -343,16 +351,7 @@ class WupWatcher:
343
351
  with Live(self.create_status_table(), refresh_per_second=1) as live:
344
352
  try:
345
353
  while True:
346
- # Run test loop
347
- if self.test_queue and await self.cpu_ok():
348
- test_type, service, endpoints = self.test_queue.popleft()
349
-
350
- if test_type == "quick":
351
- passed = await self.run_quick_test(service, endpoints)
352
- if not passed:
353
- self.schedule_detail_test(service)
354
- elif test_type == "detail":
355
- await self.run_detail_test(service, endpoints)
354
+ await self.process_test_queue_once()
356
355
 
357
356
  live.update(self.create_status_table())
358
357
  await asyncio.sleep(1)
@@ -0,0 +1,247 @@
1
+ """TestQL integration for WUP watcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import re
8
+ import subprocess
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Dict, List, Optional, Sequence
12
+ from urllib import error, request
13
+
14
+ from .core import WupWatcher
15
+
16
+
17
+ class BrowserNotifier:
18
+ """Send watcher events to browser-facing service and local file."""
19
+
20
+ def __init__(self, service_url: Optional[str], events_file: Path):
21
+ self.service_url = service_url
22
+ self.events_file = events_file
23
+ self.events_file.parent.mkdir(parents=True, exist_ok=True)
24
+
25
+ def notify(self, payload: Dict) -> None:
26
+ payload_with_ts = {"timestamp": int(time.time()), **payload}
27
+ self.events_file.write_text(json.dumps(payload_with_ts, indent=2), encoding="utf-8")
28
+
29
+ if not self.service_url:
30
+ return
31
+
32
+ body = json.dumps(payload_with_ts).encode("utf-8")
33
+ req = request.Request(
34
+ self.service_url,
35
+ data=body,
36
+ headers={"Content-Type": "application/json"},
37
+ method="POST",
38
+ )
39
+ try:
40
+ with request.urlopen(req, timeout=5):
41
+ return
42
+ except (error.URLError, TimeoutError):
43
+ return
44
+
45
+
46
+ class TestQLWatcher(WupWatcher):
47
+ """WUP watcher running selective TestQL scenarios for changed services."""
48
+
49
+ def __init__(
50
+ self,
51
+ project_root: str,
52
+ scenarios_dir: str = "testql-scenarios",
53
+ testql_bin: str = "testql",
54
+ track_dir: str = ".wup/tracks",
55
+ browser_service_url: Optional[str] = None,
56
+ quick_limit: int = 3,
57
+ **kwargs,
58
+ ):
59
+ super().__init__(project_root=project_root, **kwargs)
60
+ self.scenarios_dir = self.project_root / scenarios_dir
61
+ self.testql_bin = testql_bin
62
+ self.quick_limit = quick_limit
63
+ self.track_dir = self.project_root / track_dir
64
+ self.track_dir.mkdir(parents=True, exist_ok=True)
65
+ self.browser_notifier = BrowserNotifier(
66
+ service_url=browser_service_url,
67
+ events_file=self.project_root / ".wup" / "browser-events" / "latest.json",
68
+ )
69
+ self.last_track_path: Optional[Path] = None
70
+
71
+ def _tokenize_service(self, service: str) -> List[str]:
72
+ raw_tokens = re.split(r"[^a-zA-Z0-9]+", service.lower())
73
+ return [token for token in raw_tokens if len(token) >= 3]
74
+
75
+ def _discover_scenarios(self) -> List[Path]:
76
+ if not self.scenarios_dir.exists():
77
+ return []
78
+ return sorted(self.scenarios_dir.rglob("*.testql.toon.yaml"))
79
+
80
+ def _select_scenarios_for_service(self, service: str) -> List[Path]:
81
+ all_scenarios = self._discover_scenarios()
82
+ if not all_scenarios:
83
+ return []
84
+
85
+ tokens = self._tokenize_service(service)
86
+ scored: List[tuple[int, Path]] = []
87
+
88
+ for scenario in all_scenarios:
89
+ name = scenario.name.lower()
90
+ score = 0
91
+ if any(token in name for token in tokens):
92
+ score += 3
93
+ if "api" in name or "endpoint" in name:
94
+ score += 2
95
+ if "infra" in name or "smoke" in name:
96
+ score += 1
97
+ scored.append((score, scenario))
98
+
99
+ scored.sort(key=lambda item: (item[0], item[1].name), reverse=True)
100
+ selected = [scenario for score, scenario in scored if score > 0]
101
+
102
+ if selected:
103
+ return selected
104
+
105
+ return all_scenarios[: self.quick_limit]
106
+
107
+ def _run_testql(self, args: Sequence[str], timeout: int) -> subprocess.CompletedProcess:
108
+ cmd = [self.testql_bin, *args]
109
+ try:
110
+ return subprocess.run(
111
+ cmd,
112
+ cwd=str(self.project_root),
113
+ capture_output=True,
114
+ text=True,
115
+ timeout=timeout,
116
+ )
117
+ except FileNotFoundError:
118
+ fallback_cmd = ["python3", "-m", "testql.cli", *args]
119
+ return subprocess.run(
120
+ fallback_cmd,
121
+ cwd=str(self.project_root),
122
+ capture_output=True,
123
+ text=True,
124
+ timeout=timeout,
125
+ )
126
+
127
+ def _write_track(self, *, service: str, stage: str, scenario: Optional[Path], result: subprocess.CompletedProcess) -> Path:
128
+ ts = int(time.time())
129
+ safe_service = service.replace("/", "_").replace("\\", "_")
130
+ scenario_name = scenario.name if scenario else "unknown"
131
+ stderr_line = (result.stderr or "").strip().splitlines()[:1]
132
+ stdout_line = (result.stdout or "").strip().splitlines()[:1]
133
+
134
+ payload = {
135
+ "service": service,
136
+ "stage": stage,
137
+ "scenario": str(scenario) if scenario else None,
138
+ "command": result.args,
139
+ "returncode": result.returncode,
140
+ "stderr_head": stderr_line[0] if stderr_line else "",
141
+ "stdout_head": stdout_line[0] if stdout_line else "",
142
+ "track": {
143
+ "file": str(scenario) if scenario else "",
144
+ "line": 1,
145
+ "hint": scenario_name,
146
+ },
147
+ }
148
+
149
+ track_path = self.track_dir / f"{ts}_{safe_service}_{stage}.json"
150
+ track_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
151
+ self.last_track_path = track_path
152
+
153
+ self.browser_notifier.notify(
154
+ {
155
+ "type": "wup_testql_error",
156
+ "service": service,
157
+ "stage": stage,
158
+ "track_file": str(track_path),
159
+ "scenario": str(scenario) if scenario else None,
160
+ "message": payload["stderr_head"] or payload["stdout_head"] or "TestQL command failed",
161
+ }
162
+ )
163
+ return track_path
164
+
165
+ async def run_quick_test(self, service: str, endpoints: List[str]) -> bool:
166
+ scenarios = self._select_scenarios_for_service(service)[: self.quick_limit]
167
+ if not scenarios:
168
+ self.console.print(f"[yellow]⚠ No TestQL scenarios found for {service}[/yellow]")
169
+ return True
170
+
171
+ self.console.print(
172
+ f"[cyan]🧪 Quick TestQL for {service} ({len(scenarios)} scenarios / {len(endpoints)} endpoints)[/cyan]"
173
+ )
174
+
175
+ for scenario in scenarios:
176
+ result = self._run_testql(["run", str(scenario), "--dry-run"], timeout=60)
177
+ if result.returncode != 0:
178
+ track_path = self._write_track(
179
+ service=service,
180
+ stage="quick",
181
+ scenario=scenario,
182
+ result=result,
183
+ )
184
+ self.console.print(
185
+ f"[red]✗ Quick failed: {scenario.name} | track: {track_path}[/red]"
186
+ )
187
+ return False
188
+
189
+ self.console.print(f"[green]✓ Quick TestQL passed for {service}[/green]")
190
+ return True
191
+
192
+ async def run_detail_test(self, service: str, endpoints: List[str]) -> Dict:
193
+ scenarios = self._select_scenarios_for_service(service)
194
+ results = {
195
+ "service": service,
196
+ "total_scenarios": len(scenarios),
197
+ "passed": 0,
198
+ "failed": 0,
199
+ "failed_scenarios": [],
200
+ "track_files": [],
201
+ }
202
+
203
+ self.console.print(
204
+ f"[cyan]🔍 Detail TestQL for {service} ({len(scenarios)} scenarios / {len(endpoints)} endpoints)[/cyan]"
205
+ )
206
+
207
+ for scenario in scenarios:
208
+ result = self._run_testql(["run", str(scenario), "--output", "json"], timeout=180)
209
+ if result.returncode == 0:
210
+ results["passed"] += 1
211
+ continue
212
+
213
+ results["failed"] += 1
214
+ results["failed_scenarios"].append(str(scenario))
215
+ track_path = self._write_track(
216
+ service=service,
217
+ stage="detail",
218
+ scenario=scenario,
219
+ result=result,
220
+ )
221
+ results["track_files"].append(str(track_path))
222
+ self.console.print(
223
+ f"[red]✗ Detail failed: {scenario.name} | track: {track_path}[/red]"
224
+ )
225
+
226
+ if results["failed"] == 0:
227
+ self.console.print(
228
+ f"[green]✓ Detail TestQL passed for {service} ({results['passed']} scenarios)[/green]"
229
+ )
230
+
231
+ return results
232
+
233
+ async def process_changed_file_once(self, file_path: str) -> Dict:
234
+ self.on_file_change(file_path)
235
+
236
+ processed = 0
237
+ while self.test_queue and processed < 4:
238
+ await self.process_test_queue_once()
239
+ processed += 1
240
+ await asyncio.sleep(0)
241
+
242
+ return {
243
+ "file": file_path,
244
+ "processed_items": processed,
245
+ "remaining_queue": len(self.test_queue),
246
+ "last_track_path": str(self.last_track_path) if self.last_track_path else None,
247
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wup
3
- Version: 0.1.8
3
+ Version: 0.1.10
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
@@ -28,17 +28,17 @@ Dynamic: license-file
28
28
 
29
29
  ## AI Cost Tracking
30
30
 
31
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.1.8-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
32
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$0.30-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-2.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
31
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.1.10-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
32
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$0.60-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-2.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
33
33
 
34
- - 🤖 **LLM usage:** $0.3000 (2 commits)
34
+ - 🤖 **LLM usage:** $0.6000 (4 commits)
35
35
  - 👤 **Human dev:** ~$200 (2.0h @ $100/h, 30min dedup)
36
36
 
37
37
  Generated on 2026-04-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
38
38
 
39
39
  ---
40
40
 
41
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.1.8-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
41
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.1.10-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
42
42
 
43
43
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
44
44
 
@@ -6,6 +6,7 @@ wup/__init__.py
6
6
  wup/cli.py
7
7
  wup/core.py
8
8
  wup/dependency_mapper.py
9
+ wup/testql_watcher.py
9
10
  wup.egg-info/PKG-INFO
10
11
  wup.egg-info/SOURCES.txt
11
12
  wup.egg-info/dependency_links.txt
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes