woolly 0.3.0__py3-none-any.whl → 0.5.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 +21 -6
- woolly/commands/check.py +264 -82
- woolly/http.py +22 -3
- woolly/languages/base.py +187 -10
- woolly/languages/python.py +221 -8
- woolly/languages/rust.py +59 -1
- woolly/reporters/__init__.py +15 -1
- woolly/reporters/base.py +25 -0
- woolly/reporters/json.py +102 -3
- woolly/reporters/markdown.py +83 -9
- woolly/reporters/stdout.py +171 -38
- woolly/reporters/template.py +245 -0
- {woolly-0.3.0.dist-info → woolly-0.5.0.dist-info}/METADATA +3 -1
- woolly-0.5.0.dist-info/RECORD +26 -0
- woolly-0.3.0.dist-info/RECORD +0 -25
- {woolly-0.3.0.dist-info → woolly-0.5.0.dist-info}/WHEEL +0 -0
- {woolly-0.3.0.dist-info → woolly-0.5.0.dist-info}/entry_points.txt +0 -0
- {woolly-0.3.0.dist-info → woolly-0.5.0.dist-info}/licenses/LICENSE +0 -0
woolly/cache.py
CHANGED
|
@@ -14,6 +14,10 @@ CACHE_DIR = Path.home() / ".cache" / "woolly"
|
|
|
14
14
|
DEFAULT_CACHE_TTL = 86400 * 7 # 7 days
|
|
15
15
|
FEDORA_CACHE_TTL = 86400 # 1 day for Fedora repoquery data
|
|
16
16
|
|
|
17
|
+
# Track which namespace directories have already been created so that
|
|
18
|
+
# ``mkdir`` is only called once per namespace per process lifetime.
|
|
19
|
+
_ensured_namespaces: set[str] = set()
|
|
20
|
+
|
|
17
21
|
|
|
18
22
|
class CacheEntry(BaseModel):
|
|
19
23
|
"""A cached value with timestamp."""
|
|
@@ -23,17 +27,28 @@ class CacheEntry(BaseModel):
|
|
|
23
27
|
|
|
24
28
|
|
|
25
29
|
def ensure_cache_dir() -> None:
|
|
26
|
-
"""Create cache directory if it doesn't exist.
|
|
30
|
+
"""Create the top-level cache directory if it doesn't exist.
|
|
31
|
+
|
|
32
|
+
This is idempotent but cheap after the first call because the
|
|
33
|
+
directory will already exist.
|
|
34
|
+
"""
|
|
27
35
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
28
36
|
|
|
29
37
|
|
|
30
38
|
def get_cache_path(namespace: str, key: str) -> Path:
|
|
31
|
-
"""Get path for a cache entry.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
"""Get path for a cache entry.
|
|
40
|
+
|
|
41
|
+
Namespace directories are only created once per process lifetime
|
|
42
|
+
to avoid repeated filesystem calls.
|
|
43
|
+
"""
|
|
44
|
+
if namespace not in _ensured_namespaces:
|
|
45
|
+
ensure_cache_dir()
|
|
46
|
+
ns_dir = CACHE_DIR / namespace
|
|
47
|
+
ns_dir.mkdir(exist_ok=True)
|
|
48
|
+
_ensured_namespaces.add(namespace)
|
|
49
|
+
|
|
35
50
|
safe_key = hashlib.md5(key.encode()).hexdigest()
|
|
36
|
-
return
|
|
51
|
+
return CACHE_DIR / namespace / f"{safe_key}.json"
|
|
37
52
|
|
|
38
53
|
|
|
39
54
|
def read_cache(namespace: str, key: str, ttl: int = DEFAULT_CACHE_TTL) -> Optional[Any]:
|
woolly/commands/check.py
CHANGED
|
@@ -2,21 +2,36 @@
|
|
|
2
2
|
Check command - analyze package dependencies for Fedora availability.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
import fnmatch
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
|
+
from pathlib import Path
|
|
5
8
|
from typing import Annotated, Optional
|
|
6
9
|
|
|
7
10
|
import cyclopts
|
|
8
11
|
from pydantic import BaseModel, Field
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.text import Text
|
|
9
14
|
from rich.tree import Tree
|
|
10
15
|
|
|
11
16
|
from woolly.cache import CACHE_DIR
|
|
12
17
|
from woolly.commands import app, console
|
|
13
18
|
from woolly.debug import get_log_file, log, log_package_check, setup_logger
|
|
14
19
|
from woolly.languages import get_available_languages, get_provider
|
|
15
|
-
from woolly.languages.base import LanguageProvider
|
|
20
|
+
from woolly.languages.base import Dependency, FeatureInfo, LanguageProvider
|
|
16
21
|
from woolly.progress import ProgressTracker
|
|
17
22
|
from woolly.reporters import ReportData, get_available_formats, get_reporter
|
|
18
23
|
|
|
19
24
|
|
|
25
|
+
class DevBuildDepStatus(BaseModel):
|
|
26
|
+
"""Status of a dev or build dependency."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
version_requirement: str
|
|
30
|
+
is_packaged: bool
|
|
31
|
+
fedora_versions: list[str] = Field(default_factory=list)
|
|
32
|
+
fedora_packages: list[str] = Field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
20
35
|
class TreeStats(BaseModel):
|
|
21
36
|
"""Statistics collected from dependency tree analysis."""
|
|
22
37
|
|
|
@@ -29,6 +44,39 @@ class TreeStats(BaseModel):
|
|
|
29
44
|
optional_packaged: int = 0
|
|
30
45
|
optional_missing: int = 0
|
|
31
46
|
optional_missing_list: list[str] = Field(default_factory=list)
|
|
47
|
+
dev_total: int = 0
|
|
48
|
+
dev_packaged: int = 0
|
|
49
|
+
dev_missing: int = 0
|
|
50
|
+
build_total: int = 0
|
|
51
|
+
build_packaged: int = 0
|
|
52
|
+
build_missing: int = 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _compute_stats_from_visited(visited: dict) -> TreeStats:
|
|
56
|
+
"""Compute statistics directly from the *visited* dict built during tree traversal.
|
|
57
|
+
|
|
58
|
+
This replaces the old ``collect_stats`` approach that parsed Rich
|
|
59
|
+
markup strings, which was fragile and wasteful. The *visited* dict
|
|
60
|
+
already contains all the structured information we need:
|
|
61
|
+
``{package_name: (is_packaged, version, is_optional)}``.
|
|
62
|
+
"""
|
|
63
|
+
stats = TreeStats()
|
|
64
|
+
for pkg_name, (is_packaged, _version, is_optional) in visited.items():
|
|
65
|
+
stats.total += 1
|
|
66
|
+
if is_optional:
|
|
67
|
+
stats.optional_total += 1
|
|
68
|
+
if is_packaged:
|
|
69
|
+
stats.packaged += 1
|
|
70
|
+
stats.packaged_list.append(pkg_name)
|
|
71
|
+
if is_optional:
|
|
72
|
+
stats.optional_packaged += 1
|
|
73
|
+
else:
|
|
74
|
+
stats.missing += 1
|
|
75
|
+
stats.missing_list.append(pkg_name)
|
|
76
|
+
if is_optional:
|
|
77
|
+
stats.optional_missing += 1
|
|
78
|
+
stats.optional_missing_list.append(pkg_name)
|
|
79
|
+
return stats
|
|
32
80
|
|
|
33
81
|
|
|
34
82
|
def build_tree(
|
|
@@ -41,6 +89,7 @@ def build_tree(
|
|
|
41
89
|
tracker: Optional[ProgressTracker] = None,
|
|
42
90
|
include_optional: bool = False,
|
|
43
91
|
is_optional_dep: bool = False,
|
|
92
|
+
exclude_patterns: Optional[list[str]] = None,
|
|
44
93
|
):
|
|
45
94
|
"""
|
|
46
95
|
Recursively build a dependency tree for a package.
|
|
@@ -55,6 +104,7 @@ def build_tree(
|
|
|
55
104
|
Specific version, or None for latest.
|
|
56
105
|
visited
|
|
57
106
|
Dict of already-visited packages mapping to their status.
|
|
107
|
+
Each value is a tuple ``(is_packaged, version, is_optional)``.
|
|
58
108
|
depth
|
|
59
109
|
Current recursion depth.
|
|
60
110
|
max_depth
|
|
@@ -65,6 +115,8 @@ def build_tree(
|
|
|
65
115
|
If True, include optional dependencies in the analysis.
|
|
66
116
|
is_optional_dep
|
|
67
117
|
If True, this package is an optional dependency.
|
|
118
|
+
exclude_patterns
|
|
119
|
+
List of glob patterns to exclude from the dependency tree.
|
|
68
120
|
|
|
69
121
|
Returns
|
|
70
122
|
-------
|
|
@@ -81,7 +133,7 @@ def build_tree(
|
|
|
81
133
|
return f"[dim]{package_name}{optional_marker} (max depth reached)[/dim]"
|
|
82
134
|
|
|
83
135
|
if package_name in visited:
|
|
84
|
-
is_packaged, cached_version = visited[package_name]
|
|
136
|
+
is_packaged, cached_version, _ = visited[package_name]
|
|
85
137
|
log_package_check(
|
|
86
138
|
package_name,
|
|
87
139
|
"Skip (already visited)",
|
|
@@ -97,10 +149,12 @@ def build_tree(
|
|
|
97
149
|
|
|
98
150
|
log_package_check(package_name, "Fetching version", source=provider.registry_name)
|
|
99
151
|
|
|
152
|
+
# Fetch full package info to get version and license
|
|
153
|
+
pkg_info = None
|
|
100
154
|
if version is None:
|
|
101
|
-
|
|
102
|
-
if
|
|
103
|
-
visited[package_name] = (False, None)
|
|
155
|
+
pkg_info = provider.fetch_package_info(package_name)
|
|
156
|
+
if pkg_info is None:
|
|
157
|
+
visited[package_name] = (False, None, is_optional_dep)
|
|
104
158
|
log_package_check(
|
|
105
159
|
package_name, "Not found", source=provider.registry_name, result="error"
|
|
106
160
|
)
|
|
@@ -108,12 +162,19 @@ def build_tree(
|
|
|
108
162
|
f"[bold red]{package_name}[/bold red]{optional_marker} • "
|
|
109
163
|
f"[red]not found on {provider.registry_name}[/red]"
|
|
110
164
|
)
|
|
165
|
+
version = pkg_info.latest_version
|
|
166
|
+
else:
|
|
167
|
+
pkg_info = provider.fetch_package_info(package_name)
|
|
168
|
+
|
|
169
|
+
license_str = ""
|
|
170
|
+
if pkg_info and pkg_info.license:
|
|
171
|
+
license_str = f" [magenta]({pkg_info.license})[/magenta]"
|
|
111
172
|
|
|
112
173
|
log_package_check(package_name, "Checking Fedora", source="dnf repoquery")
|
|
113
174
|
|
|
114
175
|
# Check Fedora packaging status
|
|
115
176
|
status = provider.check_fedora_packaging(package_name)
|
|
116
|
-
visited[package_name] = (status.is_packaged, version)
|
|
177
|
+
visited[package_name] = (status.is_packaged, version, is_optional_dep)
|
|
117
178
|
|
|
118
179
|
if status.is_packaged:
|
|
119
180
|
log_package_check(
|
|
@@ -128,14 +189,14 @@ def build_tree(
|
|
|
128
189
|
ver_str = ", ".join(status.versions) if status.versions else "unknown"
|
|
129
190
|
pkg_str = ", ".join(status.package_names) if status.package_names else ""
|
|
130
191
|
label = (
|
|
131
|
-
f"[bold]{package_name}[/bold] [dim]v{version}[/dim]{optional_marker} • "
|
|
192
|
+
f"[bold]{package_name}[/bold] [dim]v{version}[/dim]{license_str}{optional_marker} • "
|
|
132
193
|
f"[green]✓ packaged[/green] [dim]({ver_str})[/dim]"
|
|
133
194
|
)
|
|
134
195
|
if pkg_str:
|
|
135
196
|
label += f" [dim cyan][{pkg_str}][/dim cyan]"
|
|
136
197
|
else:
|
|
137
198
|
label = (
|
|
138
|
-
f"[bold]{package_name}[/bold] [dim]v{version}[/dim]{optional_marker} • "
|
|
199
|
+
f"[bold]{package_name}[/bold] [dim]v{version}[/dim]{license_str}{optional_marker} • "
|
|
139
200
|
f"[red]✗ not packaged[/red]"
|
|
140
201
|
)
|
|
141
202
|
|
|
@@ -156,6 +217,12 @@ def build_tree(
|
|
|
156
217
|
tracker.update(package_name, discovered=len(deps))
|
|
157
218
|
|
|
158
219
|
for dep_name, _dep_req, dep_is_optional in deps:
|
|
220
|
+
# Skip dependencies matching exclude patterns
|
|
221
|
+
if exclude_patterns:
|
|
222
|
+
if any(fnmatch.fnmatch(dep_name, pattern) for pattern in exclude_patterns):
|
|
223
|
+
log(f"Filtered out dependency: {dep_name}", level="info", depth=depth)
|
|
224
|
+
continue
|
|
225
|
+
|
|
159
226
|
child = build_tree(
|
|
160
227
|
provider,
|
|
161
228
|
dep_name,
|
|
@@ -166,6 +233,7 @@ def build_tree(
|
|
|
166
233
|
tracker,
|
|
167
234
|
include_optional=include_optional,
|
|
168
235
|
is_optional_dep=dep_is_optional,
|
|
236
|
+
exclude_patterns=exclude_patterns,
|
|
169
237
|
)
|
|
170
238
|
if isinstance(child, str):
|
|
171
239
|
node.add(child)
|
|
@@ -179,72 +247,22 @@ def build_tree(
|
|
|
179
247
|
return node
|
|
180
248
|
|
|
181
249
|
|
|
182
|
-
def
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
250
|
+
def _check_fedora_for_dep(
|
|
251
|
+
provider: LanguageProvider, dep: Dependency
|
|
252
|
+
) -> DevBuildDepStatus:
|
|
253
|
+
"""Check Fedora packaging status for a single dev/build dependency.
|
|
186
254
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
elif "[/bold red]" in t:
|
|
199
|
-
name = t.split("[/bold red]")[0].split("[bold red]")[-1]
|
|
200
|
-
else:
|
|
201
|
-
name = t.split()[0]
|
|
202
|
-
stats.missing_list.append(name)
|
|
203
|
-
if is_optional:
|
|
204
|
-
stats.optional_missing += 1
|
|
205
|
-
stats.optional_missing_list.append(name)
|
|
206
|
-
elif "packaged" in t:
|
|
207
|
-
stats.packaged += 1
|
|
208
|
-
if is_optional:
|
|
209
|
-
stats.optional_packaged += 1
|
|
210
|
-
return
|
|
211
|
-
|
|
212
|
-
if hasattr(t, "label"):
|
|
213
|
-
label = str(t.label)
|
|
214
|
-
stats.total += 1
|
|
215
|
-
is_optional = "(optional)" in label
|
|
216
|
-
if is_optional:
|
|
217
|
-
stats.optional_total += 1
|
|
218
|
-
if "not packaged" in label or "not found" in label:
|
|
219
|
-
stats.missing += 1
|
|
220
|
-
# Handle both [bold] and [bold red] formats
|
|
221
|
-
if "[bold]" in label and "[bold red]" not in label:
|
|
222
|
-
name = label.split("[/bold]")[0].split("[bold]")[-1]
|
|
223
|
-
elif "[bold red]" in label:
|
|
224
|
-
name = label.split("[/bold red]")[0].split("[bold red]")[-1]
|
|
225
|
-
else:
|
|
226
|
-
name = "unknown"
|
|
227
|
-
stats.missing_list.append(name)
|
|
228
|
-
if is_optional:
|
|
229
|
-
stats.optional_missing += 1
|
|
230
|
-
stats.optional_missing_list.append(name)
|
|
231
|
-
elif "packaged" in label:
|
|
232
|
-
stats.packaged += 1
|
|
233
|
-
name = (
|
|
234
|
-
label.split("[/bold]")[0].split("[bold]")[-1]
|
|
235
|
-
if "[bold]" in label
|
|
236
|
-
else "unknown"
|
|
237
|
-
)
|
|
238
|
-
stats.packaged_list.append(name)
|
|
239
|
-
if is_optional:
|
|
240
|
-
stats.optional_packaged += 1
|
|
241
|
-
|
|
242
|
-
if hasattr(t, "children"):
|
|
243
|
-
for child in t.children:
|
|
244
|
-
walk(child)
|
|
245
|
-
|
|
246
|
-
walk(tree)
|
|
247
|
-
return stats
|
|
255
|
+
This is extracted as a standalone function so it can be submitted to
|
|
256
|
+
a :class:`~concurrent.futures.ThreadPoolExecutor`.
|
|
257
|
+
"""
|
|
258
|
+
fedora_status = provider.check_fedora_packaging(dep.name)
|
|
259
|
+
return DevBuildDepStatus(
|
|
260
|
+
name=dep.name,
|
|
261
|
+
version_requirement=dep.version_requirement,
|
|
262
|
+
is_packaged=fedora_status.is_packaged,
|
|
263
|
+
fedora_versions=fedora_status.versions,
|
|
264
|
+
fedora_packages=fedora_status.package_names,
|
|
265
|
+
)
|
|
248
266
|
|
|
249
267
|
|
|
250
268
|
@app.command(name="check")
|
|
@@ -306,6 +324,42 @@ def check(
|
|
|
306
324
|
help="Report format: stdout, json, markdown. Use 'list-formats' for all options.",
|
|
307
325
|
),
|
|
308
326
|
] = "stdout",
|
|
327
|
+
missing_only: Annotated[
|
|
328
|
+
bool,
|
|
329
|
+
cyclopts.Parameter(
|
|
330
|
+
("--missing-only", "-m"),
|
|
331
|
+
negative=(),
|
|
332
|
+
help="Only display packages that are missing from Fedora.",
|
|
333
|
+
),
|
|
334
|
+
] = False,
|
|
335
|
+
exclude: Annotated[
|
|
336
|
+
tuple[str, ...],
|
|
337
|
+
cyclopts.Parameter(
|
|
338
|
+
("--exclude", "-e"),
|
|
339
|
+
help="Glob pattern(s) to exclude dependencies (e.g., '*-windows', 'win*'). Can be specified multiple times.",
|
|
340
|
+
),
|
|
341
|
+
] = (),
|
|
342
|
+
template: Annotated[
|
|
343
|
+
Optional[str],
|
|
344
|
+
cyclopts.Parameter(
|
|
345
|
+
("--template", "-t"),
|
|
346
|
+
help="Path to a Jinja2 template file for custom report format. Only used with --report=template.",
|
|
347
|
+
),
|
|
348
|
+
] = None,
|
|
349
|
+
release: Annotated[
|
|
350
|
+
Optional[str],
|
|
351
|
+
cyclopts.Parameter(
|
|
352
|
+
("--release", "-R"),
|
|
353
|
+
help="Fedora release version to check against (e.g., '41', '42', 'rawhide').",
|
|
354
|
+
),
|
|
355
|
+
] = None,
|
|
356
|
+
repos: Annotated[
|
|
357
|
+
tuple[str, ...],
|
|
358
|
+
cyclopts.Parameter(
|
|
359
|
+
("--repos",),
|
|
360
|
+
help="Fedora repo(s) to query (e.g., 'fedora', 'updates', 'updates-testing'). Can be specified multiple times.",
|
|
361
|
+
),
|
|
362
|
+
] = (),
|
|
309
363
|
):
|
|
310
364
|
"""Check if a package's dependencies are available in Fedora.
|
|
311
365
|
|
|
@@ -327,6 +381,16 @@ def check(
|
|
|
327
381
|
Enable verbose debug logging.
|
|
328
382
|
report
|
|
329
383
|
Output format for the report.
|
|
384
|
+
missing_only
|
|
385
|
+
Only display packages that are missing from Fedora.
|
|
386
|
+
exclude
|
|
387
|
+
Glob pattern(s) to exclude dependencies from the analysis.
|
|
388
|
+
template
|
|
389
|
+
Path to a Jinja2 template file for custom report format.
|
|
390
|
+
release
|
|
391
|
+
Fedora release version to check against (e.g., '41', 'rawhide').
|
|
392
|
+
repos
|
|
393
|
+
Fedora repo(s) to query (e.g., 'fedora', 'updates', 'updates-testing').
|
|
330
394
|
"""
|
|
331
395
|
# Get the language provider
|
|
332
396
|
provider = get_provider(lang)
|
|
@@ -336,12 +400,40 @@ def check(
|
|
|
336
400
|
raise SystemExit(1)
|
|
337
401
|
|
|
338
402
|
# Get the reporter
|
|
339
|
-
|
|
403
|
+
template_path = Path(template) if template else None
|
|
404
|
+
|
|
405
|
+
# Validate template reporter requirements
|
|
406
|
+
if report.lower() in ("template", "tpl", "jinja", "jinja2"):
|
|
407
|
+
if template_path is None:
|
|
408
|
+
console.print("[red]Template reporter requires --template parameter.[/red]")
|
|
409
|
+
console.print(
|
|
410
|
+
"Example: woolly check mypackage --report=template --template=my_template.md"
|
|
411
|
+
)
|
|
412
|
+
raise SystemExit(1)
|
|
413
|
+
if not template_path.exists():
|
|
414
|
+
console.print(f"[red]Template file not found: {template_path}[/red]")
|
|
415
|
+
raise SystemExit(1)
|
|
416
|
+
elif template_path is not None:
|
|
417
|
+
console.print(
|
|
418
|
+
"[yellow]Warning: --template is only used with --report=template[/yellow]"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
reporter = get_reporter(report, console=console, template_path=template_path)
|
|
340
422
|
if reporter is None:
|
|
341
423
|
console.print(f"[red]Unknown report format: {report}[/red]")
|
|
342
424
|
console.print(f"Available formats: {', '.join(get_available_formats())}")
|
|
343
425
|
raise SystemExit(1)
|
|
344
426
|
|
|
427
|
+
# Convert exclude tuple to list for consistency
|
|
428
|
+
exclude_patterns = list(exclude) if exclude else None
|
|
429
|
+
|
|
430
|
+
# Configure Fedora release / repo targeting on the provider
|
|
431
|
+
fedora_repos_list = list(repos) if repos else None
|
|
432
|
+
if release:
|
|
433
|
+
provider.fedora_release = release
|
|
434
|
+
if fedora_repos_list:
|
|
435
|
+
provider.fedora_repos = fedora_repos_list
|
|
436
|
+
|
|
345
437
|
# Initialize logging
|
|
346
438
|
setup_logger(debug=debug)
|
|
347
439
|
log(
|
|
@@ -352,30 +444,66 @@ def check(
|
|
|
352
444
|
include_optional=optional,
|
|
353
445
|
debug=debug,
|
|
354
446
|
report_format=report,
|
|
447
|
+
exclude_patterns=exclude_patterns,
|
|
448
|
+
fedora_release=release,
|
|
449
|
+
fedora_repos=fedora_repos_list,
|
|
355
450
|
)
|
|
356
451
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
)
|
|
452
|
+
# ── Fetch root package info once (reused for license, version,
|
|
453
|
+
# features, and dev/build deps – avoids redundant calls) ──
|
|
454
|
+
root_info = provider.fetch_package_info(package)
|
|
455
|
+
root_license = root_info.license if root_info else None
|
|
456
|
+
resolved_version = version or (root_info.latest_version if root_info else None)
|
|
457
|
+
|
|
458
|
+
header = Text()
|
|
459
|
+
header.append(package, style="bold cyan")
|
|
460
|
+
header.append(f" ({provider.display_name})\n", style="dim")
|
|
461
|
+
header.append(f"Registry: {provider.registry_name}\n", style="dim")
|
|
462
|
+
header.append(f"Cache: {CACHE_DIR}", style="dim")
|
|
463
|
+
if release:
|
|
464
|
+
header.append("\n")
|
|
465
|
+
header.append(f"Release: {release}", style="dim")
|
|
466
|
+
if fedora_repos_list:
|
|
467
|
+
header.append("\n")
|
|
468
|
+
header.append(f"Repos: {', '.join(fedora_repos_list)}", style="dim")
|
|
360
469
|
if optional:
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
470
|
+
header.append("\n")
|
|
471
|
+
header.append("Including optional dependencies", style="yellow")
|
|
472
|
+
if exclude_patterns:
|
|
473
|
+
header.append("\n")
|
|
474
|
+
header.append(
|
|
475
|
+
f"Excluding dependencies matching: {', '.join(exclude_patterns)}",
|
|
476
|
+
style="yellow",
|
|
477
|
+
)
|
|
478
|
+
|
|
364
479
|
console.print()
|
|
480
|
+
console.print(
|
|
481
|
+
Panel(
|
|
482
|
+
header,
|
|
483
|
+
title="[bold]Analyzing Dependencies[/bold]",
|
|
484
|
+
border_style="blue",
|
|
485
|
+
padding=(0, 1),
|
|
486
|
+
)
|
|
487
|
+
)
|
|
365
488
|
|
|
366
489
|
tracker = None if no_progress else ProgressTracker(console)
|
|
367
490
|
|
|
368
491
|
if tracker:
|
|
369
492
|
tracker.start(f"Analyzing {provider.display_name} dependencies")
|
|
370
493
|
|
|
494
|
+
# Shared visited dict – build_tree populates it, then we derive stats.
|
|
495
|
+
visited: dict[str, tuple[bool, Optional[str], bool]] = {}
|
|
496
|
+
|
|
371
497
|
try:
|
|
372
498
|
tree = build_tree(
|
|
373
499
|
provider,
|
|
374
500
|
package,
|
|
375
501
|
version,
|
|
502
|
+
visited=visited,
|
|
376
503
|
max_depth=max_depth,
|
|
377
504
|
tracker=tracker,
|
|
378
505
|
include_optional=optional,
|
|
506
|
+
exclude_patterns=exclude_patterns,
|
|
379
507
|
)
|
|
380
508
|
if tracker:
|
|
381
509
|
tracker.finish()
|
|
@@ -386,8 +514,49 @@ def check(
|
|
|
386
514
|
|
|
387
515
|
console.print()
|
|
388
516
|
|
|
389
|
-
# Collect statistics
|
|
390
|
-
stats =
|
|
517
|
+
# ── Collect statistics directly from the visited dict ──
|
|
518
|
+
stats = _compute_stats_from_visited(visited)
|
|
519
|
+
|
|
520
|
+
# Fetch features/extras for the root package
|
|
521
|
+
features: list[FeatureInfo] = []
|
|
522
|
+
if resolved_version:
|
|
523
|
+
features = provider.fetch_features(package, resolved_version)
|
|
524
|
+
|
|
525
|
+
# ── Fetch dev and build deps in one call, check Fedora in parallel ──
|
|
526
|
+
dev_deps_status: list[DevBuildDepStatus] = []
|
|
527
|
+
build_deps_status: list[DevBuildDepStatus] = []
|
|
528
|
+
|
|
529
|
+
if resolved_version:
|
|
530
|
+
# Single fetch_dependencies call partitioned by kind
|
|
531
|
+
_normal, dev_deps, build_deps = provider.get_all_dependencies(
|
|
532
|
+
package, resolved_version, include_optional=optional
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
all_devbuild: list[Dependency] = dev_deps + build_deps
|
|
536
|
+
if all_devbuild:
|
|
537
|
+
# Check Fedora status for dev/build deps in parallel
|
|
538
|
+
results: dict[str, DevBuildDepStatus] = {}
|
|
539
|
+
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
540
|
+
future_to_dep = {
|
|
541
|
+
executor.submit(_check_fedora_for_dep, provider, dep): dep
|
|
542
|
+
for dep in all_devbuild
|
|
543
|
+
}
|
|
544
|
+
for future in as_completed(future_to_dep):
|
|
545
|
+
dep = future_to_dep[future]
|
|
546
|
+
results[dep.name] = future.result()
|
|
547
|
+
|
|
548
|
+
# Preserve original ordering
|
|
549
|
+
for dep in dev_deps:
|
|
550
|
+
dev_deps_status.append(results[dep.name])
|
|
551
|
+
for dep in build_deps:
|
|
552
|
+
build_deps_status.append(results[dep.name])
|
|
553
|
+
|
|
554
|
+
stats.dev_total = len(dev_deps_status)
|
|
555
|
+
stats.dev_packaged = sum(1 for d in dev_deps_status if d.is_packaged)
|
|
556
|
+
stats.dev_missing = sum(1 for d in dev_deps_status if not d.is_packaged)
|
|
557
|
+
stats.build_total = len(build_deps_status)
|
|
558
|
+
stats.build_packaged = sum(1 for d in build_deps_status if d.is_packaged)
|
|
559
|
+
stats.build_missing = sum(1 for d in build_deps_status if not d.is_packaged)
|
|
391
560
|
|
|
392
561
|
# Create report data
|
|
393
562
|
report_data = ReportData(
|
|
@@ -407,6 +576,19 @@ def check(
|
|
|
407
576
|
optional_packaged=stats.optional_packaged,
|
|
408
577
|
optional_missing=stats.optional_missing,
|
|
409
578
|
optional_missing_packages=stats.optional_missing_list,
|
|
579
|
+
missing_only=missing_only,
|
|
580
|
+
root_license=root_license,
|
|
581
|
+
features=features,
|
|
582
|
+
dev_dependencies=[d.model_dump() for d in dev_deps_status],
|
|
583
|
+
build_dependencies=[d.model_dump() for d in build_deps_status],
|
|
584
|
+
dev_total=stats.dev_total,
|
|
585
|
+
dev_packaged=stats.dev_packaged,
|
|
586
|
+
dev_missing=stats.dev_missing,
|
|
587
|
+
build_total=stats.build_total,
|
|
588
|
+
build_packaged=stats.build_packaged,
|
|
589
|
+
build_missing=stats.build_missing,
|
|
590
|
+
fedora_release=release,
|
|
591
|
+
fedora_repos=fedora_repos_list,
|
|
410
592
|
)
|
|
411
593
|
|
|
412
594
|
# Generate report
|
woolly/http.py
CHANGED
|
@@ -3,6 +3,8 @@ Shared HTTP client configuration for API requests.
|
|
|
3
3
|
|
|
4
4
|
This module provides centralized HTTP configuration including
|
|
5
5
|
the User-Agent header and common request settings.
|
|
6
|
+
Uses a lazily-initialized ``httpx.Client`` for connection pooling
|
|
7
|
+
and keep-alive across repeated requests to the same host.
|
|
6
8
|
"""
|
|
7
9
|
|
|
8
10
|
import httpx
|
|
@@ -17,18 +19,35 @@ DEFAULT_HEADERS = {
|
|
|
17
19
|
"User-Agent": f"woolly/{VERSION} ({PROJECT_URL})",
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
# Lazily-initialized shared client for connection pooling
|
|
23
|
+
_client: httpx.Client | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_client() -> httpx.Client:
|
|
27
|
+
"""Return the shared ``httpx.Client``, creating it on first use."""
|
|
28
|
+
global _client
|
|
29
|
+
if _client is None:
|
|
30
|
+
_client = httpx.Client(headers=DEFAULT_HEADERS)
|
|
31
|
+
return _client
|
|
32
|
+
|
|
20
33
|
|
|
21
34
|
def get(url: str, **kwargs) -> httpx.Response:
|
|
22
35
|
"""
|
|
23
36
|
Make a GET request with default headers.
|
|
24
37
|
|
|
38
|
+
Uses a shared ``httpx.Client`` so that TCP connections are
|
|
39
|
+
reused across requests to the same host.
|
|
40
|
+
|
|
25
41
|
Args:
|
|
26
42
|
url: The URL to request.
|
|
27
|
-
**kwargs: Additional arguments passed to
|
|
43
|
+
**kwargs: Additional arguments passed to ``client.get()``.
|
|
28
44
|
|
|
29
45
|
Returns:
|
|
30
46
|
httpx.Response object.
|
|
31
47
|
"""
|
|
32
48
|
headers = kwargs.pop("headers", {})
|
|
33
|
-
|
|
34
|
-
|
|
49
|
+
client = _get_client()
|
|
50
|
+
if headers:
|
|
51
|
+
merged_headers = {**DEFAULT_HEADERS, **headers}
|
|
52
|
+
return client.get(url, headers=merged_headers, **kwargs)
|
|
53
|
+
return client.get(url, **kwargs)
|