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/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
+ ]
@@ -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