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/cache.py CHANGED
@@ -8,11 +8,20 @@ import time
8
8
  from pathlib import Path
9
9
  from typing import Any, Optional
10
10
 
11
+ from pydantic import BaseModel, Field, ValidationError
12
+
11
13
  CACHE_DIR = Path.home() / ".cache" / "woolly"
12
14
  DEFAULT_CACHE_TTL = 86400 * 7 # 7 days
13
15
  FEDORA_CACHE_TTL = 86400 # 1 day for Fedora repoquery data
14
16
 
15
17
 
18
+ class CacheEntry(BaseModel):
19
+ """A cached value with timestamp."""
20
+
21
+ timestamp: float = Field(default_factory=time.time)
22
+ value: Any
23
+
24
+
16
25
  def ensure_cache_dir() -> None:
17
26
  """Create cache directory if it doesn't exist."""
18
27
  CACHE_DIR.mkdir(parents=True, exist_ok=True)
@@ -35,18 +44,19 @@ def read_cache(namespace: str, key: str, ttl: int = DEFAULT_CACHE_TTL) -> Option
35
44
 
36
45
  try:
37
46
  data = json.loads(path.read_text())
38
- if time.time() - data.get("timestamp", 0) > ttl:
47
+ entry = CacheEntry.model_validate(data)
48
+ if time.time() - entry.timestamp > ttl:
39
49
  return None # Expired
40
- return data.get("value")
41
- except (json.JSONDecodeError, KeyError):
50
+ return entry.value
51
+ except (json.JSONDecodeError, KeyError, ValidationError):
42
52
  return None
43
53
 
44
54
 
45
55
  def write_cache(namespace: str, key: str, value: Any) -> None:
46
56
  """Write to disk cache."""
47
57
  path = get_cache_path(namespace, key)
48
- data = {"timestamp": time.time(), "value": value}
49
- path.write_text(json.dumps(data))
58
+ entry = CacheEntry(value=value)
59
+ path.write_text(entry.model_dump_json())
50
60
 
51
61
 
52
62
  def clear_cache(namespace: Optional[str] = None) -> list[str]:
woolly/commands/check.py CHANGED
@@ -5,6 +5,7 @@ Check command - analyze package dependencies for Fedora availability.
5
5
  from typing import Annotated, Optional
6
6
 
7
7
  import cyclopts
8
+ from pydantic import BaseModel, Field
8
9
  from rich.tree import Tree
9
10
 
10
11
  from woolly.cache import CACHE_DIR
@@ -16,6 +17,20 @@ from woolly.progress import ProgressTracker
16
17
  from woolly.reporters import ReportData, get_available_formats, get_reporter
