woolly 0.2.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.
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,36 @@ 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
+ @cached_property
77
+ def required_missing_packages(self) -> set[str]:
78
+ """Get the set of required (non-optional) missing packages."""
79
+ return set(self.missing_packages) - set(self.optional_missing_packages)
80
+
81
+ @cached_property
82
+ def optional_missing_set(self) -> set[str]:
83
+ """Get the set of optional missing packages."""
84
+ return set(self.optional_missing_packages)
85
+
86
+ @cached_property
87
+ def unique_packaged_packages(self) -> set[str]:
88
+ """Get the unique set of packaged packages."""
89
+ return set(self.packaged_packages)
90
+
65
91
 
66
92
  class Reporter(ABC):
67
93
  """
@@ -129,3 +155,59 @@ class Reporter(ABC):
129
155
  output_path = output_dir / self.get_output_filename(data)
130
156
  output_path.write_text(content)
131
157
  return output_path
158
+
159
+ # ----------------------------------------------------------------
160
+ # Shared tree traversal utilities for subclasses
161
+ # ----------------------------------------------------------------
162
+
163
+ def _get_label(self, node) -> str:
164
+ """
165
+ Extract the label text from a tree node, handling nested Trees.
166
+
167
+ Args:
168
+ node: A Rich Tree node or string.
169
+
170
+ Returns:
171
+ The label text as a string.
172
+ """
173
+ # If it's a string, return it directly
174
+ if isinstance(node, str):
175
+ return node
176
+
177
+ # Try to get label attribute (Rich Tree has this)
178
+ if hasattr(node, "label"):
179
+ label = node.label
180
+ # If label is None, return empty string
181
+ if label is None:
182
+ return ""
183
+ # If label is another Tree-like object (has its own label), recurse
184
+ if hasattr(label, "label"):
185
+ return self._get_label(label)
186
+ # Otherwise convert to string
187
+ return str(label)
188
+
189
+ # Fallback - shouldn't happen
190
+ return str(node)
191
+
192
+ def _get_children(self, node) -> list:
193
+ """
194
+ Get all children from a tree node, flattening nested Trees.
195
+
196
+ Args:
197
+ node: A Rich Tree node.
198
+
199
+ Returns:
200
+ List of child nodes.
201
+ """
202
+ children = []
203
+
204
+ if hasattr(node, "children"):
205
+ for child in node.children:
206
+ # If the child's label is itself a Tree, use that Tree's children
207
+ if hasattr(child, "label") and hasattr(child.label, "children"):
208
+ # The child is a wrapper around another tree
209
+ children.append(child.label)
210
+ else:
211
+ children.append(child)
212
+
213
+ return children
woolly/reporters/json.py CHANGED
@@ -5,10 +5,66 @@ 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
+
41
+
42
+ class ReportSummary(BaseModel):
43
+ """Summary statistics for the JSON report."""
44
+
45
+ total_dependencies: int
46
+ packaged_count: int
47
+ missing_count: int
48
+ optional: "OptionalSummary"
49
+
50
+
51
+ class OptionalSummary(BaseModel):
52
+ """Optional dependency statistics."""
53
+
54
+ total: int
55
+ packaged: int
56
+ missing: int
57
+
58
+
59
+ class JsonReport(BaseModel):
60
+ """Complete JSON report structure."""
61
+
62
+ metadata: ReportMetadata
63
+ summary: ReportSummary
64
+ missing_packages: list[str]
65
+ missing_optional_packages: list[str]
66
+ packaged_packages: list[str]
67
+ dependency_tree: TreeNodeData
12
68
 
13
69
 
14
70
  class JsonReporter(Reporter):
@@ -21,125 +77,99 @@ class JsonReporter(Reporter):
21
77
 
22
78
  def generate(self, data: ReportData) -> str:
23
79
  """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."""
80
+ report = JsonReport(
81
+ metadata=ReportMetadata(
82
+ generated_at=data.timestamp.isoformat(),
83
+ root_package=data.root_package,
84
+ language=data.language,
85
+ registry=data.registry,
86
+ version=data.version,
87
+ max_depth=data.max_depth,
88
+ include_optional=data.include_optional,
89
+ ),
90
+ summary=ReportSummary(
91
+ total_dependencies=data.total_dependencies,
92
+ packaged_count=data.packaged_count,
93
+ missing_count=data.missing_count,
94
+ optional=OptionalSummary(
95
+ total=data.optional_total,
96
+ packaged=data.optional_packaged,
97
+ missing=data.optional_missing,
98
+ ),
99
+ ),
100
+ missing_packages=sorted(data.required_missing_packages),
101
+ missing_optional_packages=sorted(data.optional_missing_set),
102
+ packaged_packages=sorted(data.unique_packaged_packages),
103
+ dependency_tree=self._tree_to_model(data.tree),
104
+ )
105
+
106
+ return report.model_dump_json(indent=2)
107
+
108
+ def _tree_to_model(self, tree) -> TreeNodeData:
109
+ """Convert Rich Tree to TreeNodeData model."""
84
110
  label = self._get_label(tree)
85
- result = self._parse_label(label)
111
+ node_data = self._parse_label(label)
86
112
 
87
- # Get children
113
+ # Get children using inherited method
88
114
  children = self._get_children(tree)
89
115
 
90
116
  if children:
91
- result["dependencies"] = [self._tree_to_dict(child) for child in children]
117
+ node_data.dependencies = [self._tree_to_model(child) for child in children]
118
+
119
+ return node_data
92
120
 
93
- return result
121
+ def _parse_label(self, label: str) -> TreeNodeData:
122
+ """Parse a tree label into structured TreeNodeData."""
123
+ # Strip Rich markup using shared utility
124
+ clean_label = strip_markup(label)
94
125
 
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)
126
+ node = TreeNodeData(raw=clean_label.strip())
99
127
 
100
- result: dict[str, Any] = {
101
- "raw": clean_label.strip(),
102
- }
128
+ # Check if this is an optional dependency
129
+ node.optional = "(optional)" in clean_label
103
130
 
104
131
  # 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())
132
+ # Pattern: "package_name vX.Y.Z (optional) • status" or "package_name vX.Y.Z • status"
133
+ match = re.match(
134
+ r"^(\S+)\s*(?:v([\d.]+))?\s*(?:\(optional\))?\s*•\s*(.+)$",
135
+ clean_label.strip(),
136
+ )
107
137
  if match:
108
- result["name"] = match.group(1)
138
+ node.name = match.group(1)
109
139
  if match.group(2):
110
- result["version"] = match.group(2)
140
+ node.version = match.group(2)
111
141
 
112
142
  status_text = match.group(3).strip()
113
143
  if (
114
144
  "packaged" in status_text.lower()
115
145
  and "not packaged" not in status_text.lower()
116
146
  ):
117
- result["status"] = "packaged"
147
+ node.status = "packaged"
118
148
  # Try to extract Fedora versions
119
149
  ver_match = re.search(r"\(([\d., ]+)\)", status_text)
120
150
  if ver_match:
121
- result["fedora_versions"] = [
151
+ node.fedora_versions = [
122
152
  v.strip() for v in ver_match.group(1).split(",")
123
153
  ]
124
154
  # Try to extract package names
125
155
  pkg_match = re.search(r"\[([^\]]+)\]", status_text)
126
156
  if pkg_match:
127
- result["fedora_packages"] = [
157
+ node.fedora_packages = [
128
158
  p.strip() for p in pkg_match.group(1).split(",")
129
159
  ]
130
160
  elif "not packaged" in status_text.lower():
131
- result["status"] = "not_packaged"
161
+ node.status = "not_packaged"
132
162
  elif "not found" in status_text.lower():
133
- result["status"] = "not_found"
163
+ node.status = "not_found"
134
164
  elif "already visited" in status_text.lower():
135
- result["status"] = "visited"
136
- result["is_packaged"] = "✓" in status_text
165
+ node.status = "visited"
166
+ node.is_packaged = "✓" in status_text
137
167
  else:
138
168
  # Simpler patterns
139
169
  if "already visited" in clean_label:
140
- result["status"] = "visited"
141
- result["is_packaged"] = "✓" in clean_label
170
+ node.status = "visited"
171
+ node.is_packaged = "✓" in clean_label
142
172
  elif "max depth" in clean_label:
143
- result["status"] = "max_depth_reached"
173
+ node.status = "max_depth_reached"
144
174
 
145
- return result
175
+ return node
@@ -4,9 +4,7 @@ Markdown report generator.
4
4
  Generates a markdown file with the full dependency analysis.
5
5
  """
