woolly 0.1.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/__main__.py +12 -473
- woolly/cache.py +88 -0
- woolly/commands/__init__.py +27 -0
- woolly/commands/check.py +422 -0
- woolly/commands/clear_cache.py +42 -0
- woolly/commands/list_formats.py +24 -0
- woolly/commands/list_languages.py +26 -0
- woolly/debug.py +195 -0
- woolly/http.py +34 -0
- woolly/languages/__init__.py +102 -0
- woolly/languages/base.py +374 -0
- woolly/languages/python.py +200 -0
- woolly/languages/rust.py +130 -0
- woolly/progress.py +69 -0
- woolly/reporters/__init__.py +111 -0
- woolly/reporters/base.py +213 -0
- woolly/reporters/json.py +175 -0
- woolly/reporters/markdown.py +117 -0
- woolly/reporters/stdout.py +75 -0
- woolly-0.3.0.dist-info/METADATA +322 -0
- woolly-0.3.0.dist-info/RECORD +25 -0
- {woolly-0.1.0.dist-info → woolly-0.3.0.dist-info}/WHEEL +1 -1
- woolly-0.1.0.dist-info/METADATA +0 -101
- woolly-0.1.0.dist-info/RECORD +0 -7
- {woolly-0.1.0.dist-info → woolly-0.3.0.dist-info}/entry_points.txt +0 -0
- {woolly-0.1.0.dist-info → woolly-0.3.0.dist-info}/licenses/LICENSE +0 -0
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)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Language providers registry.
|
|
3
|
+
|
|
4
|
+
This module provides automatic discovery and registration of language providers.
|
|
5
|
+
To add a new language, create a module in this directory that defines a class
|
|
6
|
+
inheriting from LanguageProvider and add it to PROVIDERS dict.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from woolly.languages.base import LanguageProvider
|
|
14
|
+
from woolly.languages.python import PythonProvider
|
|
15
|
+
from woolly.languages.rust import RustProvider
|
|
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
|
+
|
|
26
|
+
# Registry of available language providers
|
|
27
|
+
# Key: language identifier (used in CLI)
|
|
28
|
+
# Value: Provider class
|
|
29
|
+
PROVIDERS: dict[str, type[LanguageProvider]] = {
|
|
30
|
+
"rust": RustProvider,
|
|
31
|
+
"python": PythonProvider,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Aliases for convenience
|
|
35
|
+
ALIASES: dict[str, str] = {
|
|
36
|
+
"rs": "rust",
|
|
37
|
+
"crate": "rust",
|
|
38
|
+
"crates": "rust",
|
|
39
|
+
"py": "python",
|
|
40
|
+
"pypi": "python",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_provider(language: str) -> Optional[LanguageProvider]:
|
|
45
|
+
"""
|
|
46
|
+
Get an instantiated provider for the specified language.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
language: Language identifier or alias (e.g., "rust", "python", "rs", "py")
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Instantiated LanguageProvider, or None if not found.
|
|
53
|
+
"""
|
|
54
|
+
# Resolve aliases
|
|
55
|
+
language = language.lower()
|
|
56
|
+
if language in ALIASES:
|
|
57
|
+
language = ALIASES[language]
|
|
58
|
+
|
|
59
|
+
provider_class = PROVIDERS.get(language)
|
|
60
|
+
if provider_class is None:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
return provider_class()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def list_providers() -> list[ProviderInfo]:
|
|
67
|
+
"""
|
|
68
|
+
List all available providers.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
List of ProviderInfo objects with language details.
|
|
72
|
+
"""
|
|
73
|
+
result = []
|
|
74
|
+
for lang_id, provider_class in PROVIDERS.items():
|
|
75
|
+
# Find aliases for this language
|
|
76
|
+
aliases = [alias for alias, target in ALIASES.items() if target == lang_id]
|
|
77
|
+
result.append(
|
|
78
|
+
ProviderInfo(
|
|
79
|
+
language_id=lang_id,
|
|
80
|
+
display_name=provider_class.display_name,
|
|
81
|
+
aliases=aliases,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_available_languages() -> list[str]:
|
|
88
|
+
"""Get list of available language identifiers."""
|
|
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
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base abstract class defining the contract for language package providers.
|
|
3
|
+
|
|
4
|
+
To add support for a new language, create a new module in the `languages/` directory
|
|
5
|
+
that implements a class inheriting from `LanguageProvider`. The class must implement
|
|
6
|
+
all abstract methods defined here.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
class GoProvider(LanguageProvider):
|
|
10
|
+
name = "go"
|
|
11
|
+
display_name = "Go"
|
|
12
|
+
registry_name = "Go Modules"
|
|
13
|
+
fedora_provides_prefix = "golang"
|
|
14
|
+
cache_namespace = "go"
|
|
15
|
+
|
|
16
|
+
def fetch_package_info(self, package_name: str) -> Optional[PackageInfo]:
|
|
17
|
+
# Fetch from proxy.golang.org or pkg.go.dev
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
def fetch_dependencies(self, package_name: str, version: str) -> list[Dependency]:
|
|
21
|
+
# Fetch dependencies from go.mod
|
|
22
|
+
...
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
from abc import ABC, abstractmethod
|
|
28
|
+
from typing import Literal, Optional
|
|
29
|
+
|
|
30
|
+
from pydantic import BaseModel, Field
|
|
31
|
+
|
|
32
|
+
from woolly.cache import FEDORA_CACHE_TTL, read_cache, write_cache
|
|
33
|
+
from woolly.debug import log_cache_hit, log_cache_miss, log_command_output
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PackageInfo(BaseModel):
|
|
37
|
+
"""Information about a package from an upstream registry."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
latest_version: str
|
|
41
|
+
description: Optional[str] = None
|
|
42
|
+
homepage: Optional[str] = None
|
|
43
|
+
repository: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Dependency(BaseModel):
|
|
47
|
+
"""A package dependency."""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
version_requirement: str
|
|
51
|
+
optional: bool = False
|
|
52
|
+
kind: Literal["normal", "dev", "build"] = "normal"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FedoraPackageStatus(BaseModel):
|
|
56
|
+
"""Status of a package in Fedora repositories."""
|
|
57
|
+
|
|
58
|
+
is_packaged: bool
|
|
59
|
+
versions: list[str] = Field(default_factory=list)
|
|
60
|
+
package_names: list[str] = Field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LanguageProvider(ABC):
|
|
64
|
+
"""
|
|
65
|
+
Abstract base class for language package providers.
|
|
66
|
+
|
|
67
|
+
Each language (Rust, Python, Go, etc.) must implement this interface
|
|
68
|
+
to enable checking its packages against Fedora repositories.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
name: Short identifier for the language (e.g., "rust", "python")
|
|
72
|
+
display_name: Human-readable name (e.g., "Rust", "Python")
|
|
73
|
+
registry_name: Name of the package registry (e.g., "crates.io", "PyPI")
|
|
74
|
+
fedora_provides_prefix: Prefix used in Fedora provides (e.g., "crate", "python3dist")
|
|
75
|
+
cache_namespace: Namespace for caching upstream registry data
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
# Class attributes that must be defined by subclasses
|
|
79
|
+
name: str
|
|
80
|
+
display_name: str
|
|
81
|
+
registry_name: str
|
|
82
|
+
fedora_provides_prefix: str
|
|
83
|
+
cache_namespace: str
|
|
84
|
+
|
|
85
|
+
# ----------------------------------------------------------------
|
|
86
|
+
# Abstract methods - MUST be implemented by subclasses
|
|
87
|
+
# ----------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
def fetch_package_info(self, package_name: str) -> Optional[PackageInfo]:
|
|
91
|
+
"""
|
|
92
|
+
Fetch package information from the upstream registry.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
package_name: The name of the package to look up.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
PackageInfo if the package exists, None otherwise.
|
|
99
|
+
"""
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
@abstractmethod
|
|
103
|
+
def fetch_dependencies(self, package_name: str, version: str) -> list[Dependency]:
|
|
104
|
+
"""
|
|
105
|
+
Fetch dependencies for a specific package version.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
package_name: The name of the package.
|
|
109
|
+
version: The specific version to get dependencies for.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
List of Dependency objects.
|
|
113
|
+
"""
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
# ----------------------------------------------------------------
|
|
117
|
+
# Concrete methods - shared implementation for all providers
|
|
118
|
+
# ----------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def get_latest_version(self, package_name: str) -> Optional[str]:
|
|
121
|
+
"""
|
|
122
|
+
Get the latest version of a package.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
package_name: The name of the package.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Version string if package exists, None otherwise.
|
|
129
|
+
"""
|
|
130
|
+
info = self.fetch_package_info(package_name)
|
|
131
|
+
if info is None:
|
|
132
|
+
return None
|
|
133
|
+
return info.latest_version
|
|
134
|
+
|
|
135
|
+
def get_normal_dependencies(
|
|
136
|
+
self,
|
|
137
|
+
package_name: str,
|
|
138
|
+
version: Optional[str] = None,
|
|
139
|
+
include_optional: bool = False,
|
|
140
|
+
) -> list[tuple[str, str, bool]]:
|
|
141
|
+
"""
|
|
142
|
+
Get runtime dependencies for a package.
|
|
143
|
+
|
|
144
|
+
This method filters dependencies to only include normal (runtime)
|
|
145
|
+
dependencies. By default, optional dependencies are excluded.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
package_name: The name of the package.
|
|
149
|
+
version: Specific version, or None for latest.
|
|
150
|
+
include_optional: If True, include optional dependencies.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
List of tuples: (dependency_name, version_requirement, is_optional)
|
|
154
|
+
"""
|
|
155
|
+
if version is None:
|
|
156
|
+
version = self.get_latest_version(package_name)
|
|
157
|
+
if version is None:
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
deps = self.fetch_dependencies(package_name, version)
|
|
161
|
+
return [
|
|
162
|
+
(d.name, d.version_requirement, d.optional)
|
|
163
|
+
for d in deps
|
|
164
|
+
if d.kind == "normal" and (include_optional or not d.optional)
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
def get_fedora_provides_pattern(self, package_name: str) -> str:
|
|
168
|
+
"""
|
|
169
|
+
Get the Fedora provides pattern for this package.
|
|
170
|
+
|
|
171
|
+
Uses the `fedora_provides_prefix` attribute to construct the pattern.
|
|
172
|
+
Override if your language needs special handling.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
package_name: The name of the package.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Provides pattern string (e.g., "crate(serde)" or "python3dist(requests)")
|
|
179
|
+
"""
|
|
180
|
+
normalized = self.normalize_package_name(package_name)
|
|
181
|
+
return f"{self.fedora_provides_prefix}({normalized})"
|
|
182
|
+
|
|
183
|
+
def normalize_package_name(self, package_name: str) -> str:
|
|
184
|
+
"""
|
|
185
|
+
Normalize a package name to its canonical form.
|
|
186
|
+
|
|
187
|
+
Override this method if the language has specific naming conventions
|
|
188
|
+
that differ between the upstream registry and Fedora.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
package_name: The package name to normalize.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Normalized package name.
|
|
195
|
+
"""
|
|
196
|
+
return package_name
|
|
197
|
+
|
|
198
|
+
def get_alternative_names(self, package_name: str) -> list[str]:
|
|
199
|
+
"""
|
|
200
|
+
Get alternative names to try when looking up a package in Fedora.
|
|
201
|
+
|
|
202
|
+
Some packages may be named differently in Fedora than in the upstream
|
|
203
|
+
registry. Override this method to provide alternatives to try.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
package_name: The original package name.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
List of alternative names to try.
|
|
210
|
+
"""
|
|
211
|
+
return []
|
|
212
|
+
|
|
213
|
+
# ----------------------------------------------------------------
|
|
214
|
+
# Fedora repository query methods - shared implementation
|
|
215
|
+
# ----------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def _repoquery_package(
|
|
218
|
+
self, package_name: str
|
|
219
|
+
) -> tuple[bool, list[str], list[str]]:
|
|
220
|
+
"""
|
|
221
|
+
Query Fedora for a package using the virtual provides pattern.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
package_name: The name of the package to query.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Tuple of (is_packaged, versions_list, package_names)
|
|
228
|
+
"""
|
|
229
|
+
cache_key = f"repoquery:{self.name}:{package_name}"
|
|
230
|
+
cached = read_cache("fedora", cache_key, FEDORA_CACHE_TTL)
|
|
231
|
+
if cached is not None:
|
|
232
|
+
log_cache_hit("fedora", cache_key)
|
|
233
|
+
return tuple(cached)
|
|
234
|
+
|
|
235
|
+
log_cache_miss("fedora", cache_key)
|
|
236
|
+
provide_pattern = self.get_fedora_provides_pattern(package_name)
|
|
237
|
+
cmd = [
|
|
238
|
+
"dnf",
|
|
239
|
+
"repoquery",
|
|
240
|
+
"--whatprovides",
|
|
241
|
+
provide_pattern,
|
|
242
|
+
"--queryformat",
|
|
243
|
+
"%{NAME}|%{VERSION}",
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
out = (
|
|
248
|
+
subprocess.check_output(
|
|
249
|
+
cmd,
|
|
250
|
+
stdin=subprocess.DEVNULL,
|
|
251
|
+
stderr=subprocess.DEVNULL,
|
|
252
|
+
)
|
|
253
|
+
.decode()
|
|
254
|
+
.strip()
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
log_command_output(" ".join(cmd), out, exit_code=0)
|
|
258
|
+
|
|
259
|
+
if not out:
|
|
260
|
+
result = (False, [], [])
|
|
261
|
+
write_cache("fedora", cache_key, list(result))
|
|
262
|
+
return result
|
|
263
|
+
|
|
264
|
+
versions = set()
|
|
265
|
+
packages = set()
|
|
266
|
+
for line in out.split("\n"):
|
|
267
|
+
if "|" in line:
|
|
268
|
+
pkg, ver = line.split("|", 1)
|
|
269
|
+
packages.add(pkg)
|
|
270
|
+
versions.add(ver)
|
|
271
|
+
|
|
272
|
+
result = (True, sorted(versions), sorted(packages))
|
|
273
|
+
write_cache("fedora", cache_key, [result[0], result[1], result[2]])
|
|
274
|
+
return result
|
|
275
|
+
except subprocess.CalledProcessError as e:
|
|
276
|
+
log_command_output(" ".join(cmd), "", exit_code=e.returncode)
|
|
277
|
+
result = (False, [], [])
|
|
278
|
+
write_cache("fedora", cache_key, list(result))
|
|
279
|
+
return result
|
|
280
|
+
|
|
281
|
+
def _get_provides_version(self, package_name: str) -> list[str]:
|
|
282
|
+
"""
|
|
283
|
+
Get the actual package version provided by Fedora packages.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
package_name: The name of the package.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
List of version strings provided by Fedora packages.
|
|
290
|
+
"""
|
|
291
|
+
cache_key = f"provides:{self.name}:{package_name}"
|
|
292
|
+
cached = read_cache("fedora", cache_key, FEDORA_CACHE_TTL)
|
|
293
|
+
if cached is not None:
|
|
294
|
+
log_cache_hit("fedora", cache_key)
|
|
295
|
+
return cached
|
|
296
|
+
|
|
297
|
+
log_cache_miss("fedora", cache_key)
|
|
298
|
+
provide_pattern = self.get_fedora_provides_pattern(package_name)
|
|
299
|
+
normalized = self.normalize_package_name(package_name)
|
|
300
|
+
cmd = ["dnf", "repoquery", "--provides", "--whatprovides", provide_pattern]
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
out = (
|
|
304
|
+
subprocess.check_output(
|
|
305
|
+
cmd,
|
|
306
|
+
stdin=subprocess.DEVNULL,
|
|
307
|
+
stderr=subprocess.DEVNULL,
|
|
308
|
+
)
|
|
309
|
+
.decode()
|
|
310
|
+
.strip()
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
log_command_output(" ".join(cmd), out, exit_code=0)
|
|
314
|
+
|
|
315
|
+
if not out:
|
|
316
|
+
write_cache("fedora", cache_key, [])
|
|
317
|
+
return []
|
|
318
|
+
|
|
319
|
+
versions = set()
|
|
320
|
+
# Build pattern: prefix(normalized_name) = version
|
|
321
|
+
pattern = re.compile(
|
|
322
|
+
rf"{re.escape(self.fedora_provides_prefix)}\({re.escape(normalized)}\)\s*=\s*([\d.]+)"
|
|
323
|
+
)
|
|
324
|
+
for line in out.split("\n"):
|
|
325
|
+
match = pattern.search(line)
|
|
326
|
+
if match:
|
|
327
|
+
versions.add(match.group(1))
|
|
328
|
+
|
|
329
|
+
result = sorted(versions)
|
|
330
|
+
write_cache("fedora", cache_key, result)
|
|
331
|
+
return result
|
|
332
|
+
except subprocess.CalledProcessError as e:
|
|
333
|
+
log_command_output(" ".join(cmd), "", exit_code=e.returncode)
|
|
334
|
+
write_cache("fedora", cache_key, [])
|
|
335
|
+
return []
|
|
336
|
+
|
|
337
|
+
def check_fedora_packaging(self, package_name: str) -> FedoraPackageStatus:
|
|
338
|
+
"""
|
|
339
|
+
Check if a package is available in Fedora repositories.
|
|
340
|
+
|
|
341
|
+
This method queries Fedora repositories to determine if the
|
|
342
|
+
package is packaged and what versions are available.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
package_name: The name of the package.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
FedoraPackageStatus with packaging information.
|
|
349
|
+
"""
|
|
350
|
+
normalized = self.normalize_package_name(package_name)
|
|
351
|
+
is_packaged, pkg_versions, packages = self._repoquery_package(normalized)
|
|
352
|
+
|
|
353
|
+
# Try alternative names if not found
|
|
354
|
+
if not is_packaged:
|
|
355
|
+
for alt_name in self.get_alternative_names(package_name):
|
|
356
|
+
is_packaged, pkg_versions, packages = self._repoquery_package(alt_name)
|
|
357
|
+
if is_packaged:
|
|
358
|
+
break
|
|
359
|
+
|
|
360
|
+
if is_packaged:
|
|
361
|
+
provided_versions = self._get_provides_version(normalized)
|
|
362
|
+
if not provided_versions:
|
|
363
|
+
provided_versions = pkg_versions
|
|
364
|
+
return FedoraPackageStatus(
|
|
365
|
+
is_packaged=True,
|
|
366
|
+
versions=provided_versions,
|
|
367
|
+
package_names=packages,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
return FedoraPackageStatus(
|
|
371
|
+
is_packaged=False,
|
|
372
|
+
versions=[],
|
|
373
|
+
package_names=[],
|
|
374
|
+
)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Python/PyPI language provider.
|
|
3
|
+
|
|
4
|
+
This provider fetches package information from PyPI and checks
|
|
5
|
+
Fedora repositories for Python packages.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from woolly import http
|
|
12
|
+
from woolly.cache import DEFAULT_CACHE_TTL, read_cache, write_cache
|
|
13
|
+
from woolly.debug import (
|
|
14
|
+
log_api_request,
|
|
15
|
+
log_api_response,
|
|
16
|
+
log_cache_hit,
|
|
17
|
+
log_cache_miss,
|
|
18
|
+
)
|
|
19
|
+
from woolly.languages.base import Dependency, LanguageProvider, PackageInfo
|
|
20
|
+
|
|
21
|
+
PYPI_API = "https://pypi.org/pypi"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PythonProvider(LanguageProvider):
|
|
25
|
+
"""Provider for Python packages via PyPI."""
|
|
26
|
+
|
|
27
|
+
name = "python"
|
|
28
|
+
display_name = "Python"
|
|
29
|
+
registry_name = "PyPI"
|
|
30
|
+
fedora_provides_prefix = "python3dist"
|
|
31
|
+
cache_namespace = "pypi"
|
|
32
|
+
|
|
33
|
+
def fetch_package_info(self, package_name: str) -> Optional[PackageInfo]:
|
|
34
|
+
"""Fetch package information from PyPI."""
|
|
35
|
+
cache_key = f"info:{package_name}"
|
|
36
|
+
cached = read_cache(self.cache_namespace, cache_key, DEFAULT_CACHE_TTL)
|
|
37
|
+
if cached is not None:
|
|
38
|
+
log_cache_hit(self.cache_namespace, cache_key)
|
|
39
|
+
if cached is False: # Explicit "not found" cache
|
|
40
|
+
return None
|
|
41
|
+
return PackageInfo(
|
|
42
|
+
name=cached["info"]["name"],
|
|
43
|
+
latest_version=cached["info"]["version"],
|
|
44
|
+
description=cached["info"].get("summary"),
|
|
45
|
+
homepage=cached["info"].get("home_page"),
|
|
46
|
+
repository=cached["info"].get("project_url"),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
log_cache_miss(self.cache_namespace, cache_key)
|
|
50
|
+
url = f"{PYPI_API}/{package_name}/json"
|
|
51
|
+
log_api_request("GET", url)
|
|
52
|
+
r = http.get(url)
|
|
53
|
+
log_api_response(r.status_code, r.text[:500] if r.text else None)
|
|
54
|
+
|
|
55
|
+
if r.status_code == 404:
|
|
56
|
+
write_cache(self.cache_namespace, cache_key, False)
|
|
57
|
+
return None
|
|
58
|
+
if r.status_code != 200:
|
|
59
|
+
raise RuntimeError(
|
|
60
|
+
f"Failed to fetch metadata for package {package_name}: {r.status_code}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
data = r.json()
|
|
64
|
+
write_cache(self.cache_namespace, cache_key, data)
|
|
65
|
+
|
|
66
|
+
return PackageInfo(
|
|
67
|
+
name=data["info"]["name"],
|
|
68
|
+
latest_version=data["info"]["version"],
|
|
69
|
+
description=data["info"].get("summary"),
|
|
70
|
+
homepage=data["info"].get("home_page"),
|
|
71
|
+
repository=data["info"].get("project_url"),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def fetch_dependencies(self, package_name: str, version: str) -> list[Dependency]:
|
|
75
|
+
"""
|
|
76
|
+
Fetch dependencies for a specific package version.
|
|
77
|
+
|
|
78
|
+
PyPI provides dependencies in the `requires_dist` field.
|
|
79
|
+
"""
|
|
80
|
+
cache_key = f"deps:{package_name}:{version}"
|
|
81
|
+
cached = read_cache(self.cache_namespace, cache_key, DEFAULT_CACHE_TTL)
|
|
82
|
+
if cached is not None:
|
|
83
|
+
log_cache_hit(self.cache_namespace, cache_key)
|
|
84
|
+
return [
|
|
85
|
+
Dependency(
|
|
86
|
+
name=d["name"],
|
|
87
|
+
version_requirement=d["version_requirement"],
|
|
88
|
+
optional=d.get("optional", False),
|
|
89
|
+
kind=d.get("kind", "normal"),
|
|
90
|
+
)
|
|
91
|
+
for d in cached
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
log_cache_miss(self.cache_namespace, cache_key)
|
|
95
|
+
url = f"{PYPI_API}/{package_name}/{version}/json"
|
|
96
|
+
log_api_request("GET", url)
|
|
97
|
+
r = http.get(url)
|
|
98
|
+
log_api_response(r.status_code, r.text[:500] if r.text else None)
|
|
99
|
+
|
|
100
|
+
if r.status_code != 200:
|
|
101
|
+
write_cache(self.cache_namespace, cache_key, [])
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
data = r.json()
|
|
105
|
+
requires_dist = data["info"].get("requires_dist") or []
|
|
106
|
+
|
|
107
|
+
deps = []
|
|
108
|
+
for req in requires_dist:
|
|
109
|
+
parsed = self._parse_requirement(req)
|
|
110
|
+
if parsed:
|
|
111
|
+
deps.append(parsed)
|
|
112
|
+
|
|
113
|
+
# Cache as dicts
|
|
114
|
+
cache_data = [
|
|
115
|
+
{
|
|
116
|
+
"name": d.name,
|
|
117
|
+
"version_requirement": d.version_requirement,
|
|
118
|
+
"optional": d.optional,
|
|
119
|
+
"kind": d.kind,
|
|
120
|
+
}
|
|
121
|
+
for d in deps
|
|
122
|
+
]
|
|
123
|
+
write_cache(self.cache_namespace, cache_key, cache_data)
|
|
124
|
+
|
|
125
|
+
return deps
|
|
126
|
+
|
|
127
|
+
def _parse_requirement(self, req_string: str) -> Optional[Dependency]:
|
|
128
|
+
"""
|
|
129
|
+
Parse a PEP 508 requirement string.
|
|
130
|
+
|
|
131
|
+
Examples:
|
|
132
|
+
"requests>=2.20.0"
|
|
133
|
+
"typing-extensions; python_version < '3.8'"
|
|
134
|
+
"pytest; extra == 'testing'"
|
|
135
|
+
"""
|
|
136
|
+
# Check if this is an optional/extra dependency
|
|
137
|
+
is_optional = False
|
|
138
|
+
kind = "normal"
|
|
139
|
+
|
|
140
|
+
if "extra ==" in req_string or "extra==" in req_string:
|
|
141
|
+
is_optional = True
|
|
142
|
+
# Keep kind as "normal" so optional dependencies can be included
|
|
143
|
+
# when --optional flag is used
|
|
144
|
+
|
|
145
|
+
# Extract the package name and version requirement
|
|
146
|
+
# Handle environment markers (everything after ';')
|
|
147
|
+
if ";" in req_string:
|
|
148
|
+
req_string = req_string.split(";")[0].strip()
|
|
149
|
+
|
|
150
|
+
# Match package name and optional version specifier
|
|
151
|
+
# Package names can contain letters, numbers, hyphens, underscores, and dots
|
|
152
|
+
match = re.match(r"^([A-Za-z0-9][-A-Za-z0-9._]*)\s*(.*)$", req_string.strip())
|
|
153
|
+
|
|
154
|
+
if not match:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
name = match.group(1)
|
|
158
|
+
version_req = match.group(2).strip()
|
|
159
|
+
|
|
160
|
+
# Handle extras in package name (e.g., "package[extra1,extra2]")
|
|
161
|
+
if "[" in name:
|
|
162
|
+
name = name.split("[")[0]
|
|
163
|
+
|
|
164
|
+
return Dependency(
|
|
165
|
+
name=self.normalize_package_name(name),
|
|
166
|
+
version_requirement=version_req or "*",
|
|
167
|
+
optional=is_optional,
|
|
168
|
+
kind=kind,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def normalize_package_name(self, package_name: str) -> str:
|
|
172
|
+
"""
|
|
173
|
+
Normalize a Python package name according to PEP 503.
|
|
174
|
+
|
|
175
|
+
Package names are case-insensitive and treat hyphens,
|
|
176
|
+
underscores, and dots as equivalent.
|
|
177
|
+
"""
|
|
178
|
+
return re.sub(r"[-_.]+", "-", package_name).lower()
|
|
179
|
+
|
|
180
|
+
def get_alternative_names(self, package_name: str) -> list[str]:
|
|
181
|
+
"""
|
|
182
|
+
Get alternative names to try for package lookup.
|
|
183
|
+
|
|
184
|
+
Python package names can use hyphens, underscores, or dots
|
|
185
|
+
interchangeably.
|
|
186
|
+
"""
|
|
187
|
+
alternatives = []
|
|
188
|
+
normalized = self.normalize_package_name(package_name)
|
|
189
|
+
|
|
190
|
+
# Try with underscores instead of hyphens
|
|
191
|
+
alt_underscore = normalized.replace("-", "_")
|
|
192
|
+
if alt_underscore != normalized:
|
|
193
|
+
alternatives.append(alt_underscore)
|
|
194
|
+
|
|
195
|
+
# Try with dots instead of hyphens
|
|
196
|
+
alt_dot = normalized.replace("-", ".")
|
|
197
|
+
if alt_dot != normalized:
|
|
198
|
+
alternatives.append(alt_dot)
|
|
199
|
+
|
|
200
|
+
return alternatives
|