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.
- package_query-0.1.0/PKG-INFO +181 -0
- package_query-0.1.0/README.md +148 -0
- package_query-0.1.0/pyproject.toml +108 -0
- package_query-0.1.0/src/package_query/__init__.py +5 -0
- package_query-0.1.0/src/package_query/client.py +41 -0
- package_query-0.1.0/src/package_query/constants.py +33 -0
- package_query-0.1.0/src/package_query/exceptions.py +10 -0
- package_query-0.1.0/src/package_query/http.py +13 -0
- package_query-0.1.0/src/package_query/mcp_server.py +48 -0
- package_query-0.1.0/src/package_query/models.py +20 -0
- package_query-0.1.0/src/package_query/providers/__init__.py +4 -0
- package_query-0.1.0/src/package_query/providers/base.py +7 -0
- package_query-0.1.0/src/package_query/providers/crates.py +60 -0
- package_query-0.1.0/src/package_query/providers/dockerhub.py +64 -0
- package_query-0.1.0/src/package_query/providers/github_actions.py +93 -0
- package_query-0.1.0/src/package_query/providers/npm.py +52 -0
- package_query-0.1.0/src/package_query/providers/pypi.py +145 -0
- package_query-0.1.0/src/package_query/py.typed +0 -0
|
@@ -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,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,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,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
|