woolly 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

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.
woolly/__main__.py CHANGED
@@ -1,482 +1,21 @@
1
- import argparse
2
- import sys
3
- from typing import Optional
4
- import requests
5
- import subprocess
6
- import re
7
- import json
8
- import hashlib
9
- import time
10
- from pathlib import Path
11
- from rich.tree import Tree
12
- from rich.console import Console
13
- from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn, TimeElapsedColumn, TaskID
14
- from rich import box
15
- from rich.table import Table
16
-
17
- CRATES_API = "https://crates.io/api/v1/crates"
18
- CACHE_DIR = Path.home() / ".cache" / "fedora-rust-checker"
19
- CACHE_TTL = 86400 * 7 # 7 days for crates.io data
20
- FEDORA_CACHE_TTL = 86400 # 1 day for Fedora repoquery data
21
-
22
- console = Console()
23
-
24
- # Required headers for crates.io API
25
- HEADERS = {"User-Agent": "fedora-rust-checker@0.1.0"}
26
-
27
-
28
- # ------------------------------------------------------------
29
- # Disk Cache Helpers
30
- # ------------------------------------------------------------
31
- def ensure_cache_dir():
32
- """Create cache directory if it doesn't exist."""
33
- CACHE_DIR.mkdir(parents=True, exist_ok=True)
34
- (CACHE_DIR / "crates").mkdir(exist_ok=True)
35
- (CACHE_DIR / "fedora").mkdir(exist_ok=True)
36
-
37
-
38
- def get_cache_path(namespace: str, key: str) -> Path:
39
- """Get path for a cache entry."""
40
- safe_key = hashlib.md5(key.encode()).hexdigest()
41
- return CACHE_DIR / namespace / f"{safe_key}.json"
42
-
43
-
44
- def read_cache(namespace: str, key: str, ttl: int = CACHE_TTL):
45
- """Read from disk cache if not expired."""
46
- path = get_cache_path(namespace, key)
47
- if not path.exists():
48
- return None
49
-
50
- try:
51
- data = json.loads(path.read_text())
52
- if time.time() - data.get("timestamp", 0) > ttl:
53
- return None # Expired
54
- return data.get("value")
55
- except (json.JSONDecodeError, KeyError):
56
- return None
57
-
58
-
59
- def write_cache(namespace: str, key: str, value):
60
- """Write to disk cache."""
61
- ensure_cache_dir()
62
- path = get_cache_path(namespace, key)
63
- data = {"timestamp": time.time(), "value": value}
64
- path.write_text(json.dumps(data))
65
-
66
-
67
- # ------------------------------------------------------------
68
- # Fedora repoquery helpers
69
- # ------------------------------------------------------------
70
- def repoquery_crate(crate: str):
71
- """
72
- Query Fedora for a crate using the virtual provides format: crate(name)
73
- Returns (is_packaged, versions_list, package_names)
74
- """
75
- cache_key = f"repoquery:{crate}"
76
- cached = read_cache("fedora", cache_key, FEDORA_CACHE_TTL)
77
- if cached is not None:
78
- return tuple(cached)
79
-
80
- provide_pattern = f"crate({crate})"
81
-
82
- try:
83
- out = subprocess.check_output(
84
- ["dnf", "repoquery", "--whatprovides", provide_pattern,
85
- "--queryformat", "%{NAME}|%{VERSION}"],
86
- stderr=subprocess.DEVNULL,
87
- ).decode().strip()
88
-
89
- if not out:
90
- result = (False, [], [])
91
- write_cache("fedora", cache_key, list(result))
92
- return result
93
-
94
- versions = set()
95
- packages = set()
96
- for line in out.split("\n"):
97
- if "|" in line:
98
- pkg, ver = line.split("|", 1)
99
- packages.add(pkg)
100
- versions.add(ver)
101
-
102
- result = (True, sorted(versions), sorted(packages))
103
- write_cache("fedora", cache_key, [result[0], result[1], result[2]])
104
- return result
105
- except subprocess.CalledProcessError:
106
- result = (False, [], [])
107
- write_cache("fedora", cache_key, list(result))
108
- return result
109
-
110
-
111
- def get_crate_provides_version(crate: str):
112
- """
113
- Get the actual crate version provided by Fedora packages.
114
- """
115
- cache_key = f"provides:{crate}"
116
- cached = read_cache("fedora", cache_key, FEDORA_CACHE_TTL)
117
- if cached is not None:
118
- return cached
119
-
120
- provide_pattern = f"crate({crate})"
121
-
122
- try:
123
- out = subprocess.check_output(
124
- ["dnf", "repoquery", "--provides", "--whatprovides", provide_pattern],
125
- stderr=subprocess.DEVNULL,
126
- ).decode().strip()
127
-
128
- if not out:
129
- write_cache("fedora", cache_key, [])
130
- return []
131
-
132
- versions = set()
133
- pattern = re.compile(rf"crate\({re.escape(crate)}\)\s*=\s*([\d.]+)")
134
- for line in out.split("\n"):
135
- match = pattern.search(line)
136
- if match:
137
- versions.add(match.group(1))
138
-
139
- result = sorted(versions)
140
- write_cache("fedora", cache_key, result)
141
- return result
142
- except subprocess.CalledProcessError:
143
- write_cache("fedora", cache_key, [])
144
- return []
145
-
146
-
147
- def crate_is_packaged(crate: str):
148
- """
149
- Check if a crate is packaged in Fedora.
150
- Returns (is_packaged, crate_versions, package_names)
151
- """
152
- is_packaged, pkg_versions, packages = repoquery_crate(crate)
153
-
154
- if not is_packaged:
155
- alt_name = crate.replace("-", "_")
156
- if alt_name != crate:
157
- is_packaged, pkg_versions, packages = repoquery_crate(alt_name)
158
-
159
- if not is_packaged:
160
- alt_name = crate.replace("_", "-")
161
- if alt_name != crate:
162
- is_packaged, pkg_versions, packages = repoquery_crate(alt_name)
163
-
164
- if is_packaged:
165
- crate_versions = get_crate_provides_version(crate)
166
- if not crate_versions:
167
- crate_versions = pkg_versions
168
- return (True, crate_versions, packages)
169
-
170
- return (False, [], [])
171
-
172
-
173
- # ------------------------------------------------------------
174
- # Crates.io API helpers
175
- # ------------------------------------------------------------
176
- def fetch_crate_info(crate_name: str):
177
- """Fetch basic crate info (latest version, etc.)"""
178
- cache_key = f"info:{crate_name}"
179
- cached = read_cache("crates", cache_key)
180
- if cached is not None:
181
- return cached
182
-
183
- url = f"{CRATES_API}/{crate_name}"
184
- r = requests.get(url, headers=HEADERS)
185
- if r.status_code == 404:
186
- write_cache("crates", cache_key, None)
187
- return None
188
- if r.status_code != 200:
189
- raise RuntimeError(f"Failed to fetch metadata for crate {crate_name}: {r.status_code}")
190
-
191
- data = r.json()
192
- write_cache("crates", cache_key, data)
193
- return data
194
-
195
-
196
- def fetch_dependencies(crate_name: str, version: str):
197
- """
198
- Fetch dependencies for a specific crate version.
199
- """
200
- cache_key = f"deps:{crate_name}:{version}"
201
- cached = read_cache("crates", cache_key)
202
- if cached is not None:
203
- return cached
204
-
205
- url = f"{CRATES_API}/{crate_name}/{version}/dependencies"
206
- r = requests.get(url, headers=HEADERS)
207
- if r.status_code != 200:
208
- write_cache("crates", cache_key, [])
209
- return []
210
-
211
- data = r.json()
212
- deps = data.get("dependencies", [])
213
- write_cache("crates", cache_key, deps)
214
- return deps
215
-
216
-
217
- def get_latest_version(crate_name: str):
218
- """Get the latest (newest) version of a crate."""
219
- info = fetch_crate_info(crate_name)
220
- if info is None:
221
- return None
222
- return info["crate"]["newest_version"]
223
-
224
-
225
- def get_normal_dependencies(crate_name: str, version: Optional[str] = None):
226
- """
227
- Returns normal (non-dev, non-build) dependencies as list of tuples:
228
- [("dep_name", "version_req"), ...]
229
- """
230
- if version is None:
231
- version = get_latest_version(crate_name)
232
- if version is None:
233
- return []
234
-
235
- deps = fetch_dependencies(crate_name, version)
236
- result = []
237
- for d in deps:
238
- if d.get("kind") == "normal":
239
- if d.get("optional", False):
240
- continue
241
- result.append((d["crate_id"], d["req"]))
242
- return result
243
-
244
-
245
- # ------------------------------------------------------------
246
- # Progress Tracker
247
- # ------------------------------------------------------------
248
- class ProgressTracker:
249
- def __init__(self):
250
- self.progress = Progress(
251
- SpinnerColumn(),
252
- TextColumn("[bold blue]{task.description}"),
253
- BarColumn(bar_width=30),
254
- TaskProgressColumn(),
255
- TextColumn("•"),
256
- TimeElapsedColumn(),
257
- TextColumn("[dim]{task.fields[status]}[/dim]"),
258
- console=console,
259
- )
260
- self.task: TaskID = TaskID(0)
261
- self.processed = 0
262
- self.total_discovered = 0
263
-
264
- def start(self):
265
- self.task = self.progress.add_task(
266
- "Analyzing dependencies",
267
- total=None,
268
- status="starting..."
269
- )
270
- self.progress.start()
271
-
272
- def stop(self):
273
- self.progress.stop()
274
-
275
- def update(self, crate_name: str, discovered: int = 0):
276
- self.processed += 1
277
- self.total_discovered += discovered
278
-
279
- if self.total_discovered > 0:
280
- self.progress.update(
281
- self.task,
282
- completed=self.processed,
283
- total=self.processed + self.total_discovered,
284
- status=f"checking: {crate_name}"
285
- )
286
- else:
287
- self.progress.update(
288
- self.task,
289
- status=f"checking: {crate_name}"
290
- )
291
-
292
- def finish(self):
293
- self.progress.update(
294
- self.task,
295
- completed=self.processed,
296
- total=self.processed,
297
- status="[green]complete![/green]"
298
- )
299
-
1
+ """
2
+ Woolly - Check if package dependencies are available in Fedora.
300
3
 
301
- # ------------------------------------------------------------
302
- # Tree Builder (FULLY RECURSIVE)
303
- # ------------------------------------------------------------
304
- def build_tree(crate_name: str, version: Optional[str] = None, visited=None, depth=0, max_depth=50, tracker=None):
305
- if visited is None:
306
- visited = set()
307
-
308
- if depth > max_depth:
309
- return f"[dim]{crate_name} (max depth reached)[/dim]"
310
-
311
- if crate_name in visited:
312
- return f"[dim]{crate_name} (already visited)[/dim]"
313
- visited.add(crate_name)
314
-
315
- if tracker:
316
- tracker.update(crate_name)
317
-
318
- if version is None:
319
- version = get_latest_version(crate_name)
320
- if version is None:
321
- return f"[bold red]{crate_name}[/bold red] • [red]not found on crates.io[/red]"
322
-
323
- # Check Fedora packaging status
324
- is_packaged, fedora_versions, packages = crate_is_packaged(crate_name)
325
-
326
- if is_packaged:
327
- ver_str = ", ".join(fedora_versions) if fedora_versions else "unknown"
328
- pkg_str = ", ".join(packages) if packages else ""
329
- label = (f"[bold]{crate_name}[/bold] [dim]v{version}[/dim] • "
330
- f"[green]✓ packaged[/green] [dim]({ver_str})[/dim]")
331
- if pkg_str:
332
- label += f" [dim cyan][{pkg_str}][/dim cyan]"
333
- else:
334
- label = f"[bold]{crate_name}[/bold] [dim]v{version}[/dim] • [red]✗ not packaged[/red]"
335
-
336
- node = Tree(label)
337
-
338
- # ALWAYS recurse into dependencies regardless of packaging status
339
- deps = get_normal_dependencies(crate_name, version)
340
- if tracker and deps:
341
- tracker.update(crate_name, discovered=len(deps))
342
-
343
- for (dep_name, dep_req) in deps:
344
- child = build_tree(dep_name, None, visited, depth + 1, max_depth, tracker)
345
- if isinstance(child, str):
346
- node.add(child)
347
- else:
348
- node.add(child)
349
-
350
- return node
351
-
352
-
353
- # ------------------------------------------------------------
354
- # Summary Collection
355
- # ------------------------------------------------------------
356
- def collect_stats(tree, stats=None):
357
- """Walk the tree and collect statistics."""
358
- if stats is None:
359
- stats = {"total": 0, "packaged": 0, "missing": 0, "missing_list": [], "packaged_list": []}
360
-
361
- def walk(t):
362
- if isinstance(t, str):
363
- stats["total"] += 1
364
- if "not packaged" in t or "not found" in t:
365
- stats["missing"] += 1
366
- name = t.split("[/bold]")[0].split("]")[-1] if "[/bold]" in t else t.split()[0]
367
- stats["missing_list"].append(name)
368
- elif "packaged" in t:
369
- stats["packaged"] += 1
370
- return
371
-
372
- if hasattr(t, "label"):
373
- label = str(t.label)
374
- stats["total"] += 1
375
- if "not packaged" in label or "not found" in label:
376
- stats["missing"] += 1
377
- name = label.split("[/bold]")[0].split("[bold]")[-1] if "[bold]" in label else "unknown"
378
- stats["missing_list"].append(name)
379
- elif "packaged" in label:
380
- stats["packaged"] += 1
381
- name = label.split("[/bold]")[0].split("[bold]")[-1] if "[bold]" in label else "unknown"
382
- stats["packaged_list"].append(name)
383
-
384
- if hasattr(t, "children"):
385
- for child in t.children:
386
- walk(child)
387
-
388
- walk(tree)
389
- return stats
390
-
391
-
392
- def print_summary_table(root_crate, tree):
393
- stats = collect_stats(tree)
394
-
395
- table = Table(title=f"Dependency Summary for '{root_crate}'", box=box.ROUNDED)
396
- table.add_column("Metric", style="bold")
397
- table.add_column("Value", justify="right")
398
-
399
- table.add_row("Total dependencies checked", str(stats["total"]))
400
- table.add_row("[green]Packaged in Fedora[/green]", str(stats["packaged"]))
401
- table.add_row("[red]Missing from Fedora[/red]", str(stats["missing"]))
402
-
403
- console.print(table)
404
- console.print()
405
-
406
- if stats["missing_list"]:
407
- console.print("[bold]Missing crates that need packaging:[/bold]")
408
- for name in sorted(set(stats["missing_list"])):
409
- console.print(f" • {name}")
410
- console.print()
4
+ Supports multiple languages:
5
+ - Rust (crates.io)
6
+ - Python (PyPI)
7
+ """
411
8
 
