py-code-org 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.
- py_code_org-0.1.0/.gitignore +5 -0
- py_code_org-0.1.0/LICENSE +21 -0
- py_code_org-0.1.0/PKG-INFO +117 -0
- py_code_org-0.1.0/README.md +97 -0
- py_code_org-0.1.0/pyproject.toml +37 -0
- py_code_org-0.1.0/src/py_code_org/__init__.py +2 -0
- py_code_org-0.1.0/src/py_code_org/__main__.py +3 -0
- py_code_org-0.1.0/src/py_code_org/api.py +93 -0
- py_code_org-0.1.0/src/py_code_org/cli.py +146 -0
- py_code_org-0.1.0/src/py_code_org/mcp.py +144 -0
- py_code_org-0.1.0/src/py_code_org/queries.py +112 -0
- py_code_org-0.1.0/tests/test_client.py +144 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 py-code.org
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: py-code-org
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find which PyPI packages use a module, function or class, from the command line or as an MCP server.
|
|
5
|
+
Project-URL: Homepage, https://py-code.org
|
|
6
|
+
Project-URL: Source, https://github.com/pypi-data/py-code
|
|
7
|
+
Project-URL: Issues, https://github.com/pypi-data/py-code/issues
|
|
8
|
+
Project-URL: Dataset, https://huggingface.co/datasets/pypi-data/py-code
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: dependencies,mcp,pypi,reverse dependencies,static analysis,who uses
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# py-code-org
|
|
22
|
+
|
|
23
|
+
Find which PyPI packages use a module, function or class, from the command line or from an AI
|
|
24
|
+
coding tool. It reads the index behind [py-code.org](https://py-code.org), which covers the latest
|
|
25
|
+
release of more than 335,000 PyPI projects (every project released in the last year, plus the
|
|
26
|
+
most downloaded ones) and records every name their code imports or reaches by attribute.
|
|
27
|
+
|
|
28
|
+
Use it before you rename or remove something in a library, to see who calls it and where.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
$ uvx py-code-org who-uses httpx.AsyncClient --limit 3
|
|
32
|
+
26,709 packages use httpx.AsyncClient (index built 2026-11-13)
|
|
33
|
+
|
|
34
|
+
# Package Version Downloads/mo Files First use
|
|
35
|
+
1 google-genai 3.2.0 124.9M 8 google/genai/_api_client.py:735
|
|
36
|
+
2 google-cloud-aiplatform 2.9.0 70.8M 3 agentplatform/_genai/_runtimes_utils.py:1810
|
|
37
|
+
3 sentry-sdk 2.75.0 66.7M 1 sentry_sdk/integrations/httpx.py:28
|
|
38
|
+
|
|
39
|
+
Showing 3 of 26,709. --limit 0 shows the 2,000 most downloaded.
|
|
40
|
+
|
|
41
|
+
Narrower names: httpx.AsyncClient.send (179), httpx.AsyncClient.__init__ (57), ...
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
It has no dependencies and needs Python 3.9 or newer.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
pipx install py-code-org # or: uv tool install py-code-org
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or run it without installing: `uvx py-code-org who-uses requests.Session`.
|
|
53
|
+
|
|
54
|
+
## Commands
|
|
55
|
+
|
|
56
|
+
| Command | What it shows |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `py-code who-uses NAME` | Packages that import or call `NAME`, most downloaded first, with the file and line of the first uses |
|
|
59
|
+
| `py-code project NAME` | What a PyPI project imports, the modules it provides, and how many packages import it |
|
|
60
|
+
| `py-code names MODULE` | Every indexed name under a top-level module and how many packages use each |
|
|
61
|
+
| `py-code mcp` | Run as an MCP server over stdio |
|
|
62
|
+
|
|
63
|
+
Options:
|
|
64
|
+
|
|
65
|
+
- `who-uses --min-downloads N`: only packages with at least N downloads in the last 30 days.
|
|
66
|
+
- `who-uses --limit N`, `names --limit N`: rows to show; `0` shows all.
|
|
67
|
+
- `names --prefix numpy.linalg`: only names that start with the prefix.
|
|
68
|
+
- `--json` on every lookup command prints JSON for scripts.
|
|
69
|
+
|
|
70
|
+
Names are case-sensitive and written as the code imports them: `requests.Session`,
|
|
71
|
+
`numpy.linalg.norm`, `PIL.Image`. When a name is not found, the tool suggests the closest
|
|
72
|
+
indexed spelling. The exit code is 0 when found, 1 when not found and 2 when the index could not be
|
|
73
|
+
reached.
|
|
74
|
+
|
|
75
|
+
## Use it from an AI coding tool (MCP)
|
|
76
|
+
|
|
77
|
+
The `mcp` command gives AI coding tools three read-only tools:
|
|
78
|
+
|
|
79
|
+
- `who_uses(name, min_downloads, limit)`
|
|
80
|
+
- `project_imports(project)`
|
|
81
|
+
- `module_names(module, prefix, limit)`
|
|
82
|
+
|
|
83
|
+
Claude Code:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
claude mcp add py-code -- uvx py-code-org mcp
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Cursor, Claude Desktop and other tools that read an `mcpServers` JSON file:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"mcpServers": {
|
|
94
|
+
"py-code": { "command": "uvx", "args": ["py-code-org", "mcp"] }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Then ask, for example: "Which popular packages call `requests.Session.merge_environment_settings`?"
|
|
100
|
+
|
|
101
|
+
## Where the data comes from
|
|
102
|
+
|
|
103
|
+
Every lookup is two small requests to the static JSON files at `https://data.py-code.org/api`: a
|
|
104
|
+
manifest, then the shard that holds the name. Nothing is sent except the name you look up. The
|
|
105
|
+
index is rebuilt monthly. Set `PY_CODE_API` to read a copy hosted elsewhere.
|
|
106
|
+
|
|
107
|
+
What it misses: older releases, dynamic imports (`importlib.import_module`, `getattr`), names
|
|
108
|
+
passed through variables, and files that do not parse as current Python. For bulk queries, the
|
|
109
|
+
full tables are on Hugging Face as Parquet:
|
|
110
|
+
[pypi-data/py-code](https://huggingface.co/datasets/pypi-data/py-code).
|
|
111
|
+
|
|
112
|
+
The scanner that builds the index is in the same repository:
|
|
113
|
+
[pypi-data/py-code](https://github.com/pypi-data/py-code).
|
|
114
|
+
|
|
115
|
+
## Licence
|
|
116
|
+
|
|
117
|
+
MIT.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# py-code-org
|
|
2
|
+
|
|
3
|
+
Find which PyPI packages use a module, function or class, from the command line or from an AI
|
|
4
|
+
coding tool. It reads the index behind [py-code.org](https://py-code.org), which covers the latest
|
|
5
|
+
release of more than 335,000 PyPI projects (every project released in the last year, plus the
|
|
6
|
+
most downloaded ones) and records every name their code imports or reaches by attribute.
|
|
7
|
+
|
|
8
|
+
Use it before you rename or remove something in a library, to see who calls it and where.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
$ uvx py-code-org who-uses httpx.AsyncClient --limit 3
|
|
12
|
+
26,709 packages use httpx.AsyncClient (index built 2026-11-13)
|
|
13
|
+
|
|
14
|
+
# Package Version Downloads/mo Files First use
|
|
15
|
+
1 google-genai 3.2.0 124.9M 8 google/genai/_api_client.py:735
|
|
16
|
+
2 google-cloud-aiplatform 2.9.0 70.8M 3 agentplatform/_genai/_runtimes_utils.py:1810
|
|
17
|
+
3 sentry-sdk 2.75.0 66.7M 1 sentry_sdk/integrations/httpx.py:28
|
|
18
|
+
|
|
19
|
+
Showing 3 of 26,709. --limit 0 shows the 2,000 most downloaded.
|
|
20
|
+
|
|
21
|
+
Narrower names: httpx.AsyncClient.send (179), httpx.AsyncClient.__init__ (57), ...
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
It has no dependencies and needs Python 3.9 or newer.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pipx install py-code-org # or: uv tool install py-code-org
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or run it without installing: `uvx py-code-org who-uses requests.Session`.
|
|
33
|
+
|
|
34
|
+
## Commands
|
|
35
|
+
|
|
36
|
+
| Command | What it shows |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `py-code who-uses NAME` | Packages that import or call `NAME`, most downloaded first, with the file and line of the first uses |
|
|
39
|
+
| `py-code project NAME` | What a PyPI project imports, the modules it provides, and how many packages import it |
|
|
40
|
+
| `py-code names MODULE` | Every indexed name under a top-level module and how many packages use each |
|
|
41
|
+
| `py-code mcp` | Run as an MCP server over stdio |
|
|
42
|
+
|
|
43
|
+
Options:
|
|
44
|
+
|
|
45
|
+
- `who-uses --min-downloads N`: only packages with at least N downloads in the last 30 days.
|
|
46
|
+
- `who-uses --limit N`, `names --limit N`: rows to show; `0` shows all.
|
|
47
|
+
- `names --prefix numpy.linalg`: only names that start with the prefix.
|
|
48
|
+
- `--json` on every lookup command prints JSON for scripts.
|
|
49
|
+
|
|
50
|
+
Names are case-sensitive and written as the code imports them: `requests.Session`,
|
|
51
|
+
`numpy.linalg.norm`, `PIL.Image`. When a name is not found, the tool suggests the closest
|
|
52
|
+
indexed spelling. The exit code is 0 when found, 1 when not found and 2 when the index could not be
|
|
53
|
+
reached.
|
|
54
|
+
|
|
55
|
+
## Use it from an AI coding tool (MCP)
|
|
56
|
+
|
|
57
|
+
The `mcp` command gives AI coding tools three read-only tools:
|
|
58
|
+
|
|
59
|
+
- `who_uses(name, min_downloads, limit)`
|
|
60
|
+
- `project_imports(project)`
|
|
61
|
+
- `module_names(module, prefix, limit)`
|
|
62
|
+
|
|
63
|
+
Claude Code:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
claude mcp add py-code -- uvx py-code-org mcp
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Cursor, Claude Desktop and other tools that read an `mcpServers` JSON file:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"mcpServers": {
|
|
74
|
+
"py-code": { "command": "uvx", "args": ["py-code-org", "mcp"] }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Then ask, for example: "Which popular packages call `requests.Session.merge_environment_settings`?"
|
|
80
|
+
|
|
81
|
+
## Where the data comes from
|
|
82
|
+
|
|
83
|
+
Every lookup is two small requests to the static JSON files at `https://data.py-code.org/api`: a
|
|
84
|
+
manifest, then the shard that holds the name. Nothing is sent except the name you look up. The
|
|
85
|
+
index is rebuilt monthly. Set `PY_CODE_API` to read a copy hosted elsewhere.
|
|
86
|
+
|
|
87
|
+
What it misses: older releases, dynamic imports (`importlib.import_module`, `getattr`), names
|
|
88
|
+
passed through variables, and files that do not parse as current Python. For bulk queries, the
|
|
89
|
+
full tables are on Hugging Face as Parquet:
|
|
90
|
+
[pypi-data/py-code](https://huggingface.co/datasets/pypi-data/py-code).
|
|
91
|
+
|
|
92
|
+
The scanner that builds the index is in the same repository:
|
|
93
|
+
[pypi-data/py-code](https://github.com/pypi-data/py-code).
|
|
94
|
+
|
|
95
|
+
## Licence
|
|
96
|
+
|
|
97
|
+
MIT.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "py-code-org"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Find which PyPI packages use a module, function or class, from the command line or as an MCP server."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["pypi", "dependencies", "reverse dependencies", "who uses", "mcp", "static analysis"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://py-code.org"
|
|
26
|
+
Source = "https://github.com/pypi-data/py-code"
|
|
27
|
+
Issues = "https://github.com/pypi-data/py-code/issues"
|
|
28
|
+
Dataset = "https://huggingface.co/datasets/pypi-data/py-code"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
py-code = "py_code_org.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.version]
|
|
34
|
+
path = "src/py_code_org/__init__.py"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/py_code_org"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Read the static py-code.org index from data.py-code.org.
|
|
2
|
+
|
|
3
|
+
The index is a set of JSON files sharded by CRC32 (zlib) of a name, so a lookup is two small
|
|
4
|
+
GET requests: the manifest, then the shard that holds the name.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
import zlib
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
|
|
16
|
+
BASE = os.environ.get("PY_CODE_API", "https://data.py-code.org/api").rstrip("/")
|
|
17
|
+
USER_AGENT = f"py-code-org/{__version__} (+https://py-code.org)"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ApiError(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Index:
|
|
25
|
+
def __init__(self, base=BASE, timeout=30):
|
|
26
|
+
self.base = base
|
|
27
|
+
self.timeout = timeout
|
|
28
|
+
self._cache = {}
|
|
29
|
+
|
|
30
|
+
def get(self, path):
|
|
31
|
+
"""Fetch one JSON file. Returns None when it does not exist."""
|
|
32
|
+
if path in self._cache:
|
|
33
|
+
return self._cache[path]
|
|
34
|
+
req = urllib.request.Request(self.base + path, headers={"User-Agent": USER_AGENT, "Accept": "application/json"})
|
|
35
|
+
try:
|
|
36
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r:
|
|
37
|
+
data = json.load(r)
|
|
38
|
+
except urllib.error.HTTPError as e:
|
|
39
|
+
if e.code == 404:
|
|
40
|
+
data = None
|
|
41
|
+
else:
|
|
42
|
+
raise ApiError(f"{self.base}{path}: HTTP {e.code}") from e
|
|
43
|
+
except (urllib.error.URLError, OSError) as e:
|
|
44
|
+
raise ApiError(f"could not reach {self.base}: {getattr(e, 'reason', e)}") from e
|
|
45
|
+
self._cache[path] = data
|
|
46
|
+
return data
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def manifest(self):
|
|
50
|
+
m = self.get("/manifest.json")
|
|
51
|
+
if not m:
|
|
52
|
+
raise ApiError(f"{self.base}/manifest.json is missing")
|
|
53
|
+
return m
|
|
54
|
+
|
|
55
|
+
def name_shard(self, name):
|
|
56
|
+
"""Path of the shard file that holds a dotted name such as requests.Session."""
|
|
57
|
+
m, top = self.manifest, name.split(".")[0]
|
|
58
|
+
if top in m["big"]:
|
|
59
|
+
return f"/big/{urllib.parse.quote(top)}/{crc32(name) % m['big'][top]}.json"
|
|
60
|
+
return f"/idx/{crc32(top) % m['idx_shards']:03x}.json"
|
|
61
|
+
|
|
62
|
+
def who_uses(self, name):
|
|
63
|
+
"""{'total': int, 'rows': [[project, version, downloads_30d, files, [path:line, ...]], ...]} or None.
|
|
64
|
+
|
|
65
|
+
rows hold at most manifest['row_cap'] projects, sorted by downloads; total is the true count.
|
|
66
|
+
"""
|
|
67
|
+
top = name.split(".")[0]
|
|
68
|
+
d = self.get(self.name_shard(name)) or {}
|
|
69
|
+
return d.get(name) if top in self.manifest["big"] else (d.get(top) or {}).get(name)
|
|
70
|
+
|
|
71
|
+
def names(self, module):
|
|
72
|
+
"""[[name, projects], ...] for every indexed name under a top-level module, most used first."""
|
|
73
|
+
m = self.manifest
|
|
74
|
+
d = self.get(f"/names/{crc32(module) % m['idx_shards']:03x}.json") or {}
|
|
75
|
+
return d.get(module) or []
|
|
76
|
+
|
|
77
|
+
def project(self, name):
|
|
78
|
+
"""One project's document: version, summary, downloads, imports, own modules, users. None if not indexed."""
|
|
79
|
+
k = norm(name)
|
|
80
|
+
d = self.get(f"/project/{crc32(k) % self.manifest['project_shards']:04x}.json") or {}
|
|
81
|
+
return d.get(k)
|
|
82
|
+
|
|
83
|
+
def status(self):
|
|
84
|
+
return self.get("/status.json") or {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def crc32(s):
|
|
88
|
+
return zlib.crc32(s.encode("utf-8"))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def norm(name):
|
|
92
|
+
"""PEP 503 normalized project name."""
|
|
93
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""py-code: which PyPI packages use a module, function or class.
|
|
2
|
+
|
|
3
|
+
py-code who-uses requests.Session
|
|
4
|
+
py-code project fastapi
|
|
5
|
+
py-code names httpx
|
|
6
|
+
py-code mcp run as an MCP server over stdio
|
|
7
|
+
"""
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .api import ApiError, Index
|
|
14
|
+
from .queries import NotFound, module_names, project_imports, who_uses
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def human(n):
|
|
18
|
+
for size, unit in ((1e9, "B"), (1e6, "M"), (1e3, "K")):
|
|
19
|
+
if n >= size:
|
|
20
|
+
return f"{n / size:.1f}{unit}"
|
|
21
|
+
return str(n)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def table(header, rows, right=()):
|
|
25
|
+
widths = [max(len(str(x)) for x in col) for col in zip(header, *rows)]
|
|
26
|
+
fmt = lambda row: " ".join( # noqa: E731
|
|
27
|
+
str(x).rjust(w) if i in right else str(x).ljust(w) for i, (x, w) in enumerate(zip(row, widths))
|
|
28
|
+
).rstrip()
|
|
29
|
+
return "\n".join([fmt(header)] + [fmt(r) for r in rows])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def print_who_uses(r):
|
|
33
|
+
n = r["total"]
|
|
34
|
+
print(f"{n:,} package{'s' if n != 1 else ''} use {r['name']}" + (f" (index built {r['index_built'][:10]})" if r["index_built"] else ""))
|
|
35
|
+
if r["min_downloads"]:
|
|
36
|
+
at_least = "" if r["matching_exact"] else "At least "
|
|
37
|
+
print(f"{at_least}{r['matching']:,} of them have {human(r['min_downloads'])} or more downloads a month")
|
|
38
|
+
if r["packages"]:
|
|
39
|
+
print()
|
|
40
|
+
rows = [
|
|
41
|
+
(i, p["project"], p["version"], human(p["downloads_30d"]), p["files"], p["locations"][0] if p["locations"] else "")
|
|
42
|
+
for i, p in enumerate(r["packages"], 1)
|
|
43
|
+
]
|
|
44
|
+
print(table(("#", "Package", "Version", "Downloads/mo", "Files", "First use"), rows, right=(0, 3, 4)))
|
|
45
|
+
of = r["matching"] if r["min_downloads"] and r["matching_exact"] else r["total"]
|
|
46
|
+
if len(r["packages"]) < r["matching"]:
|
|
47
|
+
print(f"\nShowing {len(r['packages']):,} of {of:,}. --limit 0 shows " + (
|
|
48
|
+
f"all {r['matching']:,}." if r["matching"] == of else f"the {r['matching']:,} most downloaded."))
|
|
49
|
+
elif len(r["packages"]) < of:
|
|
50
|
+
print(f"\nThe index lists the {r['listed']:,} most downloaded of the {r['total']:,}.")
|
|
51
|
+
if r["narrower_names"]:
|
|
52
|
+
print("\nNarrower names: " + ", ".join(f"{x['name']} ({x['projects']:,})" for x in r["narrower_names"]))
|
|
53
|
+
print(f"\n{r['url']}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def print_project(p):
|
|
57
|
+
print(f"{p['project']} {p['version']}" + (f": {p['summary']}" if p["summary"] else ""))
|
|
58
|
+
lines = [
|
|
59
|
+
("Downloads", f"{human(p['downloads_30d'])} in the last 30 days"),
|
|
60
|
+
("Released", p["released"] or "unknown"),
|
|
61
|
+
("Requires Python", p["requires_python"] or "not set"),
|
|
62
|
+
("Indexed file", f"{p['file']} ({p['py_files']} .py files" + (f", {p['py_failed']} did not parse)" if p["py_failed"] else ")")),
|
|
63
|
+
("Its modules", ", ".join(p["own_modules"]) or "none"),
|
|
64
|
+
("Used by", ", ".join(f"{n:,} packages import {m}" for m, n in p["used_by"].items()) or "no indexed package"),
|
|
65
|
+
("Third-party imports", ", ".join(p["third_party_imports"]) or "none"),
|
|
66
|
+
("Stdlib imports", ", ".join(p["stdlib_imports"]) or "none"),
|
|
67
|
+
]
|
|
68
|
+
if p["removed_or_deprecated_names"]:
|
|
69
|
+
lines.append(("Removed or deprecated", ", ".join(p["removed_or_deprecated_names"])))
|
|
70
|
+
if p["pth_files_with_import"]:
|
|
71
|
+
lines.append((".pth with import", ", ".join(p["pth_files_with_import"])))
|
|
72
|
+
w = max(len(k) for k, _ in lines)
|
|
73
|
+
print()
|
|
74
|
+
for k, v in lines:
|
|
75
|
+
print(f"{k.ljust(w)} {v}")
|
|
76
|
+
print(f"\n{p['url']}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def print_names(r):
|
|
80
|
+
capped = " (the index keeps the 5,000 most used names per module)" if r["count"] >= 5000 and not r["prefix"] else ""
|
|
81
|
+
print(f"{r['count']:,} indexed names under {r['prefix'] or r['module']}{capped}\n")
|
|
82
|
+
print(table(("Name", "Packages"), [(x["name"], f"{x['projects']:,}") for x in r["names"]], right=(1,)))
|
|
83
|
+
if r["count"] > len(r["names"]):
|
|
84
|
+
print(f"\n{r['count'] - len(r['names']):,} more. Use --limit 0 to list all.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main(argv=None):
|
|
88
|
+
ap = argparse.ArgumentParser(
|
|
89
|
+
prog="py-code",
|
|
90
|
+
description="Which PyPI packages use a module, function or class. Data from https://py-code.org.",
|
|
91
|
+
)
|
|
92
|
+
ap.add_argument("--version", action="version", version=f"py-code-org {__version__}")
|
|
93
|
+
sub = ap.add_subparsers(dest="cmd", required=True, metavar="command")
|
|
94
|
+
|
|
95
|
+
p = sub.add_parser("who-uses", help="packages that import or call a name, e.g. requests.Session")
|
|
96
|
+
p.add_argument("name", help="dotted name, case-sensitive: a module, function or class")
|
|
97
|
+
p.add_argument("--min-downloads", type=int, default=0, metavar="N", help="only packages with at least N downloads a month")
|
|
98
|
+
p.add_argument("--limit", type=int, default=20, help="rows to show, 0 for all (default 20)")
|
|
99
|
+
p.add_argument("--json", action="store_true", help="print JSON")
|
|
100
|
+
|
|
101
|
+
p = sub.add_parser("project", help="what a PyPI project imports and who uses it")
|
|
102
|
+
p.add_argument("name", help="PyPI project name")
|
|
103
|
+
p.add_argument("--json", action="store_true", help="print JSON")
|
|
104
|
+
|
|
105
|
+
p = sub.add_parser("names", help="indexed names under a top-level module, most used first")
|
|
106
|
+
p.add_argument("module", help="top-level module, e.g. numpy")
|
|
107
|
+
p.add_argument("--prefix", default="", help="only names that start with this, e.g. numpy.linalg")
|
|
108
|
+
p.add_argument("--limit", type=int, default=50, help="rows to show, 0 for all (default 50)")
|
|
109
|
+
p.add_argument("--json", action="store_true", help="print JSON")
|
|
110
|
+
|
|
111
|
+
sub.add_parser("mcp", help="run an MCP server over stdio for AI coding tools")
|
|
112
|
+
|
|
113
|
+
a = ap.parse_args(argv)
|
|
114
|
+
if a.cmd == "mcp":
|
|
115
|
+
from .mcp import serve
|
|
116
|
+
|
|
117
|
+
return serve()
|
|
118
|
+
|
|
119
|
+
index = Index()
|
|
120
|
+
try:
|
|
121
|
+
if a.cmd == "who-uses":
|
|
122
|
+
r, show = who_uses(index, a.name, a.min_downloads, a.limit), print_who_uses
|
|
123
|
+
elif a.cmd == "project":
|
|
124
|
+
r, show = project_imports(index, a.name), print_project
|
|
125
|
+
else:
|
|
126
|
+
r, show = module_names(index, a.module, a.prefix, a.limit), print_names
|
|
127
|
+
except NotFound as e:
|
|
128
|
+
if a.json:
|
|
129
|
+
print(json.dumps({"error": str(e), "did_you_mean": e.suggestions}, indent=2))
|
|
130
|
+
else:
|
|
131
|
+
print(e, file=sys.stderr)
|
|
132
|
+
if e.suggestions:
|
|
133
|
+
print("Did you mean: " + ", ".join(e.suggestions), file=sys.stderr)
|
|
134
|
+
return 1
|
|
135
|
+
except ApiError as e:
|
|
136
|
+
print(f"py-code: {e}", file=sys.stderr)
|
|
137
|
+
return 2
|
|
138
|
+
if a.json:
|
|
139
|
+
print(json.dumps(r, indent=2))
|
|
140
|
+
else:
|
|
141
|
+
show(r)
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
sys.exit(main())
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""A minimal MCP server over stdio (newline-delimited JSON-RPC 2.0), with no dependencies.
|
|
2
|
+
|
|
3
|
+
Tools: who_uses, project_imports, module_names. Each result is JSON text, plus structuredContent.
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .api import ApiError, Index
|
|
10
|
+
from .queries import NotFound, module_names, project_imports, who_uses
|
|
11
|
+
|
|
12
|
+
PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]
|
|
13
|
+
|
|
14
|
+
INSTRUCTIONS = (
|
|
15
|
+
"py-code.org indexes the code inside the latest release of every PyPI project released in the last "
|
|
16
|
+
"year (about 335,000 projects) and records which modules, functions and classes each one imports or "
|
|
17
|
+
"reaches by attribute. Use who_uses before changing or removing a public name in a library, to see "
|
|
18
|
+
"which packages call it and where. Names are case-sensitive and fully qualified as imported, e.g. "
|
|
19
|
+
"requests.Session or numpy.linalg.norm. If a name is not found, use module_names to find the exact spelling."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
TOOLS = [
|
|
23
|
+
{
|
|
24
|
+
"name": "who_uses",
|
|
25
|
+
"title": "Who uses this name on PyPI",
|
|
26
|
+
"description": (
|
|
27
|
+
"List the PyPI packages whose latest release imports or calls a module, function or class, "
|
|
28
|
+
"most downloaded first, with the file and line of the first uses. Returns the true total, "
|
|
29
|
+
"names one level deeper, and suggestions when the name is not indexed."
|
|
30
|
+
),
|
|
31
|
+
"inputSchema": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"properties": {
|
|
34
|
+
"name": {"type": "string", "description": "Dotted name as imported, case-sensitive, e.g. requests.Session"},
|
|
35
|
+
"min_downloads": {"type": "integer", "minimum": 0, "default": 0, "description": "Only packages with at least this many downloads in 30 days"},
|
|
36
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 500, "default": 20, "description": "Packages to return"},
|
|
37
|
+
},
|
|
38
|
+
"required": ["name"],
|
|
39
|
+
},
|
|
40
|
+
"annotations": {"readOnlyHint": True, "openWorldHint": True},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "project_imports",
|
|
44
|
+
"title": "What a PyPI project imports",
|
|
45
|
+
"description": (
|
|
46
|
+
"For one PyPI project: its latest release, Requires-Python, downloads, the top-level modules it "
|
|
47
|
+
"provides, the third-party and standard-library modules it imports, how many indexed packages "
|
|
48
|
+
"import it, and names it uses that recent Python or NumPy versions removed or deprecated."
|
|
49
|
+
),
|
|
50
|
+
"inputSchema": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"properties": {"project": {"type": "string", "description": "PyPI project name, e.g. fastapi"}},
|
|
53
|
+
"required": ["project"],
|
|
54
|
+
},
|
|
55
|
+
"annotations": {"readOnlyHint": True, "openWorldHint": True},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"name": "module_names",
|
|
59
|
+
"title": "Names used under a module",
|
|
60
|
+
"description": (
|
|
61
|
+
"List the indexed names under a top-level module with the number of packages that use each, "
|
|
62
|
+
"most used first. Use it to find the exact spelling for who_uses or to see which parts of a "
|
|
63
|
+
"library people actually call."
|
|
64
|
+
),
|
|
65
|
+
"inputSchema": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"properties": {
|
|
68
|
+
"module": {"type": "string", "description": "Top-level import name, e.g. numpy"},
|
|
69
|
+
"prefix": {"type": "string", "default": "", "description": "Only names starting with this, e.g. numpy.linalg"},
|
|
70
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 50},
|
|
71
|
+
},
|
|
72
|
+
"required": ["module"],
|
|
73
|
+
},
|
|
74
|
+
"annotations": {"readOnlyHint": True, "openWorldHint": True},
|
|
75
|
+
},
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def call_tool(index, name, args):
|
|
80
|
+
if name == "who_uses":
|
|
81
|
+
return who_uses(index, args["name"], int(args.get("min_downloads") or 0), min(int(args.get("limit") or 20), 500))
|
|
82
|
+
if name == "project_imports":
|
|
83
|
+
return project_imports(index, args["project"])
|
|
84
|
+
if name == "module_names":
|
|
85
|
+
return module_names(index, args["module"], args.get("prefix") or "", min(int(args.get("limit") or 50), 1000))
|
|
86
|
+
raise KeyError(name)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def tool_result(data, error=False):
|
|
90
|
+
return {"content": [{"type": "text", "text": json.dumps(data, indent=1)}], "structuredContent": data, "isError": error}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def handle(index, msg):
|
|
94
|
+
"""Return the response for one JSON-RPC message, or None for notifications."""
|
|
95
|
+
method, mid, params = msg.get("method"), msg.get("id"), msg.get("params") or {}
|
|
96
|
+
if mid is None:
|
|
97
|
+
return None
|
|
98
|
+
ok = lambda result: {"jsonrpc": "2.0", "id": mid, "result": result} # noqa: E731
|
|
99
|
+
err = lambda code, text: {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": text}} # noqa: E731
|
|
100
|
+
|
|
101
|
+
if method == "initialize":
|
|
102
|
+
asked = params.get("protocolVersion")
|
|
103
|
+
return ok({
|
|
104
|
+
"protocolVersion": asked if asked in PROTOCOL_VERSIONS else PROTOCOL_VERSIONS[0],
|
|
105
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
106
|
+
"serverInfo": {"name": "py-code", "title": "py-code.org", "version": __version__},
|
|
107
|
+
"instructions": INSTRUCTIONS,
|
|
108
|
+
})
|
|
109
|
+
if method == "ping":
|
|
110
|
+
return ok({})
|
|
111
|
+
if method == "tools/list":
|
|
112
|
+
return ok({"tools": TOOLS})
|
|
113
|
+
if method == "tools/call":
|
|
114
|
+
name, args = params.get("name"), params.get("arguments") or {}
|
|
115
|
+
if name not in {t["name"] for t in TOOLS}:
|
|
116
|
+
return err(-32602, f"Unknown tool: {name}")
|
|
117
|
+
try:
|
|
118
|
+
return ok(tool_result(call_tool(index, name, args)))
|
|
119
|
+
except NotFound as e:
|
|
120
|
+
return ok(tool_result({"error": str(e), "did_you_mean": e.suggestions}, error=True))
|
|
121
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
122
|
+
return ok(tool_result({"error": f"Missing or bad argument: {e}"}, error=True))
|
|
123
|
+
except ApiError as e:
|
|
124
|
+
return ok(tool_result({"error": str(e)}, error=True))
|
|
125
|
+
return err(-32601, f"Method not found: {method}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def serve(stdin=sys.stdin, stdout=sys.stdout):
|
|
129
|
+
index = Index()
|
|
130
|
+
for line in stdin:
|
|
131
|
+
line = line.strip()
|
|
132
|
+
if not line:
|
|
133
|
+
continue
|
|
134
|
+
try:
|
|
135
|
+
msg = json.loads(line)
|
|
136
|
+
except ValueError:
|
|
137
|
+
resp = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}}
|
|
138
|
+
else:
|
|
139
|
+
resp = handle(index, msg) if isinstance(msg, dict) else {
|
|
140
|
+
"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid request"}}
|
|
141
|
+
if resp is not None:
|
|
142
|
+
stdout.write(json.dumps(resp) + "\n")
|
|
143
|
+
stdout.flush()
|
|
144
|
+
return 0
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Lookups shared by the command-line tool and the MCP server. Each returns plain JSON-ready dicts."""
|
|
2
|
+
import difflib
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
from .api import Index
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NotFound(Exception):
|
|
9
|
+
def __init__(self, message, suggestions=()):
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.suggestions = list(suggestions)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def who_uses(index: Index, name, min_downloads=0, limit=20):
|
|
15
|
+
name = name.strip()
|
|
16
|
+
hit = index.who_uses(name)
|
|
17
|
+
if not hit:
|
|
18
|
+
raise NotFound(
|
|
19
|
+
f"No indexed package references {name}. Names are matched exactly and are case-sensitive.",
|
|
20
|
+
suggest_names(index, name),
|
|
21
|
+
)
|
|
22
|
+
rows = [r for r in hit["rows"] if r[2] >= min_downloads]
|
|
23
|
+
shown = rows if not limit else rows[:limit]
|
|
24
|
+
return {
|
|
25
|
+
"name": name,
|
|
26
|
+
"total": hit["total"],
|
|
27
|
+
"listed": len(hit["rows"]),
|
|
28
|
+
"matching": len(rows),
|
|
29
|
+
# rows are capped and sorted by downloads, so the count is exact when the cap was not hit
|
|
30
|
+
# or the last listed package is already below the threshold
|
|
31
|
+
"matching_exact": hit["total"] == len(hit["rows"]) or (bool(hit["rows"]) and hit["rows"][-1][2] < min_downloads),
|
|
32
|
+
"min_downloads": min_downloads,
|
|
33
|
+
"packages": [
|
|
34
|
+
{"project": r[0], "version": r[1], "downloads_30d": r[2], "files": r[3], "locations": r[4]}
|
|
35
|
+
for r in shown
|
|
36
|
+
],
|
|
37
|
+
"narrower_names": narrower(index, name),
|
|
38
|
+
"url": f"https://py-code.org/search/?q={quote(name)}",
|
|
39
|
+
"index_built": index.status().get("built"),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def project_imports(index: Index, name):
|
|
44
|
+
p = index.project(name.strip())
|
|
45
|
+
if not p:
|
|
46
|
+
raise NotFound(
|
|
47
|
+
f"{name} is not in the index. It covers the latest release of PyPI projects released in the last year."
|
|
48
|
+
)
|
|
49
|
+
return {
|
|
50
|
+
"project": p["name"],
|
|
51
|
+
"version": p["version"],
|
|
52
|
+
"summary": p.get("summary") or "",
|
|
53
|
+
"released": (p.get("upload_time") or "")[:10],
|
|
54
|
+
"requires_python": p.get("requires_python"),
|
|
55
|
+
"downloads_30d": p.get("downloads", 0),
|
|
56
|
+
"file": p.get("file"),
|
|
57
|
+
"py_files": p.get("py_files", 0),
|
|
58
|
+
"py_failed": p.get("py_failed", 0),
|
|
59
|
+
"own_modules": p.get("own_modules", []),
|
|
60
|
+
"third_party_imports": p.get("imports", {}).get("third_party", []),
|
|
61
|
+
"stdlib_imports": p.get("imports", {}).get("stdlib", []),
|
|
62
|
+
"used_by": p.get("used_by_module", {}),
|
|
63
|
+
"removed_or_deprecated_names": p.get("upgrade_hits", []),
|
|
64
|
+
"pth_files_with_import": p.get("pth_with_import", []),
|
|
65
|
+
"url": f"https://py-code.org/projects/view/?name={quote(p['key'])}",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def module_names(index: Index, module, prefix="", limit=50):
|
|
70
|
+
module = module.strip().split(".")[0]
|
|
71
|
+
rows = [r for r in index.names(module) if not prefix or r[0].startswith(prefix)]
|
|
72
|
+
if not rows:
|
|
73
|
+
raise NotFound(f"No indexed package imports the module {module}.", suggest_modules(index, module))
|
|
74
|
+
shown = rows if not limit else rows[:limit]
|
|
75
|
+
return {"module": module, "prefix": prefix, "count": len(rows),
|
|
76
|
+
"names": [{"name": n, "projects": c} for n, c in shown]}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def narrower(index, name, limit=15):
|
|
80
|
+
"""Names one level below `name`, e.g. requests.Session.get under requests.Session."""
|
|
81
|
+
depth = name.count(".") + 1
|
|
82
|
+
return [
|
|
83
|
+
{"name": n, "projects": c}
|
|
84
|
+
for n, c in index.names(name.split(".")[0])
|
|
85
|
+
if n.startswith(name + ".") and n.count(".") == depth
|
|
86
|
+
][:limit]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def suggest_names(index, name, limit=5):
|
|
90
|
+
"""Indexed names that match ignoring case, else the closest spellings under the same module."""
|
|
91
|
+
top = name.split(".")[0]
|
|
92
|
+
names = [n for n, _ in index.names(top)]
|
|
93
|
+
if not names:
|
|
94
|
+
rest = name[len(top):]
|
|
95
|
+
return [m + rest for m in suggest_modules(index, top) if index.who_uses(m + rest)] or suggest_modules(index, top)
|
|
96
|
+
low = name.lower()
|
|
97
|
+
same = [n for n in names if n.lower() == low]
|
|
98
|
+
if same:
|
|
99
|
+
return same[:limit]
|
|
100
|
+
by_lower = {}
|
|
101
|
+
for n in names:
|
|
102
|
+
by_lower.setdefault(n.lower(), n)
|
|
103
|
+
return [by_lower[n] for n in difflib.get_close_matches(low, list(by_lower), n=limit, cutoff=0.6)]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def suggest_modules(index, module):
|
|
107
|
+
"""Try common case variants of a top-level module name (Django vs django, PIL vs pil)."""
|
|
108
|
+
out = []
|
|
109
|
+
for v in dict.fromkeys([module.lower(), module.upper(), module.capitalize()]):
|
|
110
|
+
if v != module and index.names(v):
|
|
111
|
+
out.append(v)
|
|
112
|
+
return out
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Offline tests: a fake index served from memory, laid out like data.py-code.org/api."""
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import unittest
|
|
6
|
+
import zlib
|
|
7
|
+
from contextlib import redirect_stdout
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
11
|
+
|
|
12
|
+
from py_code_org import api, cli, mcp, queries # noqa: E402
|
|
13
|
+
|
|
14
|
+
IDX, PROJ = 16, 32
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def crc(s):
|
|
18
|
+
return zlib.crc32(s.encode())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fake_files():
|
|
22
|
+
rows = [["big-user", "1.0", 5000, 2, ["a.py:1"]], ["small-user", "2.0", 10, 1, ["b.py:3"]]]
|
|
23
|
+
files = {
|
|
24
|
+
"/manifest.json": {"idx_shards": IDX, "project_shards": PROJ, "big": {"bigmod": 2}, "row_cap": 2},
|
|
25
|
+
"/status.json": {"built": "2026-11-13T13:11+00:00"},
|
|
26
|
+
f"/idx/{crc('requests') % IDX:03x}.json": {"requests": {
|
|
27
|
+
"requests.Session": {"total": 3, "rows": rows},
|
|
28
|
+
}},
|
|
29
|
+
f"/names/{crc('requests') % IDX:03x}.json": {"requests": [
|
|
30
|
+
["requests", 10], ["requests.Session", 3], ["requests.Session.get", 2], ["requests.Session.get.x", 1]]},
|
|
31
|
+
f"/big/bigmod/{crc('bigmod.thing') % 2}.json": {"bigmod.thing": {"total": 1, "rows": rows[:1]}},
|
|
32
|
+
f"/project/{crc('my-pkg') % PROJ:04x}.json": {"my-pkg": {
|
|
33
|
+
"key": "my-pkg", "name": "My_Pkg", "version": "1.2", "summary": "s", "downloads": 7,
|
|
34
|
+
"file": "my_pkg-1.2.whl", "upload_time": "2026-01-02T00:00:00Z", "requires_python": ">=3.9",
|
|
35
|
+
"py_files": 3, "py_failed": 0, "own_modules": ["my_pkg"], "pth_with_import": [],
|
|
36
|
+
"imports": {"stdlib": ["os"], "third_party": ["requests"]}, "upgrade_hits": [],
|
|
37
|
+
"used_by_module": {"my_pkg": 4}, "primary_module": "my_pkg", "used_by": 4}},
|
|
38
|
+
}
|
|
39
|
+
return files
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FakeIndex(api.Index):
|
|
43
|
+
def __init__(self):
|
|
44
|
+
super().__init__(base="https://example.invalid/api")
|
|
45
|
+
self.files = fake_files()
|
|
46
|
+
|
|
47
|
+
def get(self, path):
|
|
48
|
+
return self.files.get(path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Lookups(unittest.TestCase):
|
|
52
|
+
def setUp(self):
|
|
53
|
+
self.index = FakeIndex()
|
|
54
|
+
|
|
55
|
+
def test_crc_matches_the_site(self):
|
|
56
|
+
# Values computed by the crc32 function in the site's app.js.
|
|
57
|
+
self.assertEqual(api.crc32("requests"), 2072368721)
|
|
58
|
+
self.assertEqual(api.crc32("numpy.linalg.norm"), 2828900854)
|
|
59
|
+
self.assertEqual(api.crc32("café.Ünïcode"), 3598506173)
|
|
60
|
+
self.assertEqual(api.norm("My_Pkg.Name"), "my-pkg-name")
|
|
61
|
+
|
|
62
|
+
def test_who_uses_small_module(self):
|
|
63
|
+
r = queries.who_uses(self.index, "requests.Session")
|
|
64
|
+
self.assertEqual(r["total"], 3)
|
|
65
|
+
self.assertEqual([p["project"] for p in r["packages"]], ["big-user", "small-user"])
|
|
66
|
+
self.assertEqual(r["narrower_names"], [{"name": "requests.Session.get", "projects": 2}])
|
|
67
|
+
|
|
68
|
+
def test_who_uses_big_module(self):
|
|
69
|
+
self.assertEqual(queries.who_uses(self.index, "bigmod.thing")["total"], 1)
|
|
70
|
+
|
|
71
|
+
def test_min_downloads_and_exactness(self):
|
|
72
|
+
r = queries.who_uses(self.index, "requests.Session", min_downloads=100)
|
|
73
|
+
self.assertEqual(r["matching"], 1)
|
|
74
|
+
self.assertTrue(r["matching_exact"]) # last listed row is below the threshold
|
|
75
|
+
r = queries.who_uses(self.index, "requests.Session", min_downloads=1)
|
|
76
|
+
self.assertFalse(r["matching_exact"]) # capped list, every row above the threshold
|
|
77
|
+
|
|
78
|
+
def test_not_found_suggests_case(self):
|
|
79
|
+
with self.assertRaises(queries.NotFound) as cm:
|
|
80
|
+
queries.who_uses(self.index, "requests.session")
|
|
81
|
+
self.assertIn("requests.Session", cm.exception.suggestions)
|
|
82
|
+
|
|
83
|
+
def test_project_by_any_spelling(self):
|
|
84
|
+
self.assertEqual(queries.project_imports(self.index, "MY.pkg")["project"], "My_Pkg")
|
|
85
|
+
with self.assertRaises(queries.NotFound):
|
|
86
|
+
queries.project_imports(self.index, "nope")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Cli(unittest.TestCase):
|
|
90
|
+
def run_cli(self, *argv):
|
|
91
|
+
out = io.StringIO()
|
|
92
|
+
orig = cli.Index
|
|
93
|
+
cli.Index = FakeIndex
|
|
94
|
+
try:
|
|
95
|
+
with redirect_stdout(out):
|
|
96
|
+
code = cli.main(list(argv))
|
|
97
|
+
finally:
|
|
98
|
+
cli.Index = orig
|
|
99
|
+
return code, out.getvalue()
|
|
100
|
+
|
|
101
|
+
def test_table_and_json(self):
|
|
102
|
+
code, out = self.run_cli("who-uses", "requests.Session", "--limit", "1")
|
|
103
|
+
self.assertEqual(code, 0)
|
|
104
|
+
self.assertIn("3 packages use requests.Session", out)
|
|
105
|
+
self.assertIn("Showing 1 of 3", out)
|
|
106
|
+
code, out = self.run_cli("project", "my-pkg", "--json")
|
|
107
|
+
self.assertEqual(json.loads(out)["third_party_imports"], ["requests"])
|
|
108
|
+
|
|
109
|
+
def test_not_found_exit_code(self):
|
|
110
|
+
code, out = self.run_cli("who-uses", "requests.session", "--json")
|
|
111
|
+
self.assertEqual(code, 1)
|
|
112
|
+
self.assertEqual(json.loads(out)["did_you_mean"], ["requests.Session"])
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Mcp(unittest.TestCase):
|
|
116
|
+
def talk(self, *msgs):
|
|
117
|
+
orig = mcp.Index
|
|
118
|
+
mcp.Index = FakeIndex
|
|
119
|
+
out = io.StringIO()
|
|
120
|
+
try:
|
|
121
|
+
mcp.serve(io.StringIO("\n".join(json.dumps(m) for m in msgs) + "\n"), out)
|
|
122
|
+
finally:
|
|
123
|
+
mcp.Index = orig
|
|
124
|
+
return [json.loads(line) for line in out.getvalue().splitlines()]
|
|
125
|
+
|
|
126
|
+
def test_session(self):
|
|
127
|
+
replies = self.talk(
|
|
128
|
+
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}},
|
|
129
|
+
{"jsonrpc": "2.0", "method": "notifications/initialized"},
|
|
130
|
+
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
|
|
131
|
+
{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "who_uses", "arguments": {"name": "requests.Session"}}},
|
|
132
|
+
{"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "project_imports", "arguments": {"project": "zzz"}}},
|
|
133
|
+
{"jsonrpc": "2.0", "id": 5, "method": "unknown"},
|
|
134
|
+
)
|
|
135
|
+
self.assertEqual([r["id"] for r in replies], [1, 2, 3, 4, 5])
|
|
136
|
+
self.assertEqual(replies[0]["result"]["protocolVersion"], "2025-06-18")
|
|
137
|
+
self.assertEqual({t["name"] for t in replies[1]["result"]["tools"]}, {"who_uses", "project_imports", "module_names"})
|
|
138
|
+
self.assertEqual(replies[2]["result"]["structuredContent"]["total"], 3)
|
|
139
|
+
self.assertTrue(replies[3]["result"]["isError"])
|
|
140
|
+
self.assertEqual(replies[4]["error"]["code"], -32601)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
unittest.main()
|