woolly 0.2.0__py3-none-any.whl → 0.4.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/cache.py +15 -5
- woolly/commands/check.py +176 -44
- woolly/commands/list_formats.py +3 -3
- woolly/commands/list_languages.py +4 -4
- woolly/http.py +34 -0
- woolly/languages/__init__.py +33 -3
- woolly/languages/base.py +11 -7
- woolly/languages/python.py +5 -6
- woolly/languages/rust.py +3 -5
- woolly/reporters/__init__.py +43 -8
- woolly/reporters/base.py +95 -10
- woolly/reporters/json.py +124 -89
- woolly/reporters/markdown.py +40 -60
- woolly/reporters/stdout.py +32 -10
- woolly/reporters/template.py +215 -0
- woolly-0.4.0.dist-info/METADATA +324 -0
- woolly-0.4.0.dist-info/RECORD +26 -0
- woolly-0.2.0.dist-info/METADATA +0 -213
- woolly-0.2.0.dist-info/RECORD +0 -24
- {woolly-0.2.0.dist-info → woolly-0.4.0.dist-info}/WHEEL +0 -0
- {woolly-0.2.0.dist-info → woolly-0.4.0.dist-info}/entry_points.txt +0 -0
- {woolly-0.2.0.dist-info → woolly-0.4.0.dist-info}/licenses/LICENSE +0 -0
woolly/cache.py
CHANGED
|
@@ -8,11 +8,20 @@ import time
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Any, Optional
|
|
10
10
|
|
|
11
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
12
|
+
|
|
11
13
|
CACHE_DIR = Path.home() / ".cache" / "woolly"
|
|
12
14
|
DEFAULT_CACHE_TTL = 86400 * 7 # 7 days
|
|
13
15
|
FEDORA_CACHE_TTL = 86400 # 1 day for Fedora repoquery data
|
|
14
16
|
|
|
15
17
|
|
|
18
|
+
class CacheEntry(BaseModel):
|
|
19
|
+
"""A cached value with timestamp."""
|
|
20
|
+
|
|
21
|
+
timestamp: float = Field(default_factory=time.time)
|
|
22
|
+
value: Any
|
|
23
|
+
|
|
24
|
+
|
|
16
25
|
def ensure_cache_dir() -> None:
|
|
17
26
|
"""Create cache directory if it doesn't exist."""
|
|
18
27
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
@@ -35,18 +44,19 @@ def read_cache(namespace: str, key: str, ttl: int = DEFAULT_CACHE_TTL) -> Option
|
|
|
35
44
|
|
|
36
45
|
try:
|
|
37
46
|
data = json.loads(path.read_text())
|
|
38
|
-
|
|
47
|
+
entry = CacheEntry.model_validate(data)
|
|
48
|
+
if time.time() - entry.timestamp > ttl:
|
|
39
49
|
return None # Expired
|
|
40
|
-
return
|
|
41
|
-
except (json.JSONDecodeError, KeyError):
|
|
50
|
+
return entry.value
|
|
51
|
+
except (json.JSONDecodeError, KeyError, ValidationError):
|
|
42
52
|
return None
|
|
43
53
|
|
|
44
54
|
|
|
45
55
|
def write_cache(namespace: str, key: str, value: Any) -> None:
|
|
46
56
|
"""Write to disk cache."""
|
|
47
57
|
path = get_cache_path(namespace, key)
|
|
48
|
-
|
|
49
|
-
path.write_text(
|
|
58
|
+
entry = CacheEntry(value=value)
|
|
59
|
+
path.write_text(entry.model_dump_json())
|
|
50
60
|
|
|
51
61
|
|
|
52
62
|
def clear_cache(namespace: Optional[str] = None) -> list[str]:
|
woolly/commands/check.py
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
Check command - analyze package dependencies for Fedora availability.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
import fnmatch
|
|
6
|
+
from pathlib import Path
|
|
5
7
|
from typing import Annotated, Optional
|
|
6
8
|
|
|
7
9
|
import cyclopts
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
8
11
|
from rich.tree import Tree
|
|
9
12
|
|
|
10
13
|
from woolly.cache import CACHE_DIR
|
|
@@ -16,6 +19,20 @@ from woolly.progress import ProgressTracker
|
|
|
16
19
|
from woolly.reporters import ReportData, get_available_formats, get_reporter
|
|
17
20
|
|
|
18
21
|
|
|
22
|
+
class TreeStats(BaseModel):
|
|
23
|
+
"""Statistics collected from dependency tree analysis."""
|
|
24
|
+
|
|
25
|
+
total: int = 0
|
|
26
|
+
packaged: int = 0
|
|
27
|
+
missing: int = 0
|
|
28
|
+
missing_list: list[str] = Field(default_factory=list)
|
|
29
|
+
packaged_list: list[str] = Field(default_factory=list)
|
|
30
|
+
optional_total: int = 0
|
|
31
|
+
optional_packaged: int = 0
|
|
32
|
+
optional_missing: int = 0
|
|
33
|
+
optional_missing_list: list[str] = Field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
|
|
19
36
|
def build_tree(
|
|
20
37
|
provider: LanguageProvider,
|
|
21
38
|
package_name: str,
|
|
@@ -24,6 +41,9 @@ def build_tree(
|
|
|
24
41
|
depth: int = 0,
|
|
25
42
|
max_depth: int = 50,
|
|
26
43
|
tracker: Optional[ProgressTracker] = None,
|
|
44
|
+
include_optional: bool = False,
|
|
45
|
+
is_optional_dep: bool = False,
|
|
46
|
+
exclude_patterns: Optional[list[str]] = None,
|
|
27
47
|
):
|
|
28
48
|
"""
|
|
29
49
|
Recursively build a dependency tree for a package.
|
|
@@ -44,6 +64,12 @@ def build_tree(
|
|
|
44
64
|
Maximum recursion depth.
|
|
45
65
|
tracker
|
|
46
66
|
Optional progress tracker.
|
|
67
|
+
include_optional
|
|
68
|
+
If True, include optional dependencies in the analysis.
|
|
69
|
+
is_optional_dep
|
|
70
|
+
If True, this package is an optional dependency.
|
|
71
|
+
exclude_patterns
|
|
72
|
+
List of glob patterns to exclude from the dependency tree.
|
|
47
73
|
|
|
48
74
|
Returns
|
|
49
75
|
-------
|
|
@@ -53,9 +79,11 @@ def build_tree(
|
|
|
53
79
|
if visited is None:
|
|
54
80
|
visited = {}
|
|
55
81
|
|
|
82
|
+
optional_marker = " [yellow](optional)[/yellow]" if is_optional_dep else ""
|
|
83
|
+
|
|
56
84
|
if depth > max_depth:
|
|
57
85
|
log(f"Max depth reached for {package_name}", level="warning", depth=depth)
|
|
58
|
-
return f"[dim]{package_name} (max depth reached)[/dim]"
|
|
86
|
+
return f"[dim]{package_name}{optional_marker} (max depth reached)[/dim]"
|
|
59
87
|
|
|
60
88
|
if package_name in visited:
|
|
61
89
|
is_packaged, cached_version = visited[package_name]
|
|
@@ -65,11 +93,9 @@ def build_tree(
|
|
|
65
93
|
result="packaged" if is_packaged else "not packaged",
|
|
66
94
|
)
|
|
67
95
|
if is_packaged:
|
|
68
|
-
return f"[dim]{package_name}[/dim] [dim]v{cached_version}[/dim] • [green]✓[/green] [dim](already visited)[/dim]"
|
|
96
|
+
return f"[dim]{package_name}[/dim] [dim]v{cached_version}[/dim]{optional_marker} • [green]✓[/green] [dim](already visited)[/dim]"
|
|
69
97
|
else:
|
|
70
|
-
return (
|
|
71
|
-
f"[dim]{package_name}[/dim] • [red]✗[/red] [dim](already visited)[/dim]"
|
|
72
|
-
)
|
|
98
|
+
return f"[dim]{package_name}[/dim]{optional_marker} • [red]✗[/red] [dim](already visited)[/dim]"
|
|
73
99
|
|
|
74
100
|
if tracker:
|
|
75
101
|
tracker.update(package_name)
|
|
@@ -84,7 +110,7 @@ def build_tree(
|
|
|
84
110
|
package_name, "Not found", source=provider.registry_name, result="error"
|
|
85
111
|
)
|
|
86
112
|
return (
|
|
87
|
-
f"[bold red]{package_name}[/bold red] • "
|
|
113
|
+
f"[bold red]{package_name}[/bold red]{optional_marker} • "
|
|
88
114
|
f"[red]not found on {provider.registry_name}[/red]"
|
|
89
115
|
)
|
|
90
116
|
|
|
@@ -107,14 +133,14 @@ def build_tree(
|
|
|
107
133
|
ver_str = ", ".join(status.versions) if status.versions else "unknown"
|
|
108
134
|
pkg_str = ", ".join(status.package_names) if status.package_names else ""
|
|
109
135
|
label = (
|
|
110
|
-
f"[bold]{package_name}[/bold] [dim]v{version}[/dim] • "
|
|
136
|
+
f"[bold]{package_name}[/bold] [dim]v{version}[/dim]{optional_marker} • "
|
|
111
137
|
f"[green]✓ packaged[/green] [dim]({ver_str})[/dim]"
|
|
112
138
|
)
|
|
113
139
|
if pkg_str:
|
|
114
140
|
label += f" [dim cyan][{pkg_str}][/dim cyan]"
|
|
115
141
|
else:
|
|
116
142
|
label = (
|
|
117
|
-
f"[bold]{package_name}[/bold] [dim]v{version}[/dim] • "
|
|
143
|
+
f"[bold]{package_name}[/bold] [dim]v{version}[/dim]{optional_marker} • "
|
|
118
144
|
f"[red]✗ not packaged[/red]"
|
|
119
145
|
)
|
|
120
146
|
|
|
@@ -125,16 +151,33 @@ def build_tree(
|
|
|
125
151
|
package_name, "Fetching dependencies", source=provider.registry_name
|
|
126
152
|
)
|
|
127
153
|
|
|
128
|
-
deps = provider.get_normal_dependencies(
|
|
154
|
+
deps = provider.get_normal_dependencies(
|
|
155
|
+
package_name, version, include_optional=include_optional
|
|
156
|
+
)
|
|
129
157
|
|
|
130
158
|
log(f"Found {len(deps)} dependencies for {package_name}", deps=len(deps))
|
|
131
159
|
|
|
132
160
|
if tracker and deps:
|
|
133
161
|
tracker.update(package_name, discovered=len(deps))
|
|
134
162
|
|
|
135
|
-
for dep_name, _dep_req in deps:
|
|
163
|
+
for dep_name, _dep_req, dep_is_optional in deps:
|
|
164
|
+
# Skip dependencies matching exclude patterns
|
|
165
|
+
if exclude_patterns:
|
|
166
|
+
if any(fnmatch.fnmatch(dep_name, pattern) for pattern in exclude_patterns):
|
|
167
|
+
log(f"Filtered out dependency: {dep_name}", level="info", depth=depth)
|
|
168
|
+
continue
|
|
169
|
+
|
|
136
170
|
child = build_tree(
|
|
137
|
-
provider,
|
|
171
|
+
provider,
|
|
172
|
+
dep_name,
|
|
173
|
+
None,
|
|
174
|
+
visited,
|
|
175
|
+
depth + 1,
|
|
176
|
+
max_depth,
|
|
177
|
+
tracker,
|
|
178
|
+
include_optional=include_optional,
|
|
179
|
+
is_optional_dep=dep_is_optional,
|
|
180
|
+
exclude_patterns=exclude_patterns,
|
|
138
181
|
)
|
|
139
182
|
if isinstance(child, str):
|
|
140
183
|
node.add(child)
|
|
@@ -148,51 +191,65 @@ def build_tree(
|
|
|
148
191
|
return node
|
|
149
192
|
|
|
150
193
|
|
|
151
|
-
def collect_stats(tree, stats=None):
|
|
194
|
+
def collect_stats(tree, stats: Optional[TreeStats] = None) -> TreeStats:
|
|
152
195
|
"""Walk the tree and collect statistics."""
|
|
153
196
|
if stats is None:
|
|
154
|
-
stats =
|
|
155
|
-
"total": 0,
|
|
156
|
-
"packaged": 0,
|
|
157
|
-
"missing": 0,
|
|
158
|
-
"missing_list": [],
|
|
159
|
-
"packaged_list": [],
|
|
160
|
-
}
|
|
197
|
+
stats = TreeStats()
|
|
161
198
|
|
|
162
199
|
def walk(t):
|
|
163
200
|
if isinstance(t, str):
|
|
164
|
-
stats
|
|
201
|
+
stats.total += 1
|
|
202
|
+
is_optional = "(optional)" in t
|
|
203
|
+
if is_optional:
|
|
204
|
+
stats.optional_total += 1
|
|
165
205
|
if "not packaged" in t or "not found" in t:
|
|
166
|
-
stats
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
206
|
+
stats.missing += 1
|
|
207
|
+
# Handle both [bold] and [bold red] formats
|
|
208
|
+
if "[/bold]" in t:
|
|
209
|
+
name = t.split("[/bold]")[0].split("]")[-1]
|
|
210
|
+
elif "[/bold red]" in t:
|
|
211
|
+
name = t.split("[/bold red]")[0].split("[bold red]")[-1]
|
|
212
|
+
else:
|
|
213
|
+
name = t.split()[0]
|
|
214
|
+
stats.missing_list.append(name)
|
|
215
|
+
if is_optional:
|
|
216
|
+
stats.optional_missing += 1
|
|
217
|
+
stats.optional_missing_list.append(name)
|
|
173
218
|
elif "packaged" in t:
|
|
174
|
-
stats
|
|
219
|
+
stats.packaged += 1
|
|
220
|
+
if is_optional:
|
|
221
|
+
stats.optional_packaged += 1
|
|
175
222
|
return
|
|
176
223
|
|
|
177
224
|
if hasattr(t, "label"):
|
|
178
225
|
label = str(t.label)
|
|
179
|
-
stats
|
|
226
|
+
stats.total += 1
|
|
227
|
+
is_optional = "(optional)" in label
|
|
228
|
+
if is_optional:
|
|
229
|
+
stats.optional_total += 1
|
|
180
230
|
if "not packaged" in label or "not found" in label:
|
|
181
|
-
stats
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
231
|
+
stats.missing += 1
|
|
232
|
+
# Handle both [bold] and [bold red] formats
|
|
233
|
+
if "[bold]" in label and "[bold red]" not in label:
|
|
234
|
+
name = label.split("[/bold]")[0].split("[bold]")[-1]
|
|
235
|
+
elif "[bold red]" in label:
|
|
236
|
+
name = label.split("[/bold red]")[0].split("[bold red]")[-1]
|
|
237
|
+
else:
|
|
238
|
+
name = "unknown"
|
|
239
|
+
stats.missing_list.append(name)
|
|
240
|
+
if is_optional:
|
|
241
|
+
stats.optional_missing += 1
|
|
242
|
+
stats.optional_missing_list.append(name)
|
|
188
243
|
elif "packaged" in label:
|
|
189
|
-
stats
|
|
244
|
+
stats.packaged += 1
|
|
190
245
|
name = (
|
|
191
246
|
label.split("[/bold]")[0].split("[bold]")[-1]
|
|
192
247
|
if "[bold]" in label
|
|
193
248
|
else "unknown"
|
|
194
249
|
)
|
|
195
|
-
stats
|
|
250
|
+
stats.packaged_list.append(name)
|
|
251
|
+
if is_optional:
|
|
252
|
+
stats.optional_packaged += 1
|
|
196
253
|
|
|
197
254
|
if hasattr(t, "children"):
|
|
198
255
|
for child in t.children:
|
|
@@ -232,6 +289,14 @@ def check(
|
|
|
232
289
|
help="Maximum recursion depth.",
|
|
233
290
|
),
|
|
234
291
|
] = 50,
|
|
292
|
+
optional: Annotated[
|
|
293
|
+
bool,
|
|
294
|
+
cyclopts.Parameter(
|
|
295
|
+
("--optional", "-o"),
|
|
296
|
+
negative=(),
|
|
297
|
+
help="Include optional dependencies in the analysis.",
|
|
298
|
+
),
|
|
299
|
+
] = False,
|
|
235
300
|
no_progress: Annotated[
|
|
236
301
|
bool,
|
|
237
302
|
cyclopts.Parameter(
|
|
@@ -253,6 +318,28 @@ def check(
|
|
|
253
318
|
help="Report format: stdout, json, markdown. Use 'list-formats' for all options.",
|
|
254
319
|
),
|
|
255
320
|
] = "stdout",
|
|
321
|
+
missing_only: Annotated[
|
|
322
|
+
bool,
|
|
323
|
+
cyclopts.Parameter(
|
|
324
|
+
("--missing-only", "-m"),
|
|
325
|
+
negative=(),
|
|
326
|
+
help="Only display packages that are missing from Fedora.",
|
|
327
|
+
),
|
|
328
|
+
] = False,
|
|
329
|
+
exclude: Annotated[
|
|
330
|
+
tuple[str, ...],
|
|
331
|
+
cyclopts.Parameter(
|
|
332
|
+
("--exclude", "-e"),
|
|
333
|
+
help="Glob pattern(s) to exclude dependencies (e.g., '*-windows', 'win*'). Can be specified multiple times.",
|
|
334
|
+
),
|
|
335
|
+
] = (),
|
|
336
|
+
template: Annotated[
|
|
337
|
+
Optional[str],
|
|
338
|
+
cyclopts.Parameter(
|
|
339
|
+
("--template", "-t"),
|
|
340
|
+
help="Path to a Jinja2 template file for custom report format. Only used with --report=template.",
|
|
341
|
+
),
|
|
342
|
+
] = None,
|
|
256
343
|
):
|
|
257
344
|
"""Check if a package's dependencies are available in Fedora.
|
|
258
345
|
|
|
@@ -266,12 +353,20 @@ def check(
|
|
|
266
353
|
Specific version to check (default: latest).
|
|
267
354
|
max_depth
|
|
268
355
|
Maximum recursion depth for dependency tree.
|
|
356
|
+
optional
|
|
357
|
+
Include optional dependencies in the analysis.
|
|
269
358
|
no_progress
|
|
270
359
|
Disable progress bar during analysis.
|
|
271
360
|
debug
|
|
272
361
|
Enable verbose debug logging.
|
|
273
362
|
report
|
|
274
363
|
Output format for the report.
|
|
364
|
+
missing_only
|
|
365
|
+
Only display packages that are missing from Fedora.
|
|
366
|
+
exclude
|
|
367
|
+
Glob pattern(s) to exclude dependencies from the analysis.
|
|
368
|
+
template
|
|
369
|
+
Path to a Jinja2 template file for custom report format.
|
|
275
370
|
"""
|
|
276
371
|
# Get the language provider
|
|
277
372
|
provider = get_provider(lang)
|
|
@@ -281,12 +376,33 @@ def check(
|
|
|
281
376
|
raise SystemExit(1)
|
|
282
377
|
|
|
283
378
|
# Get the reporter
|
|
284
|
-
|
|
379
|
+
template_path = Path(template) if template else None
|
|
380
|
+
|
|
381
|
+
# Validate template reporter requirements
|
|
382
|
+
if report.lower() in ("template", "tpl", "jinja", "jinja2"):
|
|
383
|
+
if template_path is None:
|
|
384
|
+
console.print("[red]Template reporter requires --template parameter.[/red]")
|
|
385
|
+
console.print(
|
|
386
|
+
"Example: woolly check mypackage --report=template --template=my_template.md"
|
|
387
|
+
)
|
|
388
|
+
raise SystemExit(1)
|
|
389
|
+
if not template_path.exists():
|
|
390
|
+
console.print(f"[red]Template file not found: {template_path}[/red]")
|
|
391
|
+
raise SystemExit(1)
|
|
392
|
+
elif template_path is not None:
|
|
393
|
+
console.print(
|
|
394
|
+
"[yellow]Warning: --template is only used with --report=template[/yellow]"
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
reporter = get_reporter(report, console=console, template_path=template_path)
|
|
285
398
|
if reporter is None:
|
|
286
399
|
console.print(f"[red]Unknown report format: {report}[/red]")
|
|
287
400
|
console.print(f"Available formats: {', '.join(get_available_formats())}")
|
|
288
401
|
raise SystemExit(1)
|
|
289
402
|
|
|
403
|
+
# Convert exclude tuple to list for consistency
|
|
404
|
+
exclude_patterns = list(exclude) if exclude else None
|
|
405
|
+
|
|
290
406
|
# Initialize logging
|
|
291
407
|
setup_logger(debug=debug)
|
|
292
408
|
log(
|
|
@@ -294,13 +410,21 @@ def check(
|
|
|
294
410
|
package=package,
|
|
295
411
|
language=lang,
|
|
296
412
|
max_depth=max_depth,
|
|
413
|
+
include_optional=optional,
|
|
297
414
|
debug=debug,
|
|
298
415
|
report_format=report,
|
|
416
|
+
exclude_patterns=exclude_patterns,
|
|
299
417
|
)
|
|
300
418
|
|
|
301
419
|
console.print(
|
|
302
420
|
f"\n[bold underline]Analyzing {provider.display_name} package:[/] {package}"
|
|
303
421
|
)
|
|
422
|
+
if optional:
|
|
423
|
+
console.print("[yellow]Including optional dependencies[/yellow]")
|
|
424
|
+
if exclude_patterns:
|
|
425
|
+
console.print(
|
|
426
|
+
f"[yellow]Excluding dependencies matching: {', '.join(exclude_patterns)}[/yellow]"
|
|
427
|
+
)
|
|
304
428
|
console.print(f"[dim]Registry: {provider.registry_name}[/dim]")
|
|
305
429
|
console.print(f"[dim]Cache directory: {CACHE_DIR}[/dim]")
|
|
306
430
|
console.print()
|
|
@@ -317,6 +441,8 @@ def check(
|
|
|
317
441
|
version,
|
|
318
442
|
max_depth=max_depth,
|
|
319
443
|
tracker=tracker,
|
|
444
|
+
include_optional=optional,
|
|
445
|
+
exclude_patterns=exclude_patterns,
|
|
320
446
|
)
|
|
321
447
|
if tracker:
|
|
322
448
|
tracker.finish()
|
|
@@ -335,14 +461,20 @@ def check(
|
|
|
335
461
|
root_package=package,
|
|
336
462
|
language=provider.display_name,
|
|
337
463
|
registry=provider.registry_name,
|
|
338
|
-
total_dependencies=stats
|
|
339
|
-
packaged_count=stats
|
|
340
|
-
missing_count=stats
|
|
341
|
-
missing_packages=stats
|
|
342
|
-
packaged_packages=stats
|
|
464
|
+
total_dependencies=stats.total,
|
|
465
|
+
packaged_count=stats.packaged,
|
|
466
|
+
missing_count=stats.missing,
|
|
467
|
+
missing_packages=stats.missing_list,
|
|
468
|
+
packaged_packages=stats.packaged_list,
|
|
343
469
|
tree=tree,
|
|
344
470
|
max_depth=max_depth,
|
|
345
471
|
version=version,
|
|
472
|
+
include_optional=optional,
|
|
473
|
+
optional_total=stats.optional_total,
|
|
474
|
+
optional_packaged=stats.optional_packaged,
|
|
475
|
+
optional_missing=stats.optional_missing,
|
|
476
|
+
optional_missing_packages=stats.optional_missing_list,
|
|
477
|
+
missing_only=missing_only,
|
|
346
478
|
)
|
|
347
479
|
|
|
348
480
|
# Generate report
|
woolly/commands/list_formats.py
CHANGED
|
@@ -17,8 +17,8 @@ def list_formats_cmd():
|
|
|
17
17
|
table.add_column("Description")
|
|
18
18
|
table.add_column("Aliases", style="dim")
|
|
19
19
|
|
|
20
|
-
for
|
|
21
|
-
alias_str = ", ".join(aliases) if aliases else "-"
|
|
22
|
-
table.add_row(format_id, description, alias_str)
|
|
20
|
+
for info in list_reporters():
|
|
21
|
+
alias_str = ", ".join(info.aliases) if info.aliases else "-"
|
|
22
|
+
table.add_row(info.format_id, info.description, alias_str)
|
|
23
23
|
|
|
24
24
|
console.print(table)
|
|
@@ -17,10 +17,10 @@ def list_languages_cmd():
|
|
|
17
17
|
table.add_column("Registry")
|
|
18
18
|
table.add_column("Aliases", style="dim")
|
|
19
19
|
|
|
20
|
-
for
|
|
21
|
-
provider = get_provider(
|
|
20
|
+
for info in list_providers():
|
|
21
|
+
provider = get_provider(info.language_id)
|
|
22
22
|
registry = provider.registry_name if provider else "Unknown"
|
|
23
|
-
alias_str = ", ".join(aliases) if aliases else "-"
|
|
24
|
-
table.add_row(f"{display_name} ({
|
|
23
|
+
alias_str = ", ".join(info.aliases) if info.aliases else "-"
|
|
24
|
+
table.add_row(f"{info.display_name} ({info.language_id})", registry, alias_str)
|
|
25
25
|
|
|
26
26
|
console.print(table)
|
woolly/http.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared HTTP client configuration for API requests.
|
|
3
|
+
|
|
4
|
+
This module provides centralized HTTP configuration including
|
|
5
|
+
the User-Agent header and common request settings.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from importlib.metadata import version
|
|
10
|
+
|
|
11
|
+
# Version identifier for the User-Agent
|
|
12
|
+
VERSION = version("woolly")
|
|
13
|
+
PROJECT_URL = "https://github.com/r0x0d/woolly"
|
|
14
|
+
|
|
15
|
+
# Shared headers for all API requests
|
|
16
|
+
DEFAULT_HEADERS = {
|
|
17
|
+
"User-Agent": f"woolly/{VERSION} ({PROJECT_URL})",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get(url: str, **kwargs) -> httpx.Response:
|
|
22
|
+
"""
|
|
23
|
+
Make a GET request with default headers.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
url: The URL to request.
|
|
27
|
+
**kwargs: Additional arguments passed to httpx.get().
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
httpx.Response object.
|
|
31
|
+
"""
|
|
32
|
+
headers = kwargs.pop("headers", {})
|
|
33
|
+
merged_headers = {**DEFAULT_HEADERS, **headers}
|
|
34
|
+
return httpx.get(url, headers=merged_headers, **kwargs)
|
woolly/languages/__init__.py
CHANGED
|
@@ -8,10 +8,21 @@ inheriting from LanguageProvider and add it to PROVIDERS dict.
|
|
|
8
8
|
|
|
9
9
|
from typing import Optional
|
|
10
10
|
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
11
13
|
from woolly.languages.base import LanguageProvider
|
|
12
14
|
from woolly.languages.python import PythonProvider
|
|
13
15
|
from woolly.languages.rust import RustProvider
|
|
14
16
|
|
|
17
|
+
|
|
18
|
+
class ProviderInfo(BaseModel):
|
|
19
|
+
"""Information about an available language provider."""
|
|
20
|
+
|
|
21
|
+
language_id: str
|
|
22
|
+
display_name: str
|
|
23
|
+
aliases: list[str] = Field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
|
|
15
26
|
# Registry of available language providers
|
|
16
27
|
# Key: language identifier (used in CLI)
|
|
17
28
|
# Value: Provider class
|
|
@@ -52,21 +63,40 @@ def get_provider(language: str) -> Optional[LanguageProvider]:
|
|
|
52
63
|
return provider_class()
|
|
53
64
|
|
|
54
65
|
|
|
55
|
-
def list_providers() -> list[
|
|
66
|
+
def list_providers() -> list[ProviderInfo]:
|
|
56
67
|
"""
|
|
57
68
|
List all available providers.
|
|
58
69
|
|
|
59
70
|
Returns:
|
|
60
|
-
List of
|
|
71
|
+
List of ProviderInfo objects with language details.
|
|
61
72
|
"""
|
|
62
73
|
result = []
|
|
63
74
|
for lang_id, provider_class in PROVIDERS.items():
|
|
64
75
|
# Find aliases for this language
|
|
65
76
|
aliases = [alias for alias, target in ALIASES.items() if target == lang_id]
|
|
66
|
-
result.append(
|
|
77
|
+
result.append(
|
|
78
|
+
ProviderInfo(
|
|
79
|
+
language_id=lang_id,
|
|
80
|
+
display_name=provider_class.display_name,
|
|
81
|
+
aliases=aliases,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
67
84
|
return result
|
|
68
85
|
|
|
69
86
|
|
|
70
87
|
def get_available_languages() -> list[str]:
|
|
71
88
|
"""Get list of available language identifiers."""
|
|
72
89
|
return list(PROVIDERS.keys())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
__all__ = [
|
|
93
|
+
"LanguageProvider",
|
|
94
|
+
"ProviderInfo",
|
|
95
|
+
"PythonProvider",
|
|
96
|
+
"RustProvider",
|
|
97
|
+
"get_provider",
|
|
98
|
+
"list_providers",
|
|
99
|
+
"get_available_languages",
|
|
100
|
+
"PROVIDERS",
|
|
101
|
+
"ALIASES",
|
|
102
|
+
]
|
woolly/languages/base.py
CHANGED
|
@@ -133,20 +133,24 @@ class LanguageProvider(ABC):
|
|
|
133
133
|
return info.latest_version
|
|
134
134
|
|
|
135
135
|
def get_normal_dependencies(
|
|
136
|
-
self,
|
|
137
|
-
|
|
136
|
+
self,
|
|
137
|
+
package_name: str,
|
|
138
|
+
version: Optional[str] = None,
|
|
139
|
+
include_optional: bool = False,
|
|
140
|
+
) -> list[tuple[str, str, bool]]:
|
|
138
141
|
"""
|
|
139
|
-
Get
|
|
142
|
+
Get runtime dependencies for a package.
|
|
140
143
|
|
|
141
144
|
This method filters dependencies to only include normal (runtime)
|
|
142
|
-
dependencies
|
|
145
|
+
dependencies. By default, optional dependencies are excluded.
|
|
143
146
|
|
|
144
147
|
Args:
|
|
145
148
|
package_name: The name of the package.
|
|
146
149
|
version: Specific version, or None for latest.
|
|
150
|
+
include_optional: If True, include optional dependencies.
|
|
147
151
|
|
|
148
152
|
Returns:
|
|
149
|
-
List of tuples: (dependency_name, version_requirement)
|
|
153
|
+
List of tuples: (dependency_name, version_requirement, is_optional)
|
|
150
154
|
"""
|
|
151
155
|
if version is None:
|
|
152
156
|
version = self.get_latest_version(package_name)
|
|
@@ -155,9 +159,9 @@ class LanguageProvider(ABC):
|
|
|
155
159
|
|
|
156
160
|
deps = self.fetch_dependencies(package_name, version)
|
|
157
161
|
return [
|
|
158
|
-
(d.name, d.version_requirement)
|
|
162
|
+
(d.name, d.version_requirement, d.optional)
|
|
159
163
|
for d in deps
|
|
160
|
-
if d.kind == "normal" and not d.optional
|
|
164
|
+
if d.kind == "normal" and (include_optional or not d.optional)
|
|
161
165
|
]
|
|
162
166
|
|
|
163
167
|
def get_fedora_provides_pattern(self, package_name: str) -> str:
|
woolly/languages/python.py
CHANGED
|
@@ -8,8 +8,7 @@ Fedora repositories for Python packages.
|
|
|
8
8
|
import re
|
|
9
9
|
from typing import Optional
|
|
10
10
|
|
|
11
|
-
import
|
|
12
|
-
|
|
11
|
+
from woolly import http
|
|
13
12
|
from woolly.cache import DEFAULT_CACHE_TTL, read_cache, write_cache
|
|
14
13
|
from woolly.debug import (
|
|
15
14
|
log_api_request,
|
|
@@ -20,7 +19,6 @@ from woolly.debug import (
|
|
|
20
19
|
from woolly.languages.base import Dependency, LanguageProvider, PackageInfo
|
|
21
20
|
|
|
22
21
|
PYPI_API = "https://pypi.org/pypi"
|
|
23
|
-
HEADERS = {"User-Agent": "woolly/0.1.0 (https://github.com/r0x0d/woolly)"}
|
|
24
22
|
|
|
25
23
|
|
|
26
24
|
class PythonProvider(LanguageProvider):
|
|
@@ -51,7 +49,7 @@ class PythonProvider(LanguageProvider):
|
|
|
51
49
|
log_cache_miss(self.cache_namespace, cache_key)
|
|
52
50
|
url = f"{PYPI_API}/{package_name}/json"
|
|
53
51
|
log_api_request("GET", url)
|
|
54
|
-
r =
|
|
52
|
+
r = http.get(url)
|
|
55
53
|
log_api_response(r.status_code, r.text[:500] if r.text else None)
|
|
56
54
|
|
|
57
55
|
if r.status_code == 404:
|
|
@@ -96,7 +94,7 @@ class PythonProvider(LanguageProvider):
|
|
|
96
94
|
log_cache_miss(self.cache_namespace, cache_key)
|
|
97
95
|
url = f"{PYPI_API}/{package_name}/{version}/json"
|
|
98
96
|
log_api_request("GET", url)
|
|
99
|
-
r =
|
|
97
|
+
r = http.get(url)
|
|
100
98
|
log_api_response(r.status_code, r.text[:500] if r.text else None)
|
|
101
99
|
|
|
102
100
|
if r.status_code != 200:
|
|
@@ -141,7 +139,8 @@ class PythonProvider(LanguageProvider):
|
|
|
141
139
|
|
|
142
140
|
if "extra ==" in req_string or "extra==" in req_string:
|
|
143
141
|
is_optional = True
|
|
144
|
-
kind
|
|
142
|
+
# Keep kind as "normal" so optional dependencies can be included
|
|
143
|
+
# when --optional flag is used
|
|
145
144
|
|
|
146
145
|
# Extract the package name and version requirement
|
|
147
146
|
# Handle environment markers (everything after ';')
|