9
+ import sys
412
10
 
413
- def clear_cache(namespace: Optional[str] = None):
414
- """Clear disk cache."""
415
- if namespace:
416
- cache_path = CACHE_DIR / namespace
417
- if cache_path.exists():
418
- for f in cache_path.glob("*.json"):
419
- f.unlink()
420
- console.print(f"[yellow]Cleared {namespace} cache[/yellow]")
421
- else:
422
- for ns in ["crates", "fedora"]:
423
- cache_path = CACHE_DIR / ns
424
- if cache_path.exists():
425
- for f in cache_path.glob("*.json"):
426
- f.unlink()
427
- console.print("[yellow]Cleared all caches[/yellow]")
11
+ from woolly.commands import app
428
12
 
429
13
 
430
14
  def main() -> int:
431
- parser = argparse.ArgumentParser(
432
- description="Check if a Rust crate's dependencies are packaged in Fedora"
433
- )
434
- parser.add_argument("crate", nargs="?", help="Crate name on crates.io (e.g., ripgrep)")
435
- parser.add_argument("--version", "-v", help="Specific version to check")
436
- parser.add_argument("--max-depth", "-d", type=int, default=50, help="Maximum recursion depth")
437
- parser.add_argument("--no-progress", action="store_true", help="Disable progress bar")
438
- parser.add_argument("--clear-cache", action="store_true", help="Clear all cached data")
439
- parser.add_argument("--clear-fedora-cache", action="store_true", help="Clear Fedora repoquery cache")
440
- args = parser.parse_args()
441
-
442
- if args.clear_cache:
443
- clear_cache()
444
- if not args.crate:
445
- exit(0)
446
-
447
- if args.clear_fedora_cache:
448
- clear_cache("fedora")
449
- if not args.crate:
450
- exit(0)
451
-
452
- if not args.crate:
453
- parser.print_help()
454
- exit(1)
455
-
456
- console.print(f"\n[bold underline]Analyzing crate:[/] {args.crate}")
457
- console.print(f"[dim]Cache directory: {CACHE_DIR}[/dim]\n")
458
-
459
- tracker = None if args.no_progress else ProgressTracker()
460
-
461
- if tracker:
462
- tracker.start()
463
-
464
- try:
465
- tree = build_tree(args.crate, args.version, max_depth=args.max_depth, tracker=tracker)
466
- if tracker:
467
- tracker.finish()
468
- finally:
469
- if tracker:
470
- tracker.stop()
471
-
472
- console.print()
473
- print_summary_table(args.crate, tree)
474
-
475
- console.print("[bold]Dependency Tree:[/bold]")
476
- console.print(tree)
477
- console.print()
478
-
15
+ """Main entry point."""
16
+ app()
479
17
  return 0