6
6
 
7
- import re
8
-
9
- from woolly.reporters.base import Reporter, ReportData
7
+ from woolly.reporters.base import ReportData, Reporter, strip_markup
10
8
 
11
9
 
12
10
  class MarkdownReporter(Reporter):
@@ -29,6 +27,8 @@ class MarkdownReporter(Reporter):
29
27
  lines.append(f"**Registry:** {data.registry}")
30
28
  if data.version:
31
29
  lines.append(f"**Version:** {data.version}")
30
+ if data.include_optional:
31
+ lines.append("**Include optional:** Yes")
32
32
  lines.append("")
33
33
 
34
34
  # Summary
@@ -39,25 +39,43 @@ class MarkdownReporter(Reporter):
39
39
  lines.append(f"| Total dependencies checked | {data.total_dependencies} |")
40
40
  lines.append(f"| Packaged in Fedora | {data.packaged_count} |")
41
41
  lines.append(f"| Missing from Fedora | {data.missing_count} |")
42
+
43
+ # Optional dependency stats
44
+ if data.optional_total > 0:
45
+ lines.append(f"| Optional dependencies | {data.optional_total} |")
46
+ lines.append(f"| Optional - Packaged | {data.optional_packaged} |")
47
+ lines.append(f"| Optional - Missing | {data.optional_missing} |")
42
48
  lines.append("")
43
49
 
44
- # Missing packages
45
- if data.missing_packages:
50
+ # Missing packages - use computed properties from ReportData
51
+ required_missing = data.required_missing_packages
52
+ optional_missing = data.optional_missing_set
53
+
54
+ if required_missing:
46
55
  lines.append("## Missing Packages")
47
56
  lines.append("")
48
57
  lines.append("The following packages need to be packaged for Fedora:")
49
58
  lines.append("")
50
- for name in sorted(set(data.missing_packages)):
59
+ for name in sorted(required_missing):
51
60
  lines.append(f"- `{name}`")
52
61
  lines.append("")
53
62
 
54
- # Packaged packages
55
- if data.packaged_packages:
63
+ if optional_missing:
64
+ lines.append("## Missing Optional Packages")
65
+ lines.append("")
66
+ lines.append("The following optional packages are not available in Fedora:")
67
+ lines.append("")
68
+ for name in sorted(optional_missing):
69
+ lines.append(f"- `{name}` *(optional)*")
70
+ lines.append("")
71
+
72
+ # Packaged packages - use computed property
73
+ if data.unique_packaged_packages:
56
74
  lines.append("## Packaged Packages")
57
75
  lines.append("")
58
76
  lines.append("The following packages are already available in Fedora:")
59
77
  lines.append("")
60
- for name in sorted(set(data.packaged_packages)):
78
+ for name in sorted(data.unique_packaged_packages):
61
79
  lines.append(f"- `{name}`")
62
80
  lines.append("")
63
81
 
@@ -71,53 +89,17 @@ class MarkdownReporter(Reporter):
71
89
 
72
90
  return "\n".join(lines)
73
91
 
74
- def _get_label(self, node) -> str:
75
- """Extract the label text from a tree node, handling nested Trees."""
76
- # If it's a string, return it directly
77
- if isinstance(node, str):
78
- return node
79
-
80
- # Try to get label attribute (Rich Tree has this)
81
- if hasattr(node, "label"):
82
- label = node.label
83
- # If label is None, return empty string
84
- if label is None:
85
- return ""
86
- # If label is another Tree-like object (has its own label), recurse
87
- if hasattr(label, "label"):
88
- return self._get_label(label)
89
- # Otherwise convert to string
90
- return str(label)
91
-
92
- # Fallback - shouldn't happen
93
- return str(node)
94
-
95
- def _get_children(self, node) -> list:
96
- """Get all children from a tree node, flattening nested Trees."""
97
- children = []
98
-
99
- if hasattr(node, "children"):
100
- for child in node.children:
101
- # If the child's label is itself a Tree, use that Tree's children
102
- if hasattr(child, "label") and hasattr(child.label, "children"):
103
- # The child is a wrapper around another tree
104
- children.append(child.label)
105
- else:
106
- children.append(child)
107
-
108
- return children
109
-
110
92
  def _tree_to_text(self, tree, prefix: str = "") -> str:
111
93
  """Convert Rich Tree to plain text representation."""
112
94
  lines = []
113
95
 
114
- # Get label text (strip Rich markup)
96
+ # Get label text (strip Rich markup) using inherited method and shared utility
115
97
  label = self._get_label(tree)
116
- label = self._strip_markup(label)
98
+ label = strip_markup(label)
117
99
 
118
100
  lines.append(label)
119
101
 
120
- # Get children
102
+ # Get children using inherited method
121
103
  children = self._get_children(tree)
122
104
 
123
105
  for i, child in enumerate(children):
@@ -133,8 +115,3 @@ class MarkdownReporter(Reporter):
133
115
  lines.append(line)
134
116
 
135
117
  return "\n".join(lines)
136
-
137
- def _strip_markup(self, text: str) -> str:
138
- """Strip Rich markup from text."""
139
- # Remove Rich markup tags like [bold], [/bold], [green], etc.
140
- return re.sub(r"\[/?[^\]]+\]", "", text)
@@ -8,7 +8,7 @@ from rich import box
8
8
  from rich.console import Console
9
9
  from rich.table import Table
10
10
 
11
- from woolly.reporters.base import Reporter, ReportData
11
+ from woolly.reporters.base import ReportData, Reporter
12
12
 
13
13
 
14
14
  class StdoutReporter(Reporter):
@@ -36,15 +36,36 @@ class StdoutReporter(Reporter):
36
36
  table.add_row("[green]Packaged in Fedora[/green]", str(data.packaged_count))
37
37
  table.add_row("[red]Missing from Fedora[/red]", str(data.missing_count))
38
38
 
39
+ # Show optional dependency stats if any were found
40
+ if data.optional_total > 0:
41
+ table.add_row("", "") # Empty row as separator
42
+ table.add_row(
43
+ "[yellow]Optional dependencies[/yellow]", str(data.optional_total)
44
+ )
45
+ table.add_row("[yellow] ├─ Packaged[/yellow]", str(data.optional_packaged))
46
+ table.add_row("[yellow] └─ Missing[/yellow]", str(data.optional_missing))
47
+
39
48
  self.console.print(table)
40
49
  self.console.print()
41
50
 
42
- # Print missing packages list
51
+ # Print missing packages list using computed properties
43
52
  if data.missing_packages:
44
- self.console.print("[bold]Missing packages that need packaging:[/bold]")
45
- for name in sorted(set(data.missing_packages)):
46
- self.console.print(f" • {name}")
47
- self.console.print()
53
+ required_missing = data.required_missing_packages
54
+ optional_missing = data.optional_missing_set
55
+
56
+ if required_missing:
57
+ self.console.print("[bold]Missing packages that need packaging:[/bold]")
58
+ for name in sorted(required_missing):
59
+ self.console.print(f" • {name}")
60
+ self.console.print()
61
+
62
+ if optional_missing:
63
+ self.console.print(
64
+ "[bold yellow]Missing optional packages:[/bold yellow]"
65
+ )
66
+ for name in sorted(optional_missing):
67
+ self.console.print(f" • {name} [dim](optional)[/dim]")
68
+ self.console.print()
48
69
 
49
70
  # Print dependency tree
50
71
  self.console.print("[bold]Dependency Tree:[/bold]")