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 +12 -473
- woolly/cache.py +78 -0
- woolly/commands/__init__.py +27 -0
- woolly/commands/check.py +358 -0
- woolly/commands/clear_cache.py +42 -0
- woolly/commands/list_formats.py +24 -0
- woolly/commands/list_languages.py +26 -0
- woolly/debug.py +195 -0
- woolly/languages/__init__.py +72 -0
- woolly/languages/base.py +370 -0
- woolly/languages/python.py +201 -0
- woolly/languages/rust.py +132 -0
- woolly/progress.py +69 -0
- woolly/reporters/__init__.py +90 -0
- woolly/reporters/base.py +131 -0
- woolly/reporters/json.py +145 -0
- woolly/reporters/markdown.py +140 -0
- woolly/reporters/stdout.py +54 -0
- woolly-0.2.0.dist-info/METADATA +213 -0
- woolly-0.2.0.dist-info/RECORD +24 -0
- {woolly-0.1.0.dist-info → woolly-0.2.0.dist-info}/WHEEL +1 -1
- woolly-0.1.0.dist-info/METADATA +0 -101
- woolly-0.1.0.dist-info/RECORD +0 -7
- {woolly-0.1.0.dist-info → woolly-0.2.0.dist-info}/entry_points.txt +0 -0
- {woolly-0.1.0.dist-info → woolly-0.2.0.dist-info}/licenses/LICENSE +0 -0
woolly/__main__.py
CHANGED
|
@@ -1,482 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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"]
|