480
18
 
19
+
481
20
  if __name__ == "__main__":
482
- sys.exit(main())
21
+ sys.exit(main())
woolly/cache.py ADDED
@@ -0,0 +1,78 @@
1
+ """
2
+ Disk cache helpers for storing API and repoquery results.
3
+ """
4
+
5
+ import hashlib
6
+ import json
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any, Optional
10
+
11
+ CACHE_DIR = Path.home() / ".cache" / "woolly"
12
+ DEFAULT_CACHE_TTL = 86400 * 7 # 7 days
13
+ FEDORA_CACHE_TTL = 86400 # 1 day for Fedora repoquery data
14
+
15
+
16
+ def ensure_cache_dir() -> None:
17
+ """Create cache directory if it doesn't exist."""
18
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
19
+
20
+
21
+ def get_cache_path(namespace: str, key: str) -> Path:
22
+ """Get path for a cache entry."""
23
+ ensure_cache_dir()
24
+ ns_dir = CACHE_DIR / namespace
25
+ ns_dir.mkdir(exist_ok=True)
26
+ safe_key = hashlib.md5(key.encode()).hexdigest()
27
+ return ns_dir / f"{safe_key}.json"
28
+
29
+
30
+ def read_cache(namespace: str, key: str, ttl: int = DEFAULT_CACHE_TTL) -> Optional[Any]:
31
+ """Read from disk cache if not expired."""
32
+ path = get_cache_path(namespace, key)
33
+ if not path.exists():
34
+ return None
35
+
36
+ try:
37
+ data = json.loads(path.read_text())
38
+ if time.time() - data.get("timestamp", 0) > ttl:
39
+ return None # Expired
40
+ return data.get("value")
41
+ except (json.JSONDecodeError, KeyError):
42
+ return None
43
+
44
+
45
+ def write_cache(namespace: str, key: str, value: Any) -> None:
46
+ """Write to disk cache."""
47
+ path = get_cache_path(namespace, key)
48
+ data = {"timestamp": time.time(), "value": value}
49
+ path.write_text(json.dumps(data))
50
+
51
+
52
+ def clear_cache(namespace: Optional[str] = None) -> list[str]:
53
+ """
54
+ Clear disk cache.
55
+
56
+ Args:
57
+ namespace: Specific namespace to clear, or None for all.
58
+
59
+ Returns:
60
+ List of namespaces that were cleared.
61
+ """
62
+ cleared = []
63
+
64
+ if namespace:
65
+ cache_path = CACHE_DIR / namespace
66
+ if cache_path.exists():
67
+ for f in cache_path.glob("*.json"):
68
+ f.unlink()
69
+ cleared.append(namespace)
70
+ else:
71
+ if CACHE_DIR.exists():
72
+ for ns_dir in CACHE_DIR.iterdir():
73
+ if ns_dir.is_dir():
74
+ for f in ns_dir.glob("*.json"):
75
+ f.unlink()
76
+ cleared.append(ns_dir.name)
77
+
78
+ return cleared
@@ -0,0 +1,27 @@
1
+ """
2
+ CLI commands for Woolly.
3
+
4
+ This module defines the cyclopts app and registers all commands.
5
+ """
6
+
7
+ import cyclopts
8
+ from rich.console import Console
9
+
10
+ # Shared console instance for all commands
11
+ console = Console()
12
+
13
+ # Main application
14
+ app = cyclopts.App(
15
+ name="woolly",
16
+ help="Check if package dependencies are available in Fedora.",
17
+ version_flags=(), # We don't need --version for the app itself
18
+ )
19
+
20
+ # Import and register commands
21
+ # These imports must come after app is defined to avoid circular imports
22
+ from woolly.commands.check import check # noqa: E402, F401
23
+ from woolly.commands.clear_cache import clear_cache_cmd # noqa: E402, F401
24
+ from woolly.commands.list_formats import list_formats_cmd # noqa: E402, F401
25
+ from woolly.commands.list_languages import list_languages_cmd # noqa: E402, F401
26
+
27
+ __all__ = ["app", "console"]