17
18
 
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
+
19
34
  def build_tree(
20
35
  provider: LanguageProvider,
21
36
  package_name: str,
@@ -24,6 +39,8 @@ def build_tree(
24
39
  depth: int = 0,
25
40
  max_depth: int = 50,
26
41
  tracker: Optional[ProgressTracker] = None,
42
+ include_optional: bool = False,
43
+ is_optional_dep: bool = False,
27
44
  ):
28
45
  """
29
46
  Recursively build a dependency tree for a package.
@@ -44,6 +61,10 @@ def build_tree(
44
61
  Maximum recursion depth.
45
62
  tracker
46
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.
47
68
 
48
69
  Returns
49
70
  -------
@@ -53,9 +74,11 @@ def build_tree(
53
74
  if visited is None:
54
75
  visited = {}
55
76
 
77
+ optional_marker = " [yellow](optional)[/yellow]" if is_optional_dep else ""
78
+
56
79
  if depth > max_depth:
57
80
  log(f"Max depth reached for {package_name}", level="warning", depth=depth)
58
- return f"[dim]{package_name} (max depth reached)[/dim]"
81
+ return f"[dim]{package_name}{optional_marker} (max depth reached)[/dim]"
59
82
 
60
83
  if package_name in visited:
61
84
  is_packaged, cached_version = visited[package_name]
@@ -65,11 +88,9 @@ def build_tree(
65
88
  result="packaged" if is_packaged else "not packaged",
66
89
  )
67
90
  if is_packaged:
68
- return f"[dim]{package_name}[/dim] [dim]v{cached_version}[/dim] • [green]✓[/green] [dim](already visited)[/dim]"
91
+ return f"[dim]{package_name}[/dim] [dim]v{cached_version}[/dim]{optional_marker} • [green]✓[/green] [dim](already visited)[/dim]"
69
92
  else:
70
- return (
71
- f"[dim]{package_name}[/dim] • [red]✗[/red] [dim](already visited)[/dim]"
72
- )
93
+ return f"[dim]{package_name}[/dim]{optional_marker} • [red]✗[/red] [dim](already visited)[/dim]"
73
94
 
74
95
  if tracker:
75
96
  tracker.update(package_name)
@@ -84,7 +105,7 @@ def build_tree(
84
105
  package_name, "Not found", source=provider.registry_name, result="error"
85
106
  )
86
107
  return (
87
- f"[bold red]{package_name}[/bold red] • "
108
+ f"[bold red]{package_name}[/bold red]{optional_marker} • "
88
109
  f"[red]not found on {provider.registry_name}[/red]"
89
110
  )
90
111
 
@@ -107,14 +128,14 @@ def build_tree(
107
128
  ver_str = ", ".join(status.versions) if status.versions else "unknown"
108
129
  pkg_str = ", ".join(status.package_names) if status.package_names else ""
109
130
  label = (
110
- f"[bold]{package_name}[/bold] [dim]v{version}[/dim] • "
131
+ f"[bold]{package_name}[/bold] [dim]v{version}[/dim]{optional_marker} • "
111
132
  f"[green]✓ packaged[/green] [dim]({ver_str})[/dim]"
112
133
  )
113
134
  if pkg_str:
114
135
  label += f" [dim cyan][{pkg_str}][/dim cyan]"
115
136
  else:
116
137
  label = (
117
- f"[bold]{package_name}[/bold] [dim]v{version}[/dim] • "
138
+ f"[bold]{package_name}[/bold] [dim]v{version}[/dim]{optional_marker} • "
118
139
  f"[red]✗ not packaged[/red]"
119
140
  )
120
141
 
@@ -125,16 +146,26 @@ def build_tree(
125
146
  package_name, "Fetching dependencies", source=provider.registry_name
126
147
  )
127
148
 
128
- deps = provider.get_normal_dependencies(package_name, version)
149
+ deps = provider.get_normal_dependencies(
150
+ package_name, version, include_optional=include_optional
151
+ )
129
152
 
130
153
  log(f"Found {len(deps)} dependencies for {package_name}", deps=len(deps))
131
154
 
132
155
  if tracker and deps:
133
156
  tracker.update(package_name, discovered=len(deps))
134
157
 
135
- for dep_name, _dep_req in deps:
158
+ for dep_name, _dep_req, dep_is_optional in deps:
136
159
  child = build_tree(
137
- provider, dep_name, None, visited, depth + 1, max_depth, tracker
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,
138
169
  )
139
170
  if isinstance(child, str):
140
171
  node.add(child)
@@ -148,51 +179,65 @@ def build_tree(
148
179
  return node
149
180
 
150
181
 
151
- def collect_stats(tree, stats=None):
182
+ def collect_stats(tree, stats: Optional[TreeStats] = None) -> TreeStats:
152
183
  """Walk the tree and collect statistics."""
153
184
  if stats is None:
154
- stats = {
155
- "total": 0,
156
- "packaged": 0,
157
- "missing": 0,
158
- "missing_list": [],
159
- "packaged_list": [],
160
- }
185
+ stats = TreeStats()
161
186
 
162
187
  def walk(t):
163
188
  if isinstance(t, str):
164
- stats["total"] += 1
189
+ stats.total += 1
190
+ is_optional = "(optional)" in t
191
+ if is_optional:
192
+ stats.optional_total += 1
165
193
  if "not packaged" in t or "not found" in t:
166
- stats["missing"] += 1
167
- name = (
168
- t.split("[/bold]")[0].split("]")[-1]
169
- if "[/bold]" in t
170
- else t.split()[0]
171
- )
172
- stats["missing_list"].append(name)
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)
173
206
  elif "packaged" in t:
174
- stats["packaged"] += 1
207
+ stats.packaged += 1
208
+ if is_optional:
209
+ stats.optional_packaged += 1
175
210
  return
176
211
 
177
212
  if hasattr(t, "label"):
178
213
  label = str(t.label)
179
- stats["total"] += 1
214
+ stats.total += 1
215
+ is_optional = "(optional)" in label
216
+ if is_optional:
217
+ stats.optional_total += 1
180
218
  if "not packaged" in label or "not found" in label:
181
- stats["missing"] += 1
182
- name = (
183
- label.split("[/bold]")[0].split("[bold]")[-1]
184
- if "[bold]" in label
185
- else "unknown"
186
- )
187
- stats["missing_list"].append(name)
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)
188
231
  elif "packaged" in label:
189
- stats["packaged"] += 1
232
+ stats.packaged += 1
190
233
  name = (
191
234
  label.split("[/bold]")[0].split("[bold]")[-1]
192
235
  if "[bold]" in label
193
236
  else "unknown"
194
237
  )
195
- stats["packaged_list"].append(name)
238
+ stats.packaged_list.append(name)
239
+ if is_optional:
240
+ stats.optional_packaged += 1
196
241
 
197
242
  if hasattr(t, "children"):
198
243
  for child in t.children:
@@ -232,6 +277,14 @@ def check(
232
277
  help="Maximum recursion depth.",
233
278
  ),
234
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,
235
288
  no_progress: Annotated[
236
289
  bool,
237
290
  cyclopts.Parameter(
@@ -266,6 +319,8 @@ def check(
266
319
  Specific version to check (default: latest).
267
320
  max_depth
268
321
  Maximum recursion depth for dependency tree.
322
+ optional
323
+ Include optional dependencies in the analysis.
269
324
  no_progress
270
325
  Disable progress bar during analysis.
271
326
  debug
@@ -294,6 +349,7 @@ def check(
294
349
  package=package,
295
350
  language=lang,
296
351
  max_depth=max_depth,
352
+ include_optional=optional,
297
353
  debug=debug,
298
354
  report_format=report,
299
355
  )
@@ -301,6 +357,8 @@ def check(
301
357
  console.print(
302
358
  f"\n[bold underline]Analyzing {provider.display_name} package:[/] {package}"
303
359
  )
360
+ if optional:
361
+ console.print("[yellow]Including optional dependencies[/yellow]")
304
362
  console.print(f"[dim]Registry: {provider.registry_name}[/dim]")
305
363
  console.print(f"[dim]Cache directory: {CACHE_DIR}[/dim]")
306
364
  console.print()
@@ -317,6 +375,7 @@ def check(
317
375
  version,
318
376
  max_depth=max_depth,
319
377
  tracker=tracker,
378
+ include_optional=optional,
320
379
  )
321
380
  if tracker:
322
381
  tracker.finish()
@@ -335,14 +394,19 @@ def check(
335
394
  root_package=package,
336
395
  language=provider.display_name,
337
396
  registry=provider.registry_name,
338
- total_dependencies=stats["total"],
339
- packaged_count=stats["packaged"],
340
- missing_count=stats["missing"],
341
- missing_packages=stats["missing_list"],
342
- packaged_packages=stats["packaged_list"],
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,
343
402
  tree=tree,
344
403
  max_depth=max_depth,
345
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,
346
410
  )
347
411
 
348
412
  # Generate report
@@ -17,8 +17,8 @@ def list_formats_cmd():
17
17
  table.add_column("Description")
18
18
  table.add_column("Aliases", style="dim")
19
19
 
20
- for format_id, description, aliases in list_reporters():
21
- alias_str = ", ".join(aliases) if aliases else "-"
22
- table.add_row(format_id, description, alias_str)
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
23
 
24
24
  console.print(table)
@@ -17,10 +17,10 @@ def list_languages_cmd():
17
17
  table.add_column("Registry")
18
18
  table.add_column("Aliases", style="dim")
19
19
 
20
- for lang_id, display_name, aliases in list_providers():
21
- provider = get_provider(lang_id)
20
+ for info in list_providers():
21
+ provider = get_provider(info.language_id)
22
22
  registry = provider.registry_name if provider else "Unknown"
23
- alias_str = ", ".join(aliases) if aliases else "-"
24
- table.add_row(f"{display_name} ({lang_id})", registry, alias_str)
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
25
 
26
26
  console.print(table)
woolly/http.py ADDED
@@ -0,0 +1,34 @@
1
+ """
2
+ Shared HTTP client configuration for API requests.
3
+
4
+ This module provides centralized HTTP configuration including
5
+ the User-Agent header and common request settings.
6
+ """
7
+
8
+ import httpx
9
+ from importlib.metadata import version
10
+
11
+ # Version identifier for the User-Agent
12
+ VERSION = version("woolly")
13
+ PROJECT_URL = "https://github.com/r0x0d/woolly"
14
+
15
+ # Shared headers for all API requests
16
+ DEFAULT_HEADERS = {
17
+ "User-Agent": f"woolly/{VERSION} ({PROJECT_URL})",
18
+ }
19
+
20
+
21
+ def get(url: str, **kwargs) -> httpx.Response:
22
+ """
23
+ Make a GET request with default headers.
24
+
25
+ Args:
26
+ url: The URL to request.
27
+ **kwargs: Additional arguments passed to httpx.get().
28
+
29
+ Returns:
30
+ httpx.Response object.
31
+ """
32
+ headers = kwargs.pop("headers", {})
33
+ merged_headers = {**DEFAULT_HEADERS, **headers}
34
+ return httpx.get(url, headers=merged_headers, **kwargs)
@@ -8,10 +8,21 @@ inheriting from LanguageProvider and add it to PROVIDERS dict.
8
8
 
9
9
  from typing import Optional
10
10
 
11
+ from pydantic import BaseModel, Field
12
+
11
13
  from woolly.languages.base import LanguageProvider
12
14
  from woolly.languages.python import PythonProvider
13
15
  from woolly.languages.rust import RustProvider
14
16
 
17
+
18
+ class ProviderInfo(BaseModel):
19
+ """Information about an available language provider."""
20
+
21
+ language_id: str
22
+ display_name: str
23
+ aliases: list[str] = Field(default_factory=list)
24
+
25
+
15
26
  # Registry of available language providers
16
27
  # Key: language identifier (used in CLI)
17
28
  # Value: Provider class
@@ -52,21 +63,40 @@ def get_provider(language: str) -> Optional[LanguageProvider]:
52
63
  return provider_class()
53
64
 
54
65
 
55
- def list_providers() -> list[tuple[str, str, list[str]]]:
66
+ def list_providers() -> list[ProviderInfo]:
56
67
  """
57
68
  List all available providers.
58
69
 
59
70
  Returns:
60
- List of tuples: (language_id, display_name, aliases)
71
+ List of ProviderInfo objects with language details.
61
72
  """
62
73
  result = []
63
74
  for lang_id, provider_class in PROVIDERS.items():
64
75
  # Find aliases for this language
65
76
  aliases = [alias for alias, target in ALIASES.items() if target == lang_id]
66
- result.append((lang_id, provider_class.display_name, aliases))
77
+ result.append(
78
+ ProviderInfo(
79
+ language_id=lang_id,
80
+ display_name=provider_class.display_name,
81
+ aliases=aliases,
82
+ )
83
+ )
67
84
  return result
68
85
 
69
86
 
70
87
  def get_available_languages() -> list[str]:
71
88
  """Get list of available language identifiers."""
72
89
  return list(PROVIDERS.keys())
90
+
91
+
92
+ __all__ = [
93
+ "LanguageProvider",
94
+ "ProviderInfo",
95
+ "PythonProvider",
96
+ "RustProvider",
97
+ "get_provider",
98
+ "list_providers",
99
+ "get_available_languages",
100
+ "PROVIDERS",
101
+ "ALIASES",
102
+ ]
woolly/languages/base.py CHANGED
@@ -133,20 +133,24 @@ class LanguageProvider(ABC):
133
133
  return info.latest_version
134
134
 
135
135
  def get_normal_dependencies(
136
- self, package_name: str, version: Optional[str] = None
137
- ) -> list[tuple[str, str]]:
136
+ self,
137
+ package_name: str,
138
+ version: Optional[str] = None,
139
+ include_optional: bool = False,
140
+ ) -> list[tuple[str, str, bool]]:
138
141
  """
139
- Get non-optional, runtime dependencies for a package.
142
+ Get runtime dependencies for a package.
140
143
 
141
144
  This method filters dependencies to only include normal (runtime)
142
- dependencies that are not optional.
145
+ dependencies. By default, optional dependencies are excluded.
143
146
 
144
147
  Args:
145
148
  package_name: The name of the package.
146
149
  version: Specific version, or None for latest.
150
+ include_optional: If True, include optional dependencies.
147
151
 
148
152
  Returns:
149
- List of tuples: (dependency_name, version_requirement)
153
+ List of tuples: (dependency_name, version_requirement, is_optional)
150
154
  """
151
155
  if version is None:
152
156
  version = self.get_latest_version(package_name)
@@ -155,9 +159,9 @@ class LanguageProvider(ABC):
155
159
 
156
160
  deps = self.fetch_dependencies(package_name, version)
157
161
  return [
158
- (d.name, d.version_requirement)
162
+ (d.name, d.version_requirement, d.optional)
159
163
  for d in deps
160
- if d.kind == "normal" and not d.optional
164
+ if d.kind == "normal" and (include_optional or not d.optional)
161
165
  ]
162
166
 
163
167
  def get_fedora_provides_pattern(self, package_name: str) -> str:
@@ -8,8 +8,7 @@ Fedora repositories for Python packages.
8
8
  import re
9
9
  from typing import Optional
10
10
 
11
- import httpx
12
-
11
+ from woolly import http
13
12
  from woolly.cache import DEFAULT_CACHE_TTL, read_cache, write_cache
14
13
  from woolly.debug import (
15
14
  log_api_request,
@@ -20,7 +19,6 @@ from woolly.debug import (
20
19
  from woolly.languages.base import Dependency, LanguageProvider, PackageInfo
21
20
 
22
21
  PYPI_API = "https://pypi.org/pypi"
23
- HEADERS = {"User-Agent": "woolly/0.1.0 (https://github.com/r0x0d/woolly)"}
24
22
 
25
23
 
26
24
  class PythonProvider(LanguageProvider):
@@ -51,7 +49,7 @@ class PythonProvider(LanguageProvider):
51
49
  log_cache_miss(self.cache_namespace, cache_key)
52
50
  url = f"{PYPI_API}/{package_name}/json"
53
51
  log_api_request("GET", url)
54
- r = httpx.get(url, headers=HEADERS)
52
+ r = http.get(url)
55
53
  log_api_response(r.status_code, r.text[:500] if r.text else None)
56
54
 
57
55
  if r.status_code == 404:
@@ -96,7 +94,7 @@ class PythonProvider(LanguageProvider):
96
94
  log_cache_miss(self.cache_namespace, cache_key)
97
95
  url = f"{PYPI_API}/{package_name}/{version}/json"
98
96
  log_api_request("GET", url)
99
- r = httpx.get(url, headers=HEADERS)
97
+ r = http.get(url)
100
98
  log_api_response(r.status_code, r.text[:500] if r.text else None)
101
99
 
102
100
  if r.status_code != 200:
@@ -141,7 +139,8 @@ class PythonProvider(LanguageProvider):
141
139
 
142
140
  if "extra ==" in req_string or "extra==" in req_string:
143
141
  is_optional = True
144
- kind = "dev" # Treat extras as dev dependencies
142
+ # Keep kind as "normal" so optional dependencies can be included
143
+ # when --optional flag is used
145
144
 
146
145
  # Extract the package name and version requirement
147
146
  # Handle environment markers (everything after ';')
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,24 @@ 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 typing import Optional
10
+
11
+ from pydantic import BaseModel, Field
9
12
  from rich.console import Console
10
13
 
11
- from woolly.reporters.base import Reporter, ReportData, PackageStatus
12
- from woolly.reporters.stdout import StdoutReporter
13
- from woolly.reporters.markdown import MarkdownReporter
14
+ from woolly.reporters.base import ReportData, Reporter, strip_markup
14
15
  from woolly.reporters.json import JsonReporter
16
+ from woolly.reporters.markdown import MarkdownReporter
17
+ from woolly.reporters.stdout import StdoutReporter
18
+
19
+
20
+ class ReporterInfo(BaseModel):
21
+ """Information about an available reporter."""
22
+
23
+ format_id: str
24
+ description: str
25
+ aliases: list[str] = Field(default_factory=list)
26
+
15
27
 
16
28
  # Registry of available reporters
17
29
  # Key: format identifier (used in CLI)
@@ -30,7 +42,9 @@ ALIASES: dict[str, str] = {
30
42
  }
31
43
 
32
44
 
33
- def get_reporter(format_name: str, console: Console | None = None) -> Reporter | None:
45
+ def get_reporter(
46
+ format_name: str, console: Optional[Console] = None
47
+ ) -> Optional[Reporter]:
34
48
  """
35
49
  Get an instantiated reporter for the specified format.
36
50
 
@@ -57,18 +71,24 @@ def get_reporter(format_name: str, console: Console | None = None) -> Reporter |
57
71
  return reporter_class()
58
72
 
59
73
 
60
- def list_reporters() -> list[tuple[str, str, list[str]]]:
74
+ def list_reporters() -> list[ReporterInfo]:
61
75
  """
62
76
  List all available reporters.
63
77
 
64
78
  Returns:
65
- List of tuples: (format_id, description, aliases)
79
+ List of ReporterInfo objects with format details.
66
80
  """
67
81
  result = []
68
82
  for format_id, reporter_class in REPORTERS.items():
69
83
  # Find aliases for this format
70
84
  aliases = [alias for alias, target in ALIASES.items() if target == format_id]
71
- result.append((format_id, reporter_class.description, aliases))
85
+ result.append(
86
+ ReporterInfo(
87
+ format_id=format_id,
88
+ description=reporter_class.description,
89
+ aliases=aliases,
90
+ )
91
+ )
72
92
  return result
73
93
 
74
94
 
@@ -80,11 +100,12 @@ def get_available_formats() -> list[str]:
80
100
  __all__ = [
81
101
  "Reporter",
82
102
  "ReportData",
83
- "PackageStatus",
103
+ "ReporterInfo",
84
104
  "StdoutReporter",
85
105
  "MarkdownReporter",
86
106
  "JsonReporter",
87
107
  "get_reporter",
88
108
  "list_reporters",
89
109
  "get_available_formats",
110
+ "strip_markup",
90
111
  ]