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/cache.py +15 -5
- woolly/commands/check.py +176 -44
- woolly/commands/list_formats.py +3 -3
- woolly/commands/list_languages.py +4 -4
- woolly/http.py +34 -0
- woolly/languages/__init__.py +33 -3
- woolly/languages/base.py +11 -7
- woolly/languages/python.py +5 -6
- woolly/languages/rust.py +3 -5
- woolly/reporters/__init__.py +43 -8
- woolly/reporters/base.py +95 -10
- woolly/reporters/json.py +124 -89
- woolly/reporters/markdown.py +40 -60
- woolly/reporters/stdout.py +32 -10
- woolly/reporters/template.py +215 -0
- woolly-0.4.0.dist-info/METADATA +324 -0
- woolly-0.4.0.dist-info/RECORD +26 -0
- woolly-0.2.0.dist-info/METADATA +0 -213
- woolly-0.2.0.dist-info/RECORD +0 -24
- {woolly-0.2.0.dist-info → woolly-0.4.0.dist-info}/WHEEL +0 -0
- {woolly-0.2.0.dist-info → woolly-0.4.0.dist-info}/entry_points.txt +0 -0
- {woolly-0.2.0.dist-info → woolly-0.4.0.dist-info}/licenses/LICENSE +0 -0
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
|
|
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 =
|
|
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 =
|
|
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:
|
woolly/reporters/__init__.py
CHANGED
|
@@ -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
|
|
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(
|
|
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[
|
|
87
|
+
def list_reporters() -> list[ReporterInfo]:
|
|
61
88
|
"""
|
|
62
89
|
List all available reporters.
|
|
63
90
|
|
|
64
91
|
Returns:
|
|
65
|
-
List of
|
|
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(
|
|
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
|
-
"
|
|
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
|
-
|
|
28
|
-
"""
|
|
29
|
+
def strip_markup(text: str) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Strip Rich markup from text.
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
9
|
-
from typing import Any
|
|
8
|
+
from typing import Optional
|
|
10
9
|
|
|
11
|
-
from
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
+
node_data.dependencies = [self._tree_to_model(child) for child in children]
|
|
123
|
+
|
|
124
|
+
return node_data
|
|
92
125
|
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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(
|
|
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
|
-
|
|
143
|
+
node.name = match.group(1)
|
|
109
144
|
if match.group(2):
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
+
node.status = "not_packaged"
|
|
132
167
|
elif "not found" in status_text.lower():
|
|
133
|
-
|
|
168
|
+
node.status = "not_found"
|
|
134
169
|
elif "already visited" in status_text.lower():
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
141
|
-
|
|
175
|
+
node.status = "visited"
|
|
176
|
+
node.is_packaged = "✓" in clean_label
|
|
142
177
|
elif "max depth" in clean_label:
|
|
143
|
-
|
|
178
|
+
node.status = "max_depth_reached"
|
|
144
179
|
|
|
145
|
-
return
|
|
180
|
+
return node
|