package-query 0.1.0__tar.gz

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.
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.3
2
+ Name: package-query
3
+ Version: 0.1.0
4
+ Summary: Query latest package versions from PyPI, npm, crates.io, Docker Hub, and GitHub Actions
5
+ Keywords: mcp,pypi,npm,crates.io,docker,github actions
6
+ Author: Henrique
7
+ Author-email: Henrique <henriquemoreira10fk@gmail.com>
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Internet :: WWW/HTTP
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Typing :: Typed
21
+ Requires-Dist: curl-cffi>=0.14.0
22
+ Requires-Dist: orjson>=3.11.5
23
+ Requires-Dist: pydantic>=2.12.5
24
+ Requires-Dist: fastmcp>=2.14.2 ; extra == 'mcp'
25
+ Requires-Python: >=3.10, <3.15
26
+ Project-URL: Changelog, https://github.com/henrique-coder/package-query/releases
27
+ Project-URL: Documentation, https://github.com/henrique-coder/package-query#readme
28
+ Project-URL: Homepage, https://github.com/henrique-coder/package-query
29
+ Project-URL: Issues, https://github.com/henrique-coder/package-query/issues
30
+ Project-URL: Repository, https://github.com/henrique-coder/package-query.git
31
+ Provides-Extra: mcp
32
+ Description-Content-Type: text/markdown
33
+
34
+ # package-query
35
+
36
+ Query the latest package versions from **PyPI**, **npm**, **crates.io**, **Docker Hub**, and **GitHub Actions** — directly from Python or as an MCP server for AI agents.
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ # Core library only
42
+ uv pip install package-query
43
+
44
+ # With MCP server support
45
+ uv pip install "package-query[mcp]"
46
+ ```
47
+
48
+ ## Python Usage
49
+
50
+ ```python
51
+ import asyncio
52
+ from package_query import PackageQuery, PackageInfo
53
+
54
+ async def main():
55
+ pq = PackageQuery()
56
+
57
+ info: PackageInfo = await pq.query("pypi", "requests")
58
+ print(f"{info.name}=={info.version}")
59
+
60
+ info = await pq.query("npm", "express")
61
+ print(f"{info.name}=={info.version}")
62
+
63
+ info = await pq.query("github-actions", "actions/checkout")
64
+ print(f"{info.name}=={info.version}")
65
+
66
+ asyncio.run(main())
67
+ ```
68
+
69
+ ### Supported Registries
70
+
71
+ | Registry | Example Package |
72
+ | ---------------- | ------------------ |
73
+ | `pypi` | `requests` |
74
+ | `npm` | `express` |
75
+ | `crates` | `serde` |
76
+ | `docker` | `nginx` |
77
+ | `github-actions` | `actions/checkout` |
78
+
79
+ ## MCP Server
80
+
81
+ The MCP server allows AI agents (Claude, Copilot, Cursor, etc.) to query package versions without web search.
82
+
83
+ ### Running the Server
84
+
85
+ ```bash
86
+ # If installed with [mcp] extra
87
+ package-query-mcp
88
+
89
+ # Or from source
90
+ uv run --extra mcp package-query-mcp
91
+ ```
92
+
93
+ ### IDE Configuration
94
+
95
+ #### VS Code / Cursor
96
+
97
+ Add to your `settings.json`:
98
+
99
+ ```json
100
+ {
101
+ "mcp": {
102
+ "servers": {
103
+ "package-query": {
104
+ "command": "uvx",
105
+ "args": ["--from", "package-query[mcp]", "package-query-mcp"]
106
+ }
107
+ }
108
+ }
109
+ }
110
+ ```
111
+
112
+ Or if installed globally:
113
+
114
+ ```json
115
+ {
116
+ "mcp": {
117
+ "servers": {
118
+ "package-query": {
119
+ "command": "package-query-mcp"
120
+ }
121
+ }
122
+ }
123
+ }
124
+ ```
125
+
126
+ #### Claude Desktop
127
+
128
+ Add to `~/.config/Claude/claude_desktop_config.json` (Linux) or equivalent:
129
+
130
+ ```json
131
+ {
132
+ "mcpServers": {
133
+ "package-query": {
134
+ "command": "uvx",
135
+ "args": ["--from", "package-query[mcp]", "package-query-mcp"]
136
+ }
137
+ }
138
+ }
139
+ ```
140
+
141
+ #### Antigravity IDE
142
+
143
+ Add to `~/.gemini/antigravity/mcp_config.json`:
144
+
145
+ ```json
146
+ {
147
+ "mcpServers": {
148
+ "package-query": {
149
+ "command": "uvx",
150
+ "args": ["--from", "package-query[mcp]", "package-query-mcp"]
151
+ }
152
+ }
153
+ }
154
+ ```
155
+
156
+ ### Available Tools
157
+
158
+ | Tool | Description |
159
+ | --------------------- | ----------------------------------- |
160
+ | `get_package_version` | Get the latest version of a package |
161
+
162
+ **Parameters:**
163
+
164
+ - `registry` (string): One of `pypi`, `npm`, `crates`, `docker`, `github-actions`
165
+ - `package` (string): Package name
166
+ - `include_prerelease` (bool, optional): Include pre-release versions
167
+
168
+ ## Development
169
+
170
+ ```bash
171
+ # Clone and install all dependencies
172
+ git clone https://github.com/henrique-coder/package-query.git
173
+ cd package-query
174
+ uv sync --upgrade --all-groups --all-extras
175
+ just lint
176
+ just format
177
+ ```
178
+
179
+ ## License
180
+
181
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,148 @@
1
+ # package-query
2
+
3
+ Query the latest package versions from **PyPI**, **npm**, **crates.io**, **Docker Hub**, and **GitHub Actions** — directly from Python or as an MCP server for AI agents.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ # Core library only
9
+ uv pip install package-query
10
+
11
+ # With MCP server support
12
+ uv pip install "package-query[mcp]"
13
+ ```
14
+
15
+ ## Python Usage
16
+
17
+ ```python
18
+ import asyncio
19
+ from package_query import PackageQuery, PackageInfo
20
+
21
+ async def main():
22
+ pq = PackageQuery()
23
+
24
+ info: PackageInfo = await pq.query("pypi", "requests")
25
+ print(f"{info.name}=={info.version}")
26
+
27
+ info = await pq.query("npm", "express")
28
+ print(f"{info.name}=={info.version}")
29
+
30
+ info = await pq.query("github-actions", "actions/checkout")
31
+ print(f"{info.name}=={info.version}")
32
+
33
+ asyncio.run(main())
34
+ ```
35
+
36
+ ### Supported Registries
37
+
38
+ | Registry | Example Package |
39
+ | ---------------- | ------------------ |
40
+ | `pypi` | `requests` |
41
+ | `npm` | `express` |
42
+ | `crates` | `serde` |
43
+ | `docker` | `nginx` |
44
+ | `github-actions` | `actions/checkout` |
45
+
46
+ ## MCP Server
47
+
48
+ The MCP server allows AI agents (Claude, Copilot, Cursor, etc.) to query package versions without web search.
49
+
50
+ ### Running the Server
51
+
52
+ ```bash
53
+ # If installed with [mcp] extra
54
+ package-query-mcp
55
+
56
+ # Or from source
57
+ uv run --extra mcp package-query-mcp
58
+ ```
59
+
60
+ ### IDE Configuration
61
+
62
+ #### VS Code / Cursor
63
+
64
+ Add to your `settings.json`:
65
+
66
+ ```json
67
+ {
68
+ "mcp": {
69
+ "servers": {
70
+ "package-query": {
71
+ "command": "uvx",
72
+ "args": ["--from", "package-query[mcp]", "package-query-mcp"]
73
+ }
74
+ }
75
+ }
76
+ }
77
+ ```
78
+
79
+ Or if installed globally:
80
+
81
+ ```json
82
+ {
83
+ "mcp": {
84
+ "servers": {
85
+ "package-query": {
86
+ "command": "package-query-mcp"
87
+ }
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ #### Claude Desktop
94
+
95
+ Add to `~/.config/Claude/claude_desktop_config.json` (Linux) or equivalent:
96
+
97
+ ```json
98
+ {
99
+ "mcpServers": {
100
+ "package-query": {
101
+ "command": "uvx",
102
+ "args": ["--from", "package-query[mcp]", "package-query-mcp"]
103
+ }
104
+ }
105
+ }
106
+ ```
107
+
108
+ #### Antigravity IDE
109
+
110
+ Add to `~/.gemini/antigravity/mcp_config.json`:
111
+
112
+ ```json
113
+ {
114
+ "mcpServers": {
115
+ "package-query": {
116
+ "command": "uvx",
117
+ "args": ["--from", "package-query[mcp]", "package-query-mcp"]
118
+ }
119
+ }
120
+ }
121
+ ```
122
+
123
+ ### Available Tools
124
+
125
+ | Tool | Description |
126
+ | --------------------- | ----------------------------------- |
127
+ | `get_package_version` | Get the latest version of a package |
128
+
129
+ **Parameters:**
130
+
131
+ - `registry` (string): One of `pypi`, `npm`, `crates`, `docker`, `github-actions`
132
+ - `package` (string): Package name
133
+ - `include_prerelease` (bool, optional): Include pre-release versions
134
+
135
+ ## Development
136
+
137
+ ```bash
138
+ # Clone and install all dependencies
139
+ git clone https://github.com/henrique-coder/package-query.git
140
+ cd package-query
141
+ uv sync --upgrade --all-groups --all-extras
142
+ just lint
143
+ just format
144
+ ```
145
+
146
+ ## License
147
+
148
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,108 @@
1
+ [project]
2
+ name = "package-query"
3
+ version = "0.1.0"
4
+ description = "Query latest package versions from PyPI, npm, crates.io, Docker Hub, and GitHub Actions"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Henrique", email = "henriquemoreira10fk@gmail.com" }
8
+ ]
9
+ keywords = ["mcp", "pypi", "npm", "crates.io", "docker", "github actions"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Topic :: Internet :: WWW/HTTP",
22
+ "Topic :: Software Development :: Libraries :: Python Modules",
23
+ "Typing :: Typed",
24
+ ]
25
+ requires-python = ">=3.10,<3.15"
26
+ dependencies = [
27
+ "curl-cffi>=0.14.0",
28
+ "orjson>=3.11.5",
29
+ "pydantic>=2.12.5",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ mcp = [
34
+ "fastmcp>=2.14.2"
35
+ ]
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "rich>=14.2.0"
40
+ ]
41
+ lint = [
42
+ "ruff>=0.14.10",
43
+ "ty>=0.0.9",
44
+ ]
45
+
46
+ [project.urls]
47
+ Homepage = "https://github.com/henrique-coder/package-query"
48
+ Documentation = "https://github.com/henrique-coder/package-query#readme"
49
+ Repository = "https://github.com/henrique-coder/package-query.git"
50
+ Issues = "https://github.com/henrique-coder/package-query/issues"
51
+ Changelog = "https://github.com/henrique-coder/package-query/releases"
52
+
53
+ [tool.ruff]
54
+ line-length = 120
55
+ indent-width = 4
56
+
57
+ [tool.ruff.lint]
58
+ select = [
59
+ "F", # Pyflakes: unused imports/variables, undefined names
60
+ "E", # pycodestyle errors: basic PEP 8 style violations
61
+ "W", # pycodestyle warnings: whitespace issues, blank lines
62
+ "I", # isort: import sorting and organization
63
+ "UP", # pyupgrade: upgrade syntax for newer Python versions
64
+ "B", # flake8-bugbear: common bugs and design problems
65
+ "SIM", # flake8-simplify: simplify complex code patterns
66
+ "C4", # flake8-comprehensions: better list/dict/set comprehensions
67
+ "PIE", # flake8-pie: misc. lints (unnecessary pass, duplicate keys)
68
+ "RUF", # Ruff-specific: modern Python best practices
69
+ "PERF", # Perflint: performance anti-patterns
70
+ "FURB", # refurb: modernize legacy Python idioms
71
+ "PTH", # flake8-use-pathlib: prefer pathlib over os.path
72
+ "T20", # flake8-print: detect leftover print() statements
73
+ "TCH", # flake8-type-checking: optimize TYPE_CHECKING imports
74
+ "PL", # Pylint: broad set of code quality checks
75
+ "D205", # 1 blank line required between docstring and code
76
+ ]
77
+ ignore = [
78
+ "PLR0912", # Too many branches (complex validation logic is acceptable)
79
+ "PLR0913", # Too many arguments in function definition (common in APIs)
80
+ "PLR2004", # Magic value used in comparison (too strict for general use)
81
+ ]
82
+ fixable = ["ALL"] # Allow auto-fix for all enabled rules
83
+ dummy-variable-rgx = "^_$" # Only underscore is considered a dummy variable
84
+
85
+ [tool.ruff.lint.isort]
86
+ lines-after-imports = 2 # PEP 8: two blank lines after imports
87
+ force-sort-within-sections = true # Alphabetical order within each section
88
+ split-on-trailing-comma = true # Trailing comma triggers multi-line format
89
+
90
+ [tool.ruff.lint.pydocstyle]
91
+ convention = "google" # Google-style docstrings (Args, Returns, Raises)
92
+
93
+ [tool.ruff.lint.flake8-quotes]
94
+ docstring-quotes = "double" # Docstrings must use triple double quotes
95
+
96
+ [tool.ruff.format]
97
+ quote-style = "double" # Strings use double quotes (PEP 8 preference)
98
+ indent-style = "space" # Spaces over tabs (PEP 8)
99
+ line-ending = "lf" # Unix-style line endings
100
+ docstring-code-format = true # Format code examples inside docstrings
101
+ skip-magic-trailing-comma = false # Preserve trailing commas as formatting hints
102
+
103
+ [project.scripts]
104
+ package-query-mcp = "package_query.mcp_server:main"
105
+
106
+ [build-system]
107
+ requires = ["uv_build"]
108
+ build-backend = "uv_build"
@@ -0,0 +1,5 @@
1
+ from package_query.client import PackageQuery
2
+ from package_query.models import PackageInfo
3
+
4
+
5
+ __all__ = ["PackageInfo", "PackageQuery"]
@@ -0,0 +1,41 @@
1
+ from typing import Final
2
+
3
+ from package_query.models import PackageInfo
4
+ from package_query.providers.crates import CratesProvider
5
+ from package_query.providers.dockerhub import DockerHubProvider
6
+ from package_query.providers.github_actions import GitHubActionsProvider
7
+ from package_query.providers.npm import NpmProvider
8
+ from package_query.providers.pypi import PyPIProvider
9
+
10
+
11
+ class PackageQuery:
12
+ REGISTRIES: Final[dict[str, type]] = {
13
+ "pypi": PyPIProvider,
14
+ "github-actions": GitHubActionsProvider,
15
+ "npm": NpmProvider,
16
+ "crates": CratesProvider,
17
+ "docker": DockerHubProvider,
18
+ }
19
+
20
+ async def query(
21
+ self,
22
+ registry: str,
23
+ package: str,
24
+ *,
25
+ include_prerelease: bool = False,
26
+ source: str | None = None,
27
+ fallback: bool = True,
28
+ ) -> PackageInfo:
29
+ provider_class = self.REGISTRIES.get(registry.lower())
30
+ if provider_class is None:
31
+ supported: str = ", ".join(self.REGISTRIES.keys())
32
+ raise ValueError(f"Unsupported registry '{registry}'. Supported: {supported}")
33
+ return await provider_class().get_package_info(
34
+ package,
35
+ include_prerelease=include_prerelease,
36
+ source=source,
37
+ fallback=fallback,
38
+ )
39
+
40
+ def register(self, name: str, provider: type) -> None:
41
+ self.REGISTRIES[name.lower()] = provider
@@ -0,0 +1,33 @@
1
+ from re import Pattern, compile
2
+ from typing import Final
3
+
4
+
5
+ PYPI_PACKAGE_PATTERN: Final[Pattern[str]] = compile(r"^[a-zA-Z0-9_-]+$")
6
+ GITHUB_REPO_PATTERN: Final[Pattern[str]] = compile(r"^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?/[a-zA-Z0-9._-]+$")
7
+ NPM_PACKAGE_PATTERN: Final[Pattern[str]] = compile(r"^(@[a-zA-Z0-9_-]+/)?[a-zA-Z0-9._-]+$")
8
+ CRATES_PACKAGE_PATTERN: Final[Pattern[str]] = compile(r"^[a-zA-Z][a-zA-Z0-9_-]*$")
9
+ DOCKER_IMAGE_PATTERN: Final[Pattern[str]] = compile(r"^[a-z0-9_-]+(/[a-z0-9._-]+)?$")
10
+
11
+ PIWHEELS_BASE_URL: Final[str] = "https://www.piwheels.org/project"
12
+ PYPI_BASE_URL: Final[str] = "https://pypi.org/project"
13
+ GITHUB_API_BASE_URL: Final[str] = "https://api.github.com/repos"
14
+ GITHUB_BASE_URL: Final[str] = "https://github.com"
15
+ NPM_REGISTRY_URL: Final[str] = "https://registry.npmjs.org"
16
+ NPM_BASE_URL: Final[str] = "https://www.npmjs.com/package"
17
+ CRATES_API_URL: Final[str] = "https://crates.io/api/v1/crates"
18
+ CRATES_BASE_URL: Final[str] = "https://crates.io/crates"
19
+ DOCKERHUB_API_URL: Final[str] = "https://hub.docker.com/v2/repositories"
20
+ DOCKERHUB_BASE_URL: Final[str] = "https://hub.docker.com/r"
21
+
22
+ HTTP_HEADERS: Final[dict[str, str]] = {
23
+ "User-Agent": "package-query/1.0",
24
+ "Accept": "application/json",
25
+ }
26
+
27
+ GITHUB_HEADERS: Final[dict[str, str]] = {
28
+ "User-Agent": "package-query/1.0",
29
+ "Accept": "application/vnd.github+json",
30
+ }
31
+
32
+ MAJOR_VERSION_PATTERN: Final[Pattern[str]] = compile(r"^v(\d+)$")
33
+ SEMVER_PATTERN: Final[Pattern[str]] = compile(r"^v(\d+)\.(\d+)\.(\d+)$")
@@ -0,0 +1,10 @@
1
+ class PackageNotFoundError(Exception):
2
+ pass
3
+
4
+
5
+ class InvalidPackageNameError(ValueError):
6
+ pass
7
+
8
+
9
+ class RegistryError(Exception):
10
+ pass
@@ -0,0 +1,13 @@
1
+ from typing import Any
2
+
3
+ from curl_cffi.requests import AsyncSession
4
+ import orjson
5
+
6
+
7
+ async def fetch_json(url: str, headers: dict[str, str]) -> dict[str, Any]:
8
+ async with AsyncSession() as session:
9
+ response = await session.get(url, headers=headers)
10
+ if response.status_code == 404:
11
+ raise ValueError("Not found")
12
+ response.raise_for_status()
13
+ return orjson.loads(response.content)
@@ -0,0 +1,48 @@
1
+ """
2
+ MCP Server for package-query.
3
+
4
+ Exposes package version queries to AI agents via Model Context Protocol.
5
+ """
6
+
7
+ from fastmcp import FastMCP
8
+
9
+ from package_query import PackageQuery
10
+
11
+
12
+ mcp = FastMCP(
13
+ "package-query",
14
+ "Query latest package versions from PyPI, npm, crates.io, Docker Hub, and GitHub Actions",
15
+ )
16
+
17
+
18
+ @mcp.tool()
19
+ async def get_package_version(
20
+ registry: str,
21
+ package: str,
22
+ include_prerelease: bool = False,
23
+ ) -> str:
24
+ """
25
+ Get the latest version of a package from a registry.
26
+
27
+ Args:
28
+ registry: One of: pypi, npm, crates, docker, github-actions
29
+ package: Package name (e.g. "requests", "express", "nginx", "actions/checkout")
30
+ include_prerelease: Include pre-release versions if True (default: False)
31
+
32
+ Returns:
33
+ A formatted string with package name, version, and registry URL
34
+ """
35
+ pq = PackageQuery()
36
+ info = await pq.query(registry, package, include_prerelease=include_prerelease)
37
+ return f"{info.name}=={info.version} ({info.registry_url})"
38
+
39
+
40
+ def main() -> None:
41
+ """
42
+ Entry point for the MCP server.
43
+ """
44
+ mcp.run()
45
+
46
+
47
+ if __name__ == "__main__":
48
+ main()
@@ -0,0 +1,20 @@
1
+ from typing import Any
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class PackageInfo(BaseModel):
7
+ name: str
8
+ version: str
9
+ summary: str | None = None
10
+ released_at: str | None = None
11
+ is_prerelease: bool = False
12
+ homepage_url: str | None = None
13
+ registry_url: str | None = None
14
+ registry: str | None = None
15
+ source_used: str | None = None
16
+ sources_failed: list[str] = []
17
+ sources_remaining: list[str] = []
18
+
19
+ def to_dict(self) -> dict[str, Any]:
20
+ return self.model_dump()
@@ -0,0 +1,4 @@
1
+ from package_query.providers.base import PackageProvider
2
+
3
+
4
+ __all__ = ["PackageProvider"]
@@ -0,0 +1,7 @@
1
+ from typing import Protocol
2
+
3
+ from package_query.models import PackageInfo
4
+
5
+
6
+ class PackageProvider(Protocol):
7
+ async def get_package_info(self, package: str) -> PackageInfo: ...
@@ -0,0 +1,60 @@
1
+ from typing import Final
2
+
3
+ from package_query.constants import (
4
+ CRATES_API_URL,
5
+ CRATES_BASE_URL,
6
+ CRATES_PACKAGE_PATTERN,
7
+ HTTP_HEADERS,
8
+ )
9
+ from package_query.http import fetch_json
10
+ from package_query.models import PackageInfo
11
+
12
+
13
+ class CratesProvider:
14
+ REGISTRY_NAME: Final[str] = "crates"
15
+ SOURCE_NAME: Final[str] = "crates.io"
16
+
17
+ async def get_package_info(
18
+ self,
19
+ package: str,
20
+ *,
21
+ include_prerelease: bool = False,
22
+ source: str | None = None,
23
+ fallback: bool = True,
24
+ ) -> PackageInfo:
25
+ if not CRATES_PACKAGE_PATTERN.match(package):
26
+ raise ValueError(f"Invalid crate name '{package}'")
27
+
28
+ url: str = f"{CRATES_API_URL}/{package}"
29
+ data: dict = await fetch_json(url, HTTP_HEADERS)
30
+
31
+ crate: dict = data.get("crate", {})
32
+ versions: list[dict] = data.get("versions", [])
33
+
34
+ version: str = crate.get("max_version", "")
35
+ released_at: str | None = None
36
+ is_prerelease: bool = False
37
+
38
+ for v in versions:
39
+ if v.get("num") == version:
40
+ released_at = v.get("created_at")
41
+ is_prerelease = v.get("yanked", False)
42
+ break
43
+
44
+ if include_prerelease and versions:
45
+ version = versions[0].get("num", version)
46
+ released_at = versions[0].get("created_at")
47
+
48
+ return PackageInfo(
49
+ name=crate.get("name", package),
50
+ version=version,
51
+ summary=crate.get("description"),
52
+ released_at=released_at[:19] + "Z" if released_at else None,
53
+ is_prerelease=is_prerelease,
54
+ homepage_url=crate.get("homepage") or f"{CRATES_BASE_URL}/{package}",
55
+ registry_url=f"{CRATES_BASE_URL}/{package}",
56
+ registry=self.REGISTRY_NAME,
57
+ source_used=self.SOURCE_NAME,
58
+ sources_failed=[],
59
+ sources_remaining=[],
60
+ )
@@ -0,0 +1,64 @@
1
+ from typing import Final
2
+
3
+ from package_query.constants import (
4
+ DOCKER_IMAGE_PATTERN,
5
+ DOCKERHUB_API_URL,
6
+ DOCKERHUB_BASE_URL,
7
+ HTTP_HEADERS,
8
+ )
9
+ from package_query.http import fetch_json
10
+ from package_query.models import PackageInfo
11
+
12
+
13
+ class DockerHubProvider:
14
+ REGISTRY_NAME: Final[str] = "docker"
15
+ SOURCE_NAME: Final[str] = "dockerhub"
16
+
17
+ async def get_package_info(
18
+ self,
19
+ package: str,
20
+ *,
21
+ include_prerelease: bool = False,
22
+ source: str | None = None,
23
+ fallback: bool = True,
24
+ ) -> PackageInfo:
25
+ if "/" not in package:
26
+ package = f"library/{package}"
27
+
28
+ if not DOCKER_IMAGE_PATTERN.match(package):
29
+ raise ValueError(f"Invalid Docker image name '{package}'")
30
+
31
+ namespace, repo = package.split("/", 1)
32
+ tags_url: str = f"{DOCKERHUB_API_URL}/{namespace}/{repo}/tags?page_size=10"
33
+ data: dict = await fetch_json(tags_url, HTTP_HEADERS)
34
+
35
+ results: list[dict] = data.get("results", [])
36
+ if not results:
37
+ raise ValueError(f"Docker image '{package}' has no tags")
38
+
39
+ latest_tag: dict | None = None
40
+ for tag in results:
41
+ if tag.get("name") == "latest":
42
+ latest_tag = tag
43
+ break
44
+
45
+ if not latest_tag:
46
+ latest_tag = results[0]
47
+
48
+ version: str = latest_tag.get("name", "latest")
49
+ last_updated: str | None = latest_tag.get("last_updated")
50
+ display_name: str = repo if namespace == "library" else package
51
+
52
+ return PackageInfo(
53
+ name=display_name,
54
+ version=version,
55
+ summary=None,
56
+ released_at=last_updated[:19] + "Z" if last_updated else None,
57
+ is_prerelease=False,
58
+ homepage_url=f"{DOCKERHUB_BASE_URL}/{package}",
59
+ registry_url=f"{DOCKERHUB_BASE_URL}/{package}",
60
+ registry=self.REGISTRY_NAME,
61
+ source_used=self.SOURCE_NAME,
62
+ sources_failed=[],
63
+ sources_remaining=[],
64
+ )
@@ -0,0 +1,93 @@
1
+ from typing import Final
2
+
3
+ from curl_cffi.requests import AsyncSession
4
+ import orjson
5
+
6
+ from package_query.constants import (
7
+ GITHUB_API_BASE_URL,
8
+ GITHUB_BASE_URL,
9
+ GITHUB_HEADERS,
10
+ GITHUB_REPO_PATTERN,
11
+ MAJOR_VERSION_PATTERN,
12
+ SEMVER_PATTERN,
13
+ )
14
+ from package_query.models import PackageInfo
15
+
16
+
17
+ class GitHubActionsProvider:
18
+ REGISTRY_NAME: Final[str] = "github-actions"
19
+ SOURCE_NAME: Final[str] = "github-api"
20
+
21
+ async def get_package_info(
22
+ self,
23
+ package: str,
24
+ *,
25
+ include_prerelease: bool = False,
26
+ source: str | None = None,
27
+ fallback: bool = True,
28
+ ) -> PackageInfo:
29
+ if not GITHUB_REPO_PATTERN.match(package):
30
+ raise ValueError(f"Invalid action format '{package}'. Expected 'owner/repo'")
31
+
32
+ owner, repo = package.split("/", 1)
33
+ tags_url: str = f"{GITHUB_API_BASE_URL}/{owner}/{repo}/tags"
34
+ repo_url: str = f"{GITHUB_API_BASE_URL}/{owner}/{repo}"
35
+
36
+ async with AsyncSession() as session:
37
+ response = await session.get(tags_url, headers=GITHUB_HEADERS)
38
+
39
+ if response.status_code == 404:
40
+ raise ValueError(f"Action '{package}' not found")
41
+
42
+ response.raise_for_status()
43
+ tags: list[dict] = orjson.loads(response.content)
44
+
45
+ if not tags:
46
+ raise ValueError(f"Action '{package}' has no tags")
47
+
48
+ major_versions: dict[int, str] = {}
49
+ latest_semver: tuple[int, int, int] | None = None
50
+ latest_semver_tag: str | None = None
51
+
52
+ for tag in tags:
53
+ name: str = tag.get("name", "")
54
+ if major_match := MAJOR_VERSION_PATTERN.match(name):
55
+ major_num: int = int(major_match.group(1))
56
+ if major_num not in major_versions:
57
+ major_versions[major_num] = name
58
+ elif semver_match := SEMVER_PATTERN.match(name):
59
+ version_tuple: tuple[int, int, int] = (
60
+ int(semver_match.group(1)),
61
+ int(semver_match.group(2)),
62
+ int(semver_match.group(3)),
63
+ )
64
+ if latest_semver is None or version_tuple > latest_semver:
65
+ latest_semver, latest_semver_tag = version_tuple, name
66
+
67
+ if major_versions:
68
+ version: str = major_versions[max(major_versions.keys())]
69
+ elif latest_semver_tag:
70
+ version = latest_semver_tag
71
+ else:
72
+ version = tags[0].get("name", "unknown")
73
+
74
+ repo_response = await session.get(repo_url, headers=GITHUB_HEADERS)
75
+
76
+ description: str | None = None
77
+ if repo_response.status_code == 200:
78
+ repo_data: dict = orjson.loads(repo_response.content)
79
+ description = repo_data.get("description")
80
+
81
+ return PackageInfo(
82
+ name=package,
83
+ version=version,
84
+ summary=description,
85
+ released_at=None,
86
+ is_prerelease=False,
87
+ homepage_url=f"{GITHUB_BASE_URL}/{package}",
88
+ registry_url=f"{GITHUB_BASE_URL}/{package}",
89
+ registry=self.REGISTRY_NAME,
90
+ source_used=self.SOURCE_NAME,
91
+ sources_failed=[],
92
+ sources_remaining=[],
93
+ )
@@ -0,0 +1,52 @@
1
+ from typing import Final
2
+
3
+ from package_query.constants import (
4
+ HTTP_HEADERS,
5
+ NPM_BASE_URL,
6
+ NPM_PACKAGE_PATTERN,
7
+ NPM_REGISTRY_URL,
8
+ )
9
+ from package_query.http import fetch_json
10
+ from package_query.models import PackageInfo
11
+
12
+
13
+ class NpmProvider:
14
+ REGISTRY_NAME: Final[str] = "npm"
15
+ SOURCE_NAME: Final[str] = "npmjs"
16
+
17
+ async def get_package_info(
18
+ self,
19
+ package: str,
20
+ *,
21
+ include_prerelease: bool = False,
22
+ source: str | None = None,
23
+ fallback: bool = True,
24
+ ) -> PackageInfo:
25
+ if not NPM_PACKAGE_PATTERN.match(package):
26
+ raise ValueError(f"Invalid npm package name '{package}'")
27
+
28
+ url: str = f"{NPM_REGISTRY_URL}/{package}"
29
+ data: dict = await fetch_json(url, HTTP_HEADERS)
30
+
31
+ dist_tags: dict = data.get("dist-tags", {})
32
+ version: str = dist_tags.get("latest", "")
33
+
34
+ if include_prerelease and "next" in dist_tags:
35
+ version = dist_tags["next"]
36
+
37
+ time_data: dict = data.get("time", {})
38
+ released_at: str | None = time_data.get(version)
39
+
40
+ return PackageInfo(
41
+ name=data.get("name", package),
42
+ version=version,
43
+ summary=data.get("description"),
44
+ released_at=released_at.replace("Z", "").split(".")[0] + "Z" if released_at else None,
45
+ is_prerelease="next" in dist_tags and version == dist_tags.get("next"),
46
+ homepage_url=f"{NPM_BASE_URL}/{package}",
47
+ registry_url=f"{NPM_BASE_URL}/{package}",
48
+ registry=self.REGISTRY_NAME,
49
+ source_used=self.SOURCE_NAME,
50
+ sources_failed=[],
51
+ sources_remaining=[],
52
+ )
@@ -0,0 +1,145 @@
1
+ from datetime import datetime
2
+ from typing import Any, Final
3
+
4
+ from curl_cffi.requests import AsyncSession
5
+ import orjson
6
+
7
+ from package_query.constants import HTTP_HEADERS, PYPI_PACKAGE_PATTERN
8
+ from package_query.models import PackageInfo
9
+
10
+
11
+ class PyPIPiwheelsSource:
12
+ NAME: Final[str] = "piwheels"
13
+ BASE_URL: Final[str] = "https://www.piwheels.org/project"
14
+
15
+ async def fetch(self, package: str, include_prerelease: bool = False) -> PackageInfo:
16
+ url: str = f"{self.BASE_URL}/{package}/json/"
17
+
18
+ async with AsyncSession() as session:
19
+ response = await session.get(url, headers=HTTP_HEADERS)
20
+
21
+ if response.status_code == 404:
22
+ raise ValueError(f"Package '{package}' not found")
23
+
24
+ response.raise_for_status()
25
+ data: dict = orjson.loads(response.content)
26
+ releases: dict = data.get("releases", {})
27
+
28
+ if not releases:
29
+ raise ValueError(f"Package '{package}' has no releases")
30
+
31
+ latest_version: str | None = None
32
+ latest_released: datetime | None = None
33
+ is_prerelease: bool = False
34
+
35
+ for version, info in releases.items():
36
+ if info.get("yanked", False):
37
+ continue
38
+ released_str: str | None = info.get("released")
39
+ if not released_str:
40
+ continue
41
+ released: datetime = datetime.fromisoformat(released_str.replace(" ", "T"))
42
+ version_is_prerelease: bool = info.get("prerelease", False)
43
+
44
+ if include_prerelease or not version_is_prerelease:
45
+ if latest_released is None or released > latest_released:
46
+ latest_version = version
47
+ latest_released = released
48
+ is_prerelease = version_is_prerelease
49
+
50
+ if latest_version is None:
51
+ raise ValueError(f"Package '{package}' has no valid releases")
52
+
53
+ return PackageInfo(
54
+ name=data.get("package", package),
55
+ version=latest_version,
56
+ summary=data.get("summary"),
57
+ released_at=latest_released.strftime("%Y-%m-%dT%H:%M:%SZ") if latest_released else None,
58
+ is_prerelease=is_prerelease,
59
+ homepage_url=data.get("pypi_url"),
60
+ registry_url=data.get("pypi_url"),
61
+ )
62
+
63
+
64
+ class PyPIOfficialSource:
65
+ NAME: Final[str] = "pypi"
66
+ BASE_URL: Final[str] = "https://pypi.org/pypi"
67
+
68
+ async def fetch(self, package: str, include_prerelease: bool = False) -> PackageInfo:
69
+ url: str = f"{self.BASE_URL}/{package}/json"
70
+
71
+ async with AsyncSession() as session:
72
+ response = await session.get(url, headers=HTTP_HEADERS)
73
+
74
+ if response.status_code == 404:
75
+ raise ValueError(f"Package '{package}' not found")
76
+
77
+ response.raise_for_status()
78
+ data: dict = orjson.loads(response.content)
79
+
80
+ info: dict = data.get("info", {})
81
+ version: str = info.get("version", "")
82
+ releases: dict[str, list[dict[str, Any]]] = data.get("releases", {})
83
+
84
+ released_at: str | None = None
85
+ if releases.get(version):
86
+ upload_time: str | None = releases[version][0].get("upload_time_iso_8601")
87
+ if upload_time:
88
+ released_at = upload_time[:19] + "Z"
89
+
90
+ return PackageInfo(
91
+ name=info.get("name", package),
92
+ version=version,
93
+ summary=info.get("summary"),
94
+ released_at=released_at,
95
+ is_prerelease=False,
96
+ homepage_url=info.get("project_url") or f"https://pypi.org/project/{package}",
97
+ registry_url=f"https://pypi.org/project/{package}",
98
+ )
99
+
100
+
101
+ class PyPIProvider:
102
+ REGISTRY_NAME: Final[str] = "pypi"
103
+ SOURCES: Final[list[type]] = [PyPIPiwheelsSource, PyPIOfficialSource]
104
+ SOURCE_MAP: Final[dict[str, type]] = {
105
+ "piwheels": PyPIPiwheelsSource,
106
+ "pypi": PyPIOfficialSource,
107
+ }
108
+
109
+ async def get_package_info(
110
+ self,
111
+ package: str,
112
+ *,
113
+ include_prerelease: bool = False,
114
+ source: str | None = None,
115
+ fallback: bool = True,
116
+ ) -> PackageInfo:
117
+ if not PYPI_PACKAGE_PATTERN.match(package):
118
+ raise ValueError(f"Invalid package name '{package}'. Use only a-zA-Z0-9_-")
119
+
120
+ if source:
121
+ source_class = self.SOURCE_MAP.get(source.lower())
122
+ if not source_class:
123
+ raise ValueError(f"Unknown source '{source}'. Available: {list(self.SOURCE_MAP.keys())}")
124
+ sources = [source_class] + ([s for s in self.SOURCES if s != source_class] if fallback else [])
125
+ else:
126
+ sources = list(self.SOURCES) if fallback else [self.SOURCES[0]]
127
+
128
+ sources_failed: list[str] = []
129
+ last_error: Exception | None = None
130
+
131
+ for i, source_class in enumerate(sources):
132
+ try:
133
+ result: PackageInfo = await source_class().fetch(package, include_prerelease)
134
+ result.registry = self.REGISTRY_NAME
135
+ result.source_used = source_class.NAME
136
+ result.sources_failed = sources_failed
137
+ result.sources_remaining = [s.NAME for s in sources[i + 1 :]]
138
+ return result
139
+ except Exception as e:
140
+ sources_failed.append(source_class.NAME)
141
+ last_error = e
142
+ if not fallback:
143
+ raise
144
+
145
+ raise last_error or ValueError(f"Failed to fetch package '{package}'")
File without changes