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.
@@ -0,0 +1,5 @@
1
+ data/
2
+ dist/
3
+ __pycache__/
4
+ *.pyc
5
+ .deploy.env
@@ -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,2 @@
1
+ """Command-line tool and MCP server for py-code.org: which PyPI packages use a module, function or class."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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()