woolly 0.1.0__py3-none-any.whl → 0.3.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.
@@ -0,0 +1,422 @@
1
+ """
2
+ Check command - analyze package dependencies for Fedora availability.
3
+ """
4
+
5
+ from typing import Annotated, Optional
6
+
7
+ import cyclopts
8
+ from pydantic import BaseModel, Field
9
+ from rich.tree import Tree
10
+
11
+ from woolly.cache import CACHE_DIR
12
+ from woolly.commands import app, console
13
+ from woolly.debug import get_log_file, log, log_package_check, setup_logger
14
+ from woolly.languages import get_available_languages, get_provider
15
+ from woolly.languages.base import LanguageProvider
16
+ from woolly.progress import ProgressTracker
17
+ from woolly.reporters import ReportData, get_available_formats, get_reporter
18
+
19
+
20
+ class TreeStats(BaseModel):
21
+ """Statistics collected from dependency tree analysis."""
22
+
23
+ total: int = 0
24
+ packaged: int = 0
25
+ missing: int = 0
26
+ missing_list: list[str] = Field(default_factory=list)
27
+ packaged_list: list[str] = Field(default_factory=list)
28
+ optional_total: int = 0
29
+ optional_packaged: int = 0
30
+ optional_missing: int = 0
31
+ optional_missing_list: list[str] = Field(default_factory=list)
32
+
33
+
34
+ def build_tree(
35
+ provider: LanguageProvider,
36
+ package_name: str,
37
+ version: Optional[str] = None,
38
+ visited: Optional[dict] = None,
39
+ depth: int = 0,
40
+ max_depth: int = 50,
41
+ tracker: Optional[ProgressTracker] = None,
42
+ include_optional: bool = False,
43
+ is_optional_dep: bool = False,
44
+ ):
45
+ """
46
+ Recursively build a dependency tree for a package.
47
+
48
+ Parameters
49
+ ----------
50
+ provider
51
+ The language provider to use.
52
+ package_name
53
+ Name of the package to analyze.
54
+ version
55
+ Specific version, or None for latest.
56
+ visited
57
+ Dict of already-visited packages mapping to their status.
58
+ depth
59
+ Current recursion depth.
60
+ max_depth
61
+ Maximum recursion depth.
62
+ tracker
63
+ Optional progress tracker.
64
+ include_optional
65
+ If True, include optional dependencies in the analysis.
66
+ is_optional_dep
67
+ If True, this package is an optional dependency.
68
+
69
+ Returns
70
+ -------
71
+ Tree
72
+ Rich Tree object representing the dependency tree.
73
+ """
74
+ if visited is None:
75
+ visited = {}
76
+
77
+ optional_marker = " [yellow](optional)[/yellow]" if is_optional_dep else ""
78
+
79
+ if depth > max_depth:
80
+ log(f"Max depth reached for {package_name}", level="warning", depth=depth)
81
+ return f"[dim]{package_name}{optional_marker} (max depth reached)[/dim]"
82
+
83
+ if package_name in visited:
84
+ is_packaged, cached_version = visited[package_name]
85
+ log_package_check(
86
+ package_name,
87
+ "Skip (already visited)",
88
+ result="packaged" if is_packaged else "not packaged",
89
+ )
90
+ if is_packaged:
91
+ return f"[dim]{package_name}[/dim] [dim]v{cached_version}[/dim]{optional_marker} • [green]✓[/green] [dim](already visited)[/dim]"
92
+ else:
93
+ return f"[dim]{package_name}[/dim]{optional_marker} • [red]✗[/red] [dim](already visited)[/dim]"
94
+
95
+ if tracker:
96
+ tracker.update(package_name)
97
+
98
+ log_package_check(package_name, "Fetching version", source=provider.registry_name)
99
+
100
+ if version is None:
101
+ version = provider.get_latest_version(package_name)
102
+ if version is None:
103
+ visited[package_name] = (False, None)
104
+ log_package_check(
105
+ package_name, "Not found", source=provider.registry_name, result="error"
106
+ )
107
+ return (
108
+ f"[bold red]{package_name}[/bold red]{optional_marker} • "
109
+ f"[red]not found on {provider.registry_name}[/red]"
110
+ )
111
+
112
+ log_package_check(package_name, "Checking Fedora", source="dnf repoquery")
113
+
114
+ # Check Fedora packaging status
115
+ status = provider.check_fedora_packaging(package_name)
116
+ visited[package_name] = (status.is_packaged, version)
117
+
118
+ if status.is_packaged:
119
+ log_package_check(
120
+ package_name,
121
+ "Fedora status",
122
+ result=f"packaged ({', '.join(status.versions)})",
123
+ )
124
+ else:
125
+ log_package_check(package_name, "Fedora status", result="not packaged")
126
+
127
+ if status.is_packaged:
128
+ ver_str = ", ".join(status.versions) if status.versions else "unknown"
129
+ pkg_str = ", ".join(status.package_names) if status.package_names else ""
130
+ label = (
131
+ f"[bold]{package_name}[/bold] [dim]v{version}[/dim]{optional_marker} • "
132
+ f"[green]✓ packaged[/green] [dim]({ver_str})[/dim]"
133
+ )
134
+ if pkg_str:
135
+ label += f" [dim cyan][{pkg_str}][/dim cyan]"
136
+ else:
137
+ label = (
138
+ f"[bold]{package_name}[/bold] [dim]v{version}[/dim]{optional_marker} • "
139
+ f"[red]✗ not packaged[/red]"
140
+ )
141
+
142
+ node = Tree(label)
143
+
144
+ # ALWAYS recurse into dependencies regardless of packaging status
145
+ log_package_check(
146
+ package_name, "Fetching dependencies", source=provider.registry_name
147
+ )
148
+
149
+ deps = provider.get_normal_dependencies(
150
+ package_name, version, include_optional=include_optional
151
+ )
152
+
153
+ log(f"Found {len(deps)} dependencies for {package_name}", deps=len(deps))
154
+
155
+ if tracker and deps:
156
+ tracker.update(package_name, discovered=len(deps))
157
+
158
+ for dep_name, _dep_req, dep_is_optional in deps:
159
+ child = build_tree(
160
+ provider,
161
+ dep_name,
162
+ None,
163
+ visited,
164
+ depth + 1,
165
+ max_depth,
166
+ tracker,
167
+ include_optional=include_optional,
168
+ is_optional_dep=dep_is_optional,
169
+ )
170
+ if isinstance(child, str):
171
+ node.add(child)
172
+ elif isinstance(child, Tree):
173
+ # Directly append Tree children to avoid wrapping
174
+ # Rich's add() would wrap the Tree in another node
175
+ node.children.append(child)
176
+ else:
177
+ node.add(child)
178
+
179
+ return node
180
+
181
+
182
+ def collect_stats(tree, stats: Optional[TreeStats] = None) -> TreeStats:
183
+ """Walk the tree and collect statistics."""
184
+ if stats is None:
185
+ stats = TreeStats()
186
+
187
+ def walk(t):
188
+ if isinstance(t, str):
189
+ stats.total += 1
190
+ is_optional = "(optional)" in t
191
+ if is_optional:
192
+ stats.optional_total += 1
193
+ if "not packaged" in t or "not found" in t:
194
+ stats.missing += 1
195
+ # Handle both [bold] and [bold red] formats
196
+ if "[/bold]" in t:
197
+ name = t.split("[/bold]")[0].split("]")[-1]
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
248
+
249
+
250
+ @app.command(name="check")
251
+ def check(
252
+ package: Annotated[
253
+ str,
254
+ cyclopts.Parameter(
255
+ help="Package name to check.",
256
+ ),
257
+ ],
258
+ *,
259
+ lang: Annotated[
260
+ str,
261
+ cyclopts.Parameter(
262
+ ("--lang", "-l"),
263
+ help="Language/ecosystem. Use 'list-languages' to see options.",
264
+ ),
265
+ ] = "rust",
266
+ version: Annotated[
267
+ Optional[str],
268
+ cyclopts.Parameter(
269
+ ("--version", "-v"),
270
+ help="Specific version to check.",
271
+ ),
272
+ ] = None,
273
+ max_depth: Annotated[
274
+ int,
275
+ cyclopts.Parameter(
276
+ ("--max-depth", "-d"),
277
+ help="Maximum recursion depth.",
278
+ ),
279
+ ] = 50,
280
+ optional: Annotated[
281
+ bool,
282
+ cyclopts.Parameter(
283
+ ("--optional", "-o"),
284
+ negative=(),
285
+ help="Include optional dependencies in the analysis.",
286
+ ),
287
+ ] = False,
288
+ no_progress: Annotated[
289
+ bool,
290
+ cyclopts.Parameter(
291
+ negative=(),
292
+ help="Disable progress bar.",
293
+ ),
294
+ ] = False,
295
+ debug: Annotated[
296
+ bool,
297
+ cyclopts.Parameter(
298
+ negative=(),
299
+ help="Enable verbose debug logging (includes command outputs and API responses).",
300
+ ),
301
+ ] = False,
302
+ report: Annotated[
303
+ str,
304
+ cyclopts.Parameter(
305
+ ("--report", "-r"),
306
+ help="Report format: stdout, json, markdown. Use 'list-formats' for all options.",
307
+ ),
308
+ ] = "stdout",
309
+ ):
310
+ """Check if a package's dependencies are available in Fedora.
311
+
312
+ Parameters
313
+ ----------
314
+ package
315
+ The name of the package to analyze.
316
+ lang
317
+ Language/ecosystem (default: rust).
318
+ version
319
+ Specific version to check (default: latest).
320
+ max_depth
321
+ Maximum recursion depth for dependency tree.
322
+ optional
323
+ Include optional dependencies in the analysis.
324
+ no_progress
325
+ Disable progress bar during analysis.
326
+ debug
327
+ Enable verbose debug logging.
328
+ report
329
+ Output format for the report.
330
+ """
331
+ # Get the language provider
332
+ provider = get_provider(lang)
333
+ if provider is None:
334
+ console.print(f"[red]Unknown language: {lang}[/red]")
335
+ console.print(f"Available languages: {', '.join(get_available_languages())}")
336
+ raise SystemExit(1)
337
+
338
+ # Get the reporter
339
+ reporter = get_reporter(report, console=console)
340
+ if reporter is None:
341
+ console.print(f"[red]Unknown report format: {report}[/red]")
342
+ console.print(f"Available formats: {', '.join(get_available_formats())}")
343
+ raise SystemExit(1)
344
+
345
+ # Initialize logging
346
+ setup_logger(debug=debug)
347
+ log(
348
+ "Analysis started",
349
+ package=package,
350
+ language=lang,
351
+ max_depth=max_depth,
352
+ include_optional=optional,
353
+ debug=debug,
354
+ report_format=report,
355
+ )
356
+
357
+ console.print(
358
+ f"\n[bold underline]Analyzing {provider.display_name} package:[/] {package}"
359
+ )
360
+ if optional:
361
+ console.print("[yellow]Including optional dependencies[/yellow]")
362
+ console.print(f"[dim]Registry: {provider.registry_name}[/dim]")
363
+ console.print(f"[dim]Cache directory: {CACHE_DIR}[/dim]")
364
+ console.print()
365
+
366
+ tracker = None if no_progress else ProgressTracker(console)
367
+
368
+ if tracker:
369
+ tracker.start(f"Analyzing {provider.display_name} dependencies")
370
+
371
+ try:
372
+ tree = build_tree(
373
+ provider,
374
+ package,
375
+ version,
376
+ max_depth=max_depth,
377
+ tracker=tracker,
378
+ include_optional=optional,
379
+ )
380
+ if tracker:
381
+ tracker.finish()
382
+ finally:
383
+ if tracker:
384
+ tracker.stop()
385
+ log("Analysis complete")
386
+
387
+ console.print()
388
+
389
+ # Collect statistics
390
+ stats = collect_stats(tree)
391
+
392
+ # Create report data
393
+ report_data = ReportData(
394
+ root_package=package,
395
+ language=provider.display_name,
396
+ registry=provider.registry_name,
397
+ total_dependencies=stats.total,
398
+ packaged_count=stats.packaged,
399
+ missing_count=stats.missing,
400
+ missing_packages=stats.missing_list,
401
+ packaged_packages=stats.packaged_list,
402
+ tree=tree,
403
+ max_depth=max_depth,
404
+ version=version,
405
+ include_optional=optional,
406
+ optional_total=stats.optional_total,
407
+ optional_packaged=stats.optional_packaged,
408
+ optional_missing=stats.optional_missing,
409
+ optional_missing_packages=stats.optional_missing_list,
410
+ )
411
+
412
+ # Generate report
413
+ if reporter.writes_to_file:
414
+ output_path = reporter.write_report(report_data)
415
+ console.print(f"[green]Report saved to: {output_path}[/green]")
416
+ else:
417
+ reporter.generate(report_data)
418
+
419
+ # Show log file path
420
+ log_file = get_log_file()
421
+ if log_file:
422
+ console.print(f"[dim]Log saved to: {log_file}[/dim]\n")
@@ -0,0 +1,42 @@
1
+ """
2
+ Clear cache command - clear cached data.
3
+ """
4
+
5
+ from typing import Annotated
6
+
7
+ import cyclopts
8
+
9
+ from woolly.cache import clear_cache
10
+ from woolly.commands import app, console
11
+
12
+
13
+ @app.command(name="clear-cache")
14
+ def clear_cache_cmd(
15
+ fedora_only: Annotated[
16
+ bool,
17
+ cyclopts.Parameter(
18
+ ("--fedora-only", "-f"),
19
+ negative=(),
20
+ help="Clear only Fedora repoquery cache.",
21
+ ),
22
+ ] = False,
23
+ ):
24
+ """Clear cached data.
25
+
26
+ Parameters
27
+ ----------
28
+ fedora_only
29
+ If set, only clear the Fedora repoquery cache.
30
+ """
31
+ if fedora_only:
32
+ cleared = clear_cache("fedora")
33
+ if cleared:
34
+ console.print("[yellow]Cleared Fedora cache[/yellow]")
35
+ else:
36
+ console.print("[yellow]No Fedora cache to clear[/yellow]")
37
+ else:
38
+ cleared = clear_cache()
39
+ if cleared:
40
+ console.print(f"[yellow]Cleared caches: {', '.join(cleared)}[/yellow]")
41
+ else:
42
+ console.print("[yellow]No cache to clear[/yellow]")
@@ -0,0 +1,24 @@
1
+ """
2
+ List formats command - display available report formats.
3
+ """
4
+
5
+ from rich import box
6
+ from rich.table import Table
7
+
8
+ from woolly.commands import app, console
9
+ from woolly.reporters import list_reporters
10
+
11
+
12
+ @app.command(name="list-formats")
13
+ def list_formats_cmd():
14
+ """List available report formats."""
15
+ table = Table(title="Available Report Formats", box=box.ROUNDED)
16
+ table.add_column("Format", style="bold")
17
+ table.add_column("Description")
18
+ table.add_column("Aliases", style="dim")
19
+
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
+
24
+ console.print(table)
@@ -0,0 +1,26 @@
1
+ """
2
+ List languages command - display available language providers.
3
+ """
4
+
5
+ from rich import box
6
+ from rich.table import Table
7
+
8
+ from woolly.commands import app, console
9
+ from woolly.languages import get_provider, list_providers
10
+
11
+
12
+ @app.command(name="list-languages")
13
+ def list_languages_cmd():
14
+ """List available language providers."""
15
+ table = Table(title="Available Languages", box=box.ROUNDED)
16
+ table.add_column("Language", style="bold")
17
+ table.add_column("Registry")
18
+ table.add_column("Aliases", style="dim")
19
+
20
+ for info in list_providers():
21
+ provider = get_provider(info.language_id)
22
+ registry = provider.registry_name if provider else "Unknown"
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
+
26
+ console.print(table)
woolly/debug.py ADDED
@@ -0,0 +1,195 @@
1
+ """
2
+ Logging utilities for woolly.
3
+
4
+ All operations are logged to ~/.local/state/woolly/logs/
5
+ - INFO level: Basic operation info (always logged)
6
+ - DEBUG level: Detailed output from commands and API calls (with --debug)
7
+ """
8
+
9
+ import logging
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ # Log directory follows XDG Base Directory specification
15
+ LOG_DIR = Path.home() / ".local" / "state" / "woolly" / "logs"
16
+
17
+ # Global logger instance
18
+ _logger: Optional[logging.Logger] = None
19
+ _log_file: Optional[Path] = None
20
+ _debug_enabled: bool = False
21
+
22
+
23
+ def setup_logger(debug: bool = False) -> logging.Logger:
24
+ """
25
+ Set up the file logger.
26
+
27
+ Args:
28
+ debug: If True, log DEBUG level messages (command outputs, API responses).
29
+ If False, only log INFO level messages.
30
+
31
+ Returns:
32
+ Configured logger instance.
33
+ """
34
+ global _logger, _log_file, _debug_enabled
35
+
36
+ if _logger is not None:
37
+ return _logger
38
+
39
+ _debug_enabled = debug
40
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
41
+
42
+ # Create log file with timestamp
43
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
44
+ _log_file = LOG_DIR / f"woolly_{timestamp}.log"
45
+
46
+ _logger = logging.getLogger("woolly")
47
+ _logger.setLevel(logging.DEBUG) # Logger accepts all, handler filters
48
+
49
+ # Clear any existing handlers
50
+ _logger.handlers.clear()
51
+
52
+ # File handler - level depends on debug flag
53
+ file_handler = logging.FileHandler(_log_file)
54
+ file_handler.setLevel(logging.DEBUG if debug else logging.INFO)
55
+ file_format = logging.Formatter(
56
+ "%(asctime)s | %(levelname)-8s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
57
+ )
58
+ file_handler.setFormatter(file_format)
59
+ _logger.addHandler(file_handler)
60
+
61
+ return _logger
62
+
63
+
64
+ def is_debug_enabled() -> bool:
65
+ """Check if debug logging is enabled."""
66
+ return _debug_enabled
67
+
68
+
69
+ def get_logger() -> logging.Logger:
70
+ """Get the global logger instance, creating it if needed."""
71
+ if _logger is None:
72
+ return setup_logger()
73
+ return _logger
74
+
75
+
76
+ def get_log_file() -> Optional[Path]:
77
+ """Get the path to the current log file."""
78
+ return _log_file
79
+
80
+
81
+ def log(message: str, level: str = "info", **kwargs) -> None:
82
+ """
83
+ Log a message to the file.
84
+
85
+ Args:
86
+ message: Message to log.
87
+ level: Log level (debug, info, warning, error).
88
+ **kwargs: Additional context to include.
89
+ """
90
+ logger = get_logger()
91
+
92
+ # Format context as key=value pairs
93
+ if kwargs:
94
+ context = " | " + " ".join(f"{k}={v}" for k, v in kwargs.items())
95
+ message = message + context
96
+
97
+ log_method = getattr(logger, level, logger.info)
98
+ log_method(message)
99
+
100
+
101
+ def log_debug(message: str, **kwargs) -> None:
102
+ """Log a DEBUG level message (only shown with --debug)."""
103
+ log(message, level="debug", **kwargs)
104
+
105
+
106
+ def log_info(message: str, **kwargs) -> None:
107
+ """Log an INFO level message (always shown)."""
108
+ log(message, level="info", **kwargs)
109
+
110
+
111
+ def log_warning(message: str, **kwargs) -> None:
112
+ """Log a WARNING level message."""
113
+ log(message, level="warning", **kwargs)
114
+
115
+
116
+ def log_error(message: str, **kwargs) -> None:
117
+ """Log an ERROR level message."""
118
+ log(message, level="error", **kwargs)
119
+
120
+
121
+ def log_package_check(
122
+ package: str,
123
+ action: str,
124
+ source: Optional[str] = None,
125
+ result: Optional[str] = None,
126
+ ) -> None:
127
+ """
128
+ Log a package check operation (INFO level).
129
+
130
+ Args:
131
+ package: Package name being checked.
132
+ action: Action being performed.
133
+ source: Source of data (cache, api, repoquery).
134
+ result: Result of the operation.
135
+ """
136
+ log_info(
137
+ f"{action}: {package}",
138
+ package=package,
139
+ source=source,
140
+ result=result,
141
+ )
142
+
143
+
144
+ def log_command_output(command: str, output: str, exit_code: int = 0) -> None:
145
+ """
146
+ Log command execution and output (DEBUG level).
147
+
148
+ Args:
149
+ command: The command that was executed.
150
+ output: The command output.
151
+ exit_code: The command exit code.
152
+ """
153
+ log_debug(f"Command: {command}")
154
+ log_debug(f"Exit code: {exit_code}")
155
+ if output:
156
+ for line in output.strip().split("\n"):
157
+ log_debug(f" > {line}")
158
+
159
+
160
+ def log_api_request(method: str, url: str) -> None:
161
+ """
162
+ Log an API request (DEBUG level).
163
+
164
+ Args:
165
+ method: HTTP method (GET, POST, etc.)
166
+ url: The URL being requested.
167
+ """
168
+ log_debug(f"API {method}: {url}")
169
+
170
+
171
+ def log_api_response(status_code: int, body: Optional[str] = None) -> None:
172
+ """
173
+ Log an API response (DEBUG level).
174
+
175
+ Args:
176
+ status_code: HTTP status code.
177
+ body: Response body (truncated if too long).
178
+ """
179
+ log_debug(f"Response: {status_code}")
180
+ if body and is_debug_enabled():
181
+ # Truncate very long responses
182
+ if len(body) > 500:
183
+ body = body[:500] + "... (truncated)"
184
+ for line in body.strip().split("\n")[:10]: # Max 10 lines
185
+ log_debug(f" > {line}")
186
+
187
+
188
+ def log_cache_hit(namespace: str, key: str) -> None:
189
+ """Log a cache hit (DEBUG level)."""
190
+ log_debug(f"Cache HIT: {namespace}/{key}")
191
+
192
+
193
+ def log_cache_miss(namespace: str, key: str) -> None:
194
+ """Log a cache miss (DEBUG level)."""
195
+ log_debug(f"Cache MISS: {namespace}/{key}")