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.
@@ -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,10 @@ 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
+ if data.missing_only:
33
+ lines.append("**Missing only:** Yes")
32
34
  lines.append("")
33
35
 
34
36
  # Summary
@@ -39,85 +41,68 @@ class MarkdownReporter(Reporter):
39
41
  lines.append(f"| Total dependencies checked | {data.total_dependencies} |")
40
42
  lines.append(f"| Packaged in Fedora | {data.packaged_count} |")
41
43
  lines.append(f"| Missing from Fedora | {data.missing_count} |")
44
+
45
+ # Optional dependency stats
46
+ if data.optional_total > 0:
47
+ lines.append(f"| Optional dependencies | {data.optional_total} |")
48
+ lines.append(f"| Optional - Packaged | {data.optional_packaged} |")
49
+ lines.append(f"| Optional - Missing | {data.optional_missing} |")
42
50
  lines.append("")
43
51
 
44
- # Missing packages
45
- if data.missing_packages:
52
+ # Missing packages - use computed properties from ReportData
53
+ required_missing = data.required_missing_packages
54
+ optional_missing = data.optional_missing_set
55
+
56
+ if required_missing:
46
57
  lines.append("## Missing Packages")
47
58
  lines.append("")
48
59
  lines.append("The following packages need to be packaged for Fedora:")
49
60
  lines.append("")
50
- for name in sorted(set(data.missing_packages)):
61
+ for name in sorted(required_missing):
51
62
  lines.append(f"- `{name}`")
52
63
  lines.append("")
53
64
 
54
- # Packaged packages
55
- if data.packaged_packages:
65
+ if optional_missing:
66
+ lines.append("## Missing Optional Packages")
67
+ lines.append("")
68
+ lines.append("The following optional packages are not available in Fedora:")
69
+ lines.append("")
70
+ for name in sorted(optional_missing):
71
+ lines.append(f"- `{name}` *(optional)*")
72
+ lines.append("")
73
+
74
+ # Packaged packages - use computed property (skip if missing_only mode)
75
+ if data.unique_packaged_packages and not data.missing_only:
56
76
  lines.append("## Packaged Packages")
57
77
  lines.append("")
58
78
  lines.append("The following packages are already available in Fedora:")
59
79
  lines.append("")
60
- for name in sorted(set(data.packaged_packages)):
80
+ for name in sorted(data.unique_packaged_packages):
61
81
  lines.append(f"- `{name}`")
62
82
  lines.append("")
63
83
 
64
- # Dependency tree
65
- lines.append("## Dependency Tree")
66
- lines.append("")
67
- lines.append("```")
68
- lines.append(self._tree_to_text(data.tree))
69
- lines.append("```")
70
- lines.append("")
84
+ # Dependency tree (skip if missing_only mode)
85
+ if not data.missing_only:
86
+ lines.append("## Dependency Tree")
87
+ lines.append("")
88
+ lines.append("```")
89
+ lines.append(self._tree_to_text(data.tree))
90
+ lines.append("```")
91
+ lines.append("")
71
92
 
72
93
  return "\n".join(lines)
73
94
 
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
95
  def _tree_to_text(self, tree, prefix: str = "") -> str:
111
96
  """Convert Rich Tree to plain text representation."""
112
97
  lines = []
113
98
 
114
- # Get label text (strip Rich markup)
99
+ # Get label text (strip Rich markup) using inherited method and shared utility
115
100
  label = self._get_label(tree)
116
- label = self._strip_markup(label)
101
+ label = strip_markup(label)
117
102
 
118
103
  lines.append(label)
119
104
 
120
- # Get children
105
+ # Get children using inherited method
121
106
  children = self._get_children(tree)
122
107
 
123
108
  for i, child in enumerate(children):
@@ -133,8 +118,3 @@ class MarkdownReporter(Reporter):
133
118
  lines.append(line)
134
119
 
135
120
  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,19 +36,41 @@ 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
48
55
 
49
- # Print dependency tree
50
- self.console.print("[bold]Dependency Tree:[/bold]")
51
- self.console.print(data.tree)
52
- self.console.print()
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()
69
+
70
+ # Print dependency tree (skip if missing_only mode is enabled)
71
+ if not data.missing_only:
72
+ self.console.print("[bold]Dependency Tree:[/bold]")
73
+ self.console.print(data.tree)
74
+ self.console.print()
53
75
 
54
76
  return "" # Output is printed directly
@@ -0,0 +1,215 @@
1
+ """
2
+ Template-based report generator using Jinja2.
3
+
4
+ Allows users to provide a custom markdown template file for report generation.
5
+ Only a limited set of variables are exposed for security and simplicity.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from woolly.reporters.base import ReportData, Reporter, strip_markup
12
+
13
+
14
+ class TemplateReporter(Reporter):
15
+ """Reporter that generates reports from user-provided Jinja2 templates.
16
+
17
+ This reporter allows users to customize the output format by providing
18
+ a markdown template file. Only a limited, safe set of variables are
19
+ exposed to the template.
20
+
21
+ Available template variables:
22
+ Metadata:
23
+ - root_package: Name of the analyzed package
24
+ - language: Language/ecosystem name (e.g., "Rust", "Python")
25
+ - registry: Registry name (e.g., "crates.io", "PyPI")
26
+ - version: Package version (if specified)
27
+ - timestamp: Formatted timestamp string (YYYY-MM-DD HH:MM:SS)
28
+ - max_depth: Maximum recursion depth used
29
+
30
+ Statistics:
31
+ - total_dependencies: Total number of dependencies analyzed
32
+ - packaged_count: Number of packages available in Fedora
33
+ - missing_count: Number of packages missing from Fedora
34
+
35
+ Optional dependency statistics:
36
+ - include_optional: Whether optional deps were included
37
+ - optional_total: Total optional dependencies
38
+ - optional_packaged: Optional deps available in Fedora
39
+ - optional_missing: Optional deps missing from Fedora
40
+
41
+ Package lists (sorted):
42
+ - missing_packages: List of missing required package names
43
+ - packaged_packages: List of packaged package names
44
+ - optional_missing_packages: List of missing optional package names
45
+
46
+ Flags:
47
+ - missing_only: Whether missing-only mode was enabled
48
+
49
+ Example template:
50
+ # Report for {{ root_package }}
51
+
52
+ Generated: {{ timestamp }}
53
+ Language: {{ language }}
54
+
55
+ ## Summary
56
+ - Total: {{ total_dependencies }}
57
+ - Packaged: {{ packaged_count }}
58
+ - Missing: {{ missing_count }}
59
+
60
+ {% if missing_packages %}
61
+ ## Missing Packages
62
+ {% for pkg in missing_packages %}
63
+ - {{ pkg }}
64
+ {% endfor %}
65
+ {% endif %}
66
+ """
67
+
68
+ name = "template"
69
+ description = "Custom template-based report (requires --template)"
70
+ file_extension = "md"
71
+ writes_to_file = True
72
+
73
+ def __init__(self, template_path: Optional[Path] = None):
74
+ """Initialize the template reporter.
75
+
76
+ Args:
77
+ template_path: Path to the Jinja2 template file.
78
+ """
79
+ self._template_path = template_path
80
+ self._jinja2_available: Optional[bool] = None
81
+
82
+ @property
83
+ def template_path(self) -> Optional[Path]:
84
+ """Get the template path."""
85
+ return self._template_path
86
+
87
+ @template_path.setter
88
+ def template_path(self, value: Optional[Path]) -> None:
89
+ """Set the template path."""
90
+ self._template_path = value
91
+
92
+ def _check_jinja2(self) -> bool:
93
+ """Check if Jinja2 is available.
94
+
95
+ Returns:
96
+ True if Jinja2 is available, False otherwise.
97
+ """
98
+ if self._jinja2_available is None:
99
+ try:
100
+ import jinja2 # type: ignore[import-not-found] # noqa: F401
101
+
102
+ self._jinja2_available = True
103
+ except ImportError:
104
+ self._jinja2_available = False
105
+ return self._jinja2_available
106
+
107
+ def _get_template_context(self, data: ReportData) -> dict:
108
+ """Build the template context with allowed variables only.
109
+
110
+ This method creates a safe, limited context for template rendering.
111
+ Only specific variables from ReportData are exposed.
112
+
113
+ Args:
114
+ data: Report data containing all information.
115
+
116
+ Returns:
117
+ Dictionary of template variables.
118
+ """
119
+ return {
120
+ # Metadata
121
+ "root_package": data.root_package,
122
+ "language": data.language,
123
+ "registry": data.registry,
124
+ "version": data.version,
125
+ "timestamp": data.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
126
+ "max_depth": data.max_depth,
127
+ # Statistics
128
+ "total_dependencies": data.total_dependencies,
129
+ "packaged_count": data.packaged_count,
130
+ "missing_count": data.missing_count,
131
+ # Optional dependency statistics
132
+ "include_optional": data.include_optional,
133
+ "optional_total": data.optional_total,
134
+ "optional_packaged": data.optional_packaged,
135
+ "optional_missing": data.optional_missing,
136
+ # Package lists (sorted for consistent output)
137
+ "missing_packages": sorted(data.required_missing_packages),
138
+ "packaged_packages": sorted(data.unique_packaged_packages),
139
+ "optional_missing_packages": sorted(data.optional_missing_set),
140
+ # Flags
141
+ "missing_only": data.missing_only,
142
+ }
143
+
144
+ def generate(self, data: ReportData) -> str:
145
+ """Generate report content from the template.
146
+
147
+ Args:
148
+ data: Report data containing all information.
149
+
150
+ Returns:
151
+ Rendered template content as a string.
152
+
153
+ Raises:
154
+ RuntimeError: If Jinja2 is not installed or template is not set.
155
+ FileNotFoundError: If the template file doesn't exist.
156
+ jinja2.TemplateError: If there's a template syntax error.
157
+ """
158
+ if not self._check_jinja2():
159
+ raise RuntimeError(
160
+ "Jinja2 is required for template reports. "
161
+ "Install it with: pip install jinja2"
162
+ )
163
+
164
+ if self._template_path is None:
165
+ raise RuntimeError(
166
+ "No template path specified. Use --template to provide a template file."
167
+ )
168
+
169
+ if not self._template_path.exists():
170
+ raise FileNotFoundError(f"Template file not found: {self._template_path}")
171
+
172
+ # Import Jinja2 here (we've already checked it's available)
173
+ from jinja2 import ( # type: ignore[import-not-found]
174
+ Environment,
175
+ FileSystemLoader,
176
+ StrictUndefined,
177
+ select_autoescape,
178
+ )
179
+
180
+ # Create Jinja2 environment with security settings
181
+ env = Environment(
182
+ loader=FileSystemLoader(self._template_path.parent),
183
+ autoescape=select_autoescape(default=False),
184
+ undefined=StrictUndefined, # Raise error on undefined variables
185
+ # Disable dangerous features
186
+ extensions=[],
187
+ )
188
+
189
+ # Add custom filter to strip Rich markup
190
+ env.filters["strip_markup"] = strip_markup
191
+
192
+ # Load and render template
193
+ template = env.get_template(self._template_path.name)
194
+ context = self._get_template_context(data)
195
+
196
+ return template.render(**context)
197
+
198
+ def get_output_filename(self, data: ReportData) -> str:
199
+ """Get the output filename for the template report.
200
+
201
+ Uses the template filename as a base if available, otherwise
202
+ falls back to the default naming convention.
203
+
204
+ Args:
205
+ data: Report data.
206
+
207
+ Returns:
208
+ Filename string.
209
+ """
210
+ timestamp = data.timestamp.strftime("%Y%m%d_%H%M%S")
211
+ if self._template_path:
212
+ # Use template name as base (without extension)
213
+ base_name = self._template_path.stem
214
+ return f"woolly_{data.root_package}_{base_name}_{timestamp}.md"
215
+ return f"woolly_{data.root_package}_template_{timestamp}.md"