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/languages/rust.py CHANGED
@@ -7,8 +7,7 @@ Fedora repositories for Rust crate packages.
7
7
 
8
8
  from typing import Optional
9
9
 
10
- import httpx
11
-
10
+ from woolly import http
12
11
  from woolly.cache import DEFAULT_CACHE_TTL, read_cache, write_cache
13
12
  from woolly.debug import (
14
13
  log_api_request,
@@ -19,7 +18,6 @@ from woolly.debug import (
19
18
  from woolly.languages.base import Dependency, LanguageProvider, PackageInfo
20
19
 
21
20
  CRATES_API = "https://crates.io/api/v1/crates"
22
- HEADERS = {"User-Agent": "woolly/0.1.0 (https://github.com/r0x0d/woolly)"}
23
21
 
24
22
 
25
23
  class RustProvider(LanguageProvider):
@@ -50,7 +48,7 @@ class RustProvider(LanguageProvider):
50
48
  log_cache_miss(self.cache_namespace, cache_key)
51
49
  url = f"{CRATES_API}/{package_name}"
52
50
  log_api_request("GET", url)
53
- r = httpx.get(url, headers=HEADERS)
51
+ r = http.get(url)
54
52
  log_api_response(r.status_code, r.text[:500] if r.text else None)
55
53
 
56
54
  if r.status_code == 404:
@@ -91,7 +89,7 @@ class RustProvider(LanguageProvider):
91
89
  log_cache_miss(self.cache_namespace, cache_key)
92
90
  url = f"{CRATES_API}/{package_name}/{version}/dependencies"
93
91
  log_api_request("GET", url)
94
- r = httpx.get(url, headers=HEADERS)
92
+ r = http.get(url)
95
93
  log_api_response(r.status_code, r.text[:500] if r.text else None)
96
94
 
97
95
  if r.status_code != 200:
@@ -6,12 +6,26 @@ To add a new format, create a module in this directory that defines a class
6
6
  inheriting from Reporter and add it to REPORTERS dict.
7
7
  """
8
8
 
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from pydantic import BaseModel, Field
9
13
  from rich.console import Console
10
14
 
11
- from woolly.reporters.base import Reporter, ReportData, PackageStatus
12
- from woolly.reporters.stdout import StdoutReporter
13
- from woolly.reporters.markdown import MarkdownReporter
15
+ from woolly.reporters.base import ReportData, Reporter, strip_markup
14
16
  from woolly.reporters.json import JsonReporter
17
+ from woolly.reporters.markdown import MarkdownReporter
18
+ from woolly.reporters.stdout import StdoutReporter
19
+ from woolly.reporters.template import TemplateReporter
20
+
21
+
22
+ class ReporterInfo(BaseModel):
23
+ """Information about an available reporter."""
24
+
25
+ format_id: str
26
+ description: str
27
+ aliases: list[str] = Field(default_factory=list)
28
+
15
29
 
16
30
  # Registry of available reporters
17
31
  # Key: format identifier (used in CLI)
@@ -20,6 +34,7 @@ REPORTERS: dict[str, type[Reporter]] = {
20
34
  "stdout": StdoutReporter,
21
35
  "markdown": MarkdownReporter,
22
36
  "json": JsonReporter,
37
+ "template": TemplateReporter,
23
38
  }
24
39
 
25
40
  # Aliases for convenience
@@ -27,16 +42,24 @@ ALIASES: dict[str, str] = {
27
42
  "md": "markdown",
28
43
  "console": "stdout",
29
44
  "terminal": "stdout",
45
+ "tpl": "template",
46
+ "jinja": "template",
47
+ "jinja2": "template",
30
48
  }
31
49
 
32
50
 
33
- def get_reporter(format_name: str, console: Console | None = None) -> Reporter | None:
51
+ def get_reporter(
52
+ format_name: str,
53
+ console: Optional[Console] = None,
54
+ template_path: Optional[Path] = None,
55
+ ) -> Optional[Reporter]:
34
56
  """
35
57
  Get an instantiated reporter for the specified format.
36
58
 
37
59
  Args:
38
60
  format_name: Format identifier or alias (e.g., "json", "markdown", "md")
39
61
  console: Console instance for stdout reporter.
62
+ template_path: Path to template file for template reporter.
40
63
 
41
64
  Returns:
42
65
  Instantiated Reporter, or None if not found.
@@ -54,21 +77,31 @@ def get_reporter(format_name: str, console: Console | None = None) -> Reporter |
54
77
  if format_name == "stdout" and console:
55
78
  return StdoutReporter(console=console)
56
79
 
80
+ # TemplateReporter needs a template path
81
+ if format_name == "template":
82
+ return TemplateReporter(template_path=template_path)
83
+
57
84
  return reporter_class()
58
85
 
59
86
 
60
- def list_reporters() -> list[tuple[str, str, list[str]]]:
87
+ def list_reporters() -> list[ReporterInfo]:
61
88
  """
62
89
  List all available reporters.
63
90
 
64
91
  Returns:
65
- List of tuples: (format_id, description, aliases)
92
+ List of ReporterInfo objects with format details.
66
93
  """
67
94
  result = []
68
95
  for format_id, reporter_class in REPORTERS.items():
69
96
  # Find aliases for this format
70
97
  aliases = [alias for alias, target in ALIASES.items() if target == format_id]
71
- result.append((format_id, reporter_class.description, aliases))
98
+ result.append(
99
+ ReporterInfo(
100
+ format_id=format_id,
101
+ description=reporter_class.description,
102
+ aliases=aliases,
103
+ )
104
+ )
72
105
  return result
73
106
 
74
107
 
@@ -80,11 +113,13 @@ def get_available_formats() -> list[str]:
80
113
  __all__ = [
81
114
  "Reporter",
82
115
  "ReportData",
83
- "PackageStatus",
116
+ "ReporterInfo",
84
117
  "StdoutReporter",
85
118
  "MarkdownReporter",
86
119
  "JsonReporter",
120
+ "TemplateReporter",
87
121
  "get_reporter",
88
122
  "list_reporters",
89
123
  "get_available_formats",
124
+ "strip_markup",
90
125
  ]
woolly/reporters/base.py CHANGED
@@ -16,24 +16,29 @@ Example:
16
16
  ...
17
17
  """
18
18
 
19
+ import re
19
20
  from abc import ABC, abstractmethod
20
21
  from datetime import datetime
22
+ from functools import cached_property
21
23
  from pathlib import Path
22
24
  from typing import Any, Optional
23
25
 
24
26
  from pydantic import BaseModel, ConfigDict, Field
25
27
 
26
28
 
27
- class PackageStatus(BaseModel):
28
- """Status of a single package in the dependency tree."""
29
+ def strip_markup(text: str) -> str:
30
+ """
31
+ Strip Rich markup from text.
29
32
 
30
- name: str
31
- version: Optional[str] = None
32
- is_packaged: bool
33
- fedora_versions: list[str] = Field(default_factory=list)
34
- fedora_packages: list[str] = Field(default_factory=list)
35
- is_visited: bool = False
36
- not_found: bool = False
33
+ Removes Rich markup tags like [bold], [/bold], [green], etc.
34
+
35
+ Args:
36
+ text: Text containing Rich markup.
37
+
38
+ Returns:
39
+ Plain text without markup.
40
+ """
41
+ return re.sub(r"\[/?[^\]]+\]", "", text)
37
42
 
38
43
 
39
44
  class ReportData(BaseModel):
@@ -53,15 +58,39 @@ class ReportData(BaseModel):
53
58
  missing_packages: list[str] = Field(default_factory=list)
54
59
  packaged_packages: list[str] = Field(default_factory=list)
55
60
 
61
+ # Optional dependency statistics
62
+ include_optional: bool = False
63
+ optional_total: int = 0
64
+ optional_packaged: int = 0
65
+ optional_missing: int = 0
66
+ optional_missing_packages: list[str] = Field(default_factory=list)
67
+
56
68
  # Full tree for detailed reports
57
69
  tree: Any # Rich Tree object - not JSON serializable
58
- packages: list[PackageStatus] = Field(default_factory=list)
59
70
 
60
71
  # Metadata
61
72
  timestamp: datetime = Field(default_factory=datetime.now)
62
73
  max_depth: int = 50
63
74
  version: Optional[str] = None
64
75
 
76
+ # Display options
77
+ missing_only: bool = False
78
+
79
+ @cached_property
80
+ def required_missing_packages(self) -> set[str]:
81
+ """Get the set of required (non-optional) missing packages."""
82
+ return set(self.missing_packages) - set(self.optional_missing_packages)
83
+
84
+ @cached_property
85
+ def optional_missing_set(self) -> set[str]:
86
+ """Get the set of optional missing packages."""
87
+ return set(self.optional_missing_packages)
88
+
89
+ @cached_property
90
+ def unique_packaged_packages(self) -> set[str]:
91
+ """Get the unique set of packaged packages."""
92
+ return set(self.packaged_packages)
93
+
65
94
 
66
95
  class Reporter(ABC):
67
96
  """
@@ -129,3 +158,59 @@ class Reporter(ABC):
129
158
  output_path = output_dir / self.get_output_filename(data)
130
159
  output_path.write_text(content)
131
160
  return output_path
161
+
162
+ # ----------------------------------------------------------------
163
+ # Shared tree traversal utilities for subclasses
164
+ # ----------------------------------------------------------------
165
+
166
+ def _get_label(self, node) -> str:
167
+ """
168
+ Extract the label text from a tree node, handling nested Trees.
169
+
170
+ Args:
171
+ node: A Rich Tree node or string.
172
+
173
+ Returns:
174
+ The label text as a string.
175
+ """
176
+ # If it's a string, return it directly
177
+ if isinstance(node, str):
178
+ return node
179
+
180
+ # Try to get label attribute (Rich Tree has this)
181
+ if hasattr(node, "label"):
182
+ label = node.label
183
+ # If label is None, return empty string
184
+ if label is None:
185
+ return ""
186
+ # If label is another Tree-like object (has its own label), recurse
187
+ if hasattr(label, "label"):
188
+ return self._get_label(label)
189
+ # Otherwise convert to string
190
+ return str(label)
191
+
192
+ # Fallback - shouldn't happen
193
+ return str(node)
194
+
195
+ def _get_children(self, node) -> list:
196
+ """
197
+ Get all children from a tree node, flattening nested Trees.
198
+
199
+ Args:
200
+ node: A Rich Tree node.
201
+
202
+ Returns:
203
+ List of child nodes.
204
+ """
205
+ children = []
206
+
207
+ if hasattr(node, "children"):
208
+ for child in node.children:
209
+ # If the child's label is itself a Tree, use that Tree's children
210
+ if hasattr(child, "label") and hasattr(child.label, "children"):
211
+ # The child is a wrapper around another tree
212
+ children.append(child.label)
213
+ else:
214
+ children.append(child)
215
+
216
+ return children
woolly/reporters/json.py CHANGED
@@ -5,10 +5,67 @@ Generates a JSON file with structured data for machine consumption.
5
5
  """
6
6
 
7
7
  import re
8
- import json
9
- from typing import Any
8
+ from typing import Optional
10
9
 
11
- from woolly.reporters.base import Reporter, ReportData
10
+ from pydantic import BaseModel, Field
11
+
12
+ from woolly.reporters.base import ReportData, Reporter, strip_markup
13
+
14
+
15
+ class TreeNodeData(BaseModel):
16
+ """Structured data for a single node in the dependency tree."""
17
+
18
+ raw: str
19
+ name: Optional[str] = None
20
+ version: Optional[str] = None
21
+ optional: bool = False
22
+ status: Optional[str] = None
23
+ is_packaged: Optional[bool] = None
24
+ fedora_versions: list[str] = Field(default_factory=list)
25
+ fedora_packages: list[str] = Field(default_factory=list)
26
+ dependencies: list["TreeNodeData"] = Field(default_factory=list)
27
+
28
+
29
+ class ReportMetadata(BaseModel):
30
+ """Metadata for the JSON report."""
31
+
32
+ generated_at: str
33
+ tool: str = "woolly"
34
+ root_package: str
35
+ language: str
36
+ registry: str
37
+ version: Optional[str] = None
38
+ max_depth: int
39
+ include_optional: bool
40
+ missing_only: bool = False
41
+
42
+
43
+ class ReportSummary(BaseModel):
44
+ """Summary statistics for the JSON report."""
45
+
46
+ total_dependencies: int
47
+ packaged_count: int
48
+ missing_count: int
49
+ optional: "OptionalSummary"
50
+
51
+
52
+ class OptionalSummary(BaseModel):
53
+ """Optional dependency statistics."""
54
+
55
+ total: int
56
+ packaged: int
57
+ missing: int
58
+
59
+
60
+ class JsonReport(BaseModel):
61
+ """Complete JSON report structure."""
62
+
63
+ metadata: ReportMetadata
64
+ summary: ReportSummary
65
+ missing_packages: list[str]
66
+ missing_optional_packages: list[str]
67
+ packaged_packages: list[str]
68
+ dependency_tree: TreeNodeData
12
69
 
13
70
 
14
71
  class JsonReporter(Reporter):
@@ -21,125 +78,103 @@ class JsonReporter(Reporter):
21
78
 
22
79
  def generate(self, data: ReportData) -> str:
23
80
  """Generate JSON report content."""
24
- report = {
25
- "metadata": {
26
- "generated_at": data.timestamp.isoformat(),
27
- "tool": "woolly",
28
- "root_package": data.root_package,
29
- "language": data.language,
30
- "registry": data.registry,
31
- "version": data.version,
32
- "max_depth": data.max_depth,
33
- },
34
- "summary": {
35
- "total_dependencies": data.total_dependencies,
36
- "packaged_count": data.packaged_count,
37
- "missing_count": data.missing_count,
38
- },
39
- "missing_packages": sorted(set(data.missing_packages)),
40
- "packaged_packages": sorted(set(data.packaged_packages)),
41
- "dependency_tree": self._tree_to_dict(data.tree),
42
- }
43
-
44
- return json.dumps(report, indent=2)
45
-
46
- def _get_label(self, node) -> str:
47
- """Extract the label text from a tree node, handling nested Trees."""
48
- # If it's a string, return it directly
49
- if isinstance(node, str):
50
- return node
51
-
52
- # Try to get label attribute (Rich Tree has this)
53
- if hasattr(node, "label"):
54
- label = node.label
55
- # If label is None, return empty string
56
- if label is None:
57
- return ""
58
- # If label is another Tree-like object (has its own label), recurse
59
- if hasattr(label, "label"):
60
- return self._get_label(label)
61
- # Otherwise convert to string
62
- return str(label)
63
-
64
- # Fallback - shouldn't happen
65
- return str(node)
66
-
67
- def _get_children(self, node) -> list:
68
- """Get all children from a tree node, flattening nested Trees."""
69
- children = []
70
-
71
- if hasattr(node, "children"):
72
- for child in node.children:
73
- # If the child's label is itself a Tree, use that Tree's children
74
- if hasattr(child, "label") and hasattr(child.label, "children"):
75
- # The child is a wrapper around another tree
76
- children.append(child.label)
77
- else:
78
- children.append(child)
79
-
80
- return children
81
-
82
- def _tree_to_dict(self, tree) -> dict[str, Any]:
83
- """Convert Rich Tree to dictionary representation."""
81
+ report = JsonReport(
82
+ metadata=ReportMetadata(
83
+ generated_at=data.timestamp.isoformat(),
84
+ root_package=data.root_package,
85
+ language=data.language,
86
+ registry=data.registry,
87
+ version=data.version,
88
+ max_depth=data.max_depth,
89
+ include_optional=data.include_optional,
90
+ missing_only=data.missing_only,
91
+ ),
92
+ summary=ReportSummary(
93
+ total_dependencies=data.total_dependencies,
94
+ packaged_count=data.packaged_count,
95
+ missing_count=data.missing_count,
96
+ optional=OptionalSummary(
97
+ total=data.optional_total,
98
+ packaged=data.optional_packaged,
99
+ missing=data.optional_missing,
100
+ ),
101
+ ),
102
+ missing_packages=sorted(data.required_missing_packages),
103
+ missing_optional_packages=sorted(data.optional_missing_set),
104
+ # Skip packaged packages list when missing_only mode is enabled
105
+ packaged_packages=[]
106
+ if data.missing_only
107
+ else sorted(data.unique_packaged_packages),
108
+ dependency_tree=self._tree_to_model(data.tree),
109
+ )
110
+
111
+ return report.model_dump_json(indent=2)
112
+
113
+ def _tree_to_model(self, tree) -> TreeNodeData:
114
+ """Convert Rich Tree to TreeNodeData model."""
84
115
  label = self._get_label(tree)
85
- result = self._parse_label(label)
116
+ node_data = self._parse_label(label)
86
117
 
87
- # Get children
118
+ # Get children using inherited method
88
119
  children = self._get_children(tree)
89
120
 
90
121
  if children:
91
- result["dependencies"] = [self._tree_to_dict(child) for child in children]
122
+ node_data.dependencies = [self._tree_to_model(child) for child in children]
123
+
124
+ return node_data
92
125
 
93
- return result
126
+ def _parse_label(self, label: str) -> TreeNodeData:
127
+ """Parse a tree label into structured TreeNodeData."""
128
+ # Strip Rich markup using shared utility
129
+ clean_label = strip_markup(label)
94
130
 
95
- def _parse_label(self, label: str) -> dict[str, Any]:
96
- """Parse a tree label into structured data."""
97
- # Strip Rich markup
98
- clean_label = re.sub(r"\[/?[^\]]+\]", "", label)
131
+ node = TreeNodeData(raw=clean_label.strip())
99
132
 
100
- result: dict[str, Any] = {
101
- "raw": clean_label.strip(),
102
- }
133
+ # Check if this is an optional dependency
134
+ node.optional = "(optional)" in clean_label
103
135
 
104
136
  # Try to extract package name and version
105
- # Pattern: "package_name vX.Y.Z • status"
106
- match = re.match(r"^(\S+)\s*(?:v([\d.]+))?\s*•\s*(.+)$", clean_label.strip())
137
+ # Pattern: "package_name vX.Y.Z (optional) • status" or "package_name vX.Y.Z • status"
138
+ match = re.match(
139
+ r"^(\S+)\s*(?:v([\d.]+))?\s*(?:\(optional\))?\s*•\s*(.+)$",
140
+ clean_label.strip(),
141
+ )
107
142
  if match:
108
- result["name"] = match.group(1)
143
+ node.name = match.group(1)
109
144
  if match.group(2):
110
- result["version"] = match.group(2)
145
+ node.version = match.group(2)
111
146
 
112
147
  status_text = match.group(3).strip()
113
148
  if (
114
149
  "packaged" in status_text.lower()
115
150
  and "not packaged" not in status_text.lower()
116
151
  ):
117
- result["status"] = "packaged"
152
+ node.status = "packaged"
118
153
  # Try to extract Fedora versions
119
154
  ver_match = re.search(r"\(([\d., ]+)\)", status_text)
120
155
  if ver_match:
121
- result["fedora_versions"] = [
156
+ node.fedora_versions = [
122
157
  v.strip() for v in ver_match.group(1).split(",")
123
158
  ]
124
159
  # Try to extract package names
125
160
  pkg_match = re.search(r"\[([^\]]+)\]", status_text)
126
161
  if pkg_match:
127
- result["fedora_packages"] = [
162
+ node.fedora_packages = [
128
163
  p.strip() for p in pkg_match.group(1).split(",")
129
164
  ]
130
165
  elif "not packaged" in status_text.lower():
131
- result["status"] = "not_packaged"
166
+ node.status = "not_packaged"
132
167
  elif "not found" in status_text.lower():
133
- result["status"] = "not_found"
168
+ node.status = "not_found"
134
169
  elif "already visited" in status_text.lower():
135
- result["status"] = "visited"
136
- result["is_packaged"] = "✓" in status_text
170
+ node.status = "visited"
171
+ node.is_packaged = "✓" in status_text
137
172
  else:
138
173
  # Simpler patterns
139
174
  if "already visited" in clean_label:
140
- result["status"] = "visited"
141
- result["is_packaged"] = "✓" in clean_label
175
+ node.status = "visited"
176
+ node.is_packaged = "✓" in clean_label
142
177
  elif "max depth" in clean_label:
143
- result["status"] = "max_depth_reached"
178
+ node.status = "max_depth_reached"
144
179
 
145
- return result
180
+ return node