pyrefdev 2025.6__tar.gz → 2025.8__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.
Files changed (125) hide show
  1. {pyrefdev-2025.6 → pyrefdev-2025.8}/CHANGELOG.md +15 -0
  2. {pyrefdev-2025.6 → pyrefdev-2025.8}/CLAUDE.md +4 -1
  3. {pyrefdev-2025.6 → pyrefdev-2025.8}/PKG-INFO +14 -2
  4. {pyrefdev-2025.6 → pyrefdev-2025.8}/README.md +13 -1
  5. pyrefdev-2025.8/src/pyrefdev/_version.py +1 -0
  6. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/config.py +2 -1
  7. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/indexer/__main__.py +4 -0
  8. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/indexer/add_docs.py +20 -12
  9. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/indexer/crawl_docs.py +18 -29
  10. pyrefdev-2025.8/src/pyrefdev/indexer/crawl_pypi.py +27 -0
  11. pyrefdev-2025.8/src/pyrefdev/indexer/index.py +171 -0
  12. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/indexer/parse_docs.py +35 -21
  13. pyrefdev-2025.8/src/pyrefdev/indexer/parse_pypi.py +14 -0
  14. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/indexer/update_docs.py +6 -8
  15. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/indexer/update_landing_page.py +1 -0
  16. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/__init__.py +17 -4
  17. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/__python__.py +2 -1
  18. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/aiohttp.py +2 -1
  19. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/aioitertools.py +2 -1
  20. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/aiosignal.py +2 -1
  21. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/anyio.py +2 -1
  22. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/apache-beam.py +2 -1
  23. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/arrow.py +2 -1
  24. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/attrs.py +2 -1
  25. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/azure-identity.py +2 -1
  26. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/boto3.py +2 -1
  27. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/botocore.py +2 -1
  28. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/cachetools.py +2 -1
  29. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/cattrs.py +2 -1
  30. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/charset-normalizer.py +2 -1
  31. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/click.py +2 -1
  32. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/coverage.py +2 -1
  33. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/cryptography.py +2 -1
  34. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/cyclopts.py +2 -1
  35. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/distlib.py +2 -1
  36. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/fastapi.py +2 -1
  37. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/fastjsonschema.py +2 -1
  38. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/filelock.py +2 -1
  39. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/flask.py +2 -1
  40. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/frozenlist.py +2 -1
  41. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/fsspec.py +2 -1
  42. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/google-api-core.py +2 -1
  43. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/google-auth.py +2 -1
  44. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/grpcio.py +2 -1
  45. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/h11.py +2 -1
  46. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/h5py.py +2 -1
  47. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/httplib2.py +2 -1
  48. pyrefdev-2025.8/src/pyrefdev/mapping/ipython.py +1480 -0
  49. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/jinja2.py +2 -1
  50. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/jsonschema.py +2 -1
  51. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/jupyter-core.py +2 -1
  52. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/libcst.py +2 -1
  53. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/markupsafe.py +2 -1
  54. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/matplotlib.py +2 -1
  55. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/more-itertools.py +2 -1
  56. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/multidict.py +2 -1
  57. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/nbformat.py +2 -1
  58. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/networkx.py +2 -1
  59. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/numba.py +2 -1
  60. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/numpy.py +2 -1
  61. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/oauthlib.py +2 -1
  62. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/packaging.py +2 -1
  63. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/pandas.py +2 -1
  64. pyrefdev-2025.8/src/pyrefdev/mapping/passlib.py +411 -0
  65. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/pathspec.py +2 -1
  66. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/pillow.py +2 -1
  67. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/platformdirs.py +2 -1
  68. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/pluggy.py +2 -1
  69. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/propcache.py +2 -1
  70. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/protobuf.py +2 -1
  71. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/psutil.py +2 -1
  72. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/pyOpenSSL.py +2 -1
  73. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/pyarrow.py +2 -1
  74. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/pyasn1.py +2 -1
  75. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/pydantic-core.py +2 -1
  76. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/pydantic.py +2 -1
  77. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/pygments.py +2 -1
  78. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/pyjwt.py +2 -1
  79. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/pyparsing.py +2 -1
  80. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/pytest.py +2 -1
  81. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/python-dateutil.py +2 -1
  82. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/requests-oauthlib.py +2 -1
  83. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/requests-toolbelt.py +2 -1
  84. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/requests.py +2 -1
  85. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/rich.py +2 -1
  86. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/rpds-py.py +2 -1
  87. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/s3fs.py +2 -1
  88. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/scikit-learn.py +2 -1
  89. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/scipy.py +2 -1
  90. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/semver.py +2 -1
  91. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/setuptools.py +2 -1
  92. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/six.py +2 -1
  93. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/sniffio.py +2 -1
  94. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/sqlalchemy.py +2 -1
  95. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/tomlkit.py +2 -1
  96. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/torch.py +1182 -1181
  97. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/tox.py +2 -1
  98. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/trio.py +2 -1
  99. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/typing-extensions.py +2 -1
  100. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/urllib3.py +2 -1
  101. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/werkzeug.py +2 -1
  102. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/wrapt.py +2 -1
  103. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/yarl.py +2 -1
  104. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/mapping/yib.py +2 -1
  105. pyrefdev-2025.8/src/pyrefdev/server.py +126 -0
  106. pyrefdev-2025.8/static/styles.css +693 -0
  107. {pyrefdev-2025.6 → pyrefdev-2025.8}/templates/index.html +30 -415
  108. pyrefdev-2025.8/templates/search.html +152 -0
  109. {pyrefdev-2025.6 → pyrefdev-2025.8}/tests/test_pyrefdev.py +3 -1
  110. pyrefdev-2025.6/src/pyrefdev/_version.py +0 -1
  111. pyrefdev-2025.6/src/pyrefdev/indexer/requests.py +0 -54
  112. pyrefdev-2025.6/src/pyrefdev/indexer/schema.py +0 -16
  113. pyrefdev-2025.6/src/pyrefdev/mapping/ipython.py +0 -5
  114. pyrefdev-2025.6/src/pyrefdev/server.py +0 -35
  115. {pyrefdev-2025.6 → pyrefdev-2025.8}/.github/workflows/ci.yml +0 -0
  116. {pyrefdev-2025.6 → pyrefdev-2025.8}/.github/workflows/pypi_upload.yml +0 -0
  117. {pyrefdev-2025.6 → pyrefdev-2025.8}/.gitignore +0 -0
  118. {pyrefdev-2025.6 → pyrefdev-2025.8}/.pre-commit-config.yaml +0 -0
  119. {pyrefdev-2025.6 → pyrefdev-2025.8}/LICENSE +0 -0
  120. {pyrefdev-2025.6 → pyrefdev-2025.8}/pyproject.toml +0 -0
  121. {pyrefdev-2025.6 → pyrefdev-2025.8}/pyrefdev.service +0 -0
  122. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/__init__.py +0 -0
  123. {pyrefdev-2025.6 → pyrefdev-2025.8}/src/pyrefdev/__main__.py +0 -0
  124. {pyrefdev-2025.6 → pyrefdev-2025.8}/static/logo128.png +0 -0
  125. {pyrefdev-2025.6 → pyrefdev-2025.8}/uv.lock +0 -0
@@ -4,6 +4,21 @@
4
4
 
5
5
  Nothing noticeable unreleased.
6
6
 
7
+ ## v2025.8
8
+
9
+ - When no symbol is specified, the lucky button now redirects to a random API reference URL.
10
+ - Support [passlib](https://passlib.readthedocs.io/en/stable/).
11
+ - Updated certain torch URLs with more detailed docs.
12
+ - Fixed symbols for [ipython](https://ipython.readthedocs.io/en/stable/).
13
+ - Indexer: in the `add-docs` command, guess the API docs based on PyPI package information when `--index-url` is not specified.
14
+ - Indexer: add a `crawl-pypi` command to cache the PyPI package information.
15
+ - Indexer: add a `parse-pypi` command to parse cached PyPI package information and update the config when API docs are found.
16
+
17
+ ## v2025.7
18
+
19
+ - Added search to [pyref.dev](https://pyref.dev).
20
+ - When the symbol is not found, redirect to the search page.
21
+
7
22
  ## v2025.6
8
23
 
9
24
  - Added a `-p/--print` flag to the `pyrefdev` CLI to print the URL instead of opening it in the browser.
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
6
6
 
7
7
  The project has several components:
8
8
 
9
- 1. **Web Server** (`src/pyrefdev/server.py`): FastAPI application that serves redirects based on symbol lookup. `index.html` is the landing page of the server
9
+ 1. **Web Server** (`src/pyrefdev/server.py`): FastAPI application that serves redirects based on symbol lookup. `templates/index.html` is the landing page of the server, `static/styles.css` contains CSS styles
10
10
  2. **CLI Tool** (`src/pyrefdev/__main__.py`): Command-line interface that opens documentation in browser
11
11
  3. **Indexer** (`src/pyrefdev/indexer/`): Tools for crawling, parsing, and managing documentation mappings
12
12
  4. **Mapping System** (`src/pyrefdev/mapping/`): Individual Python files per package containing symbol-to-URL mappings
@@ -48,4 +48,7 @@ pyrefdev-indexer update-landing-page
48
48
 
49
49
  ## Important Notes
50
50
 
51
+ - Do not add tests unless explictly asked to.
52
+ - When creating a new file with content, ensure the file has an extra new line at the end.
53
+ - Do NOT add redundant comments describe what the cod does. When needed, DO add comments that explains "why".
51
54
  - Server deployment uses systemd and is configured in `pyrefdev.service`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyrefdev
3
- Version: 2025.6
3
+ Version: 2025.8
4
4
  Summary: The entry point to Python reference docs
5
5
  Project-URL: Homepage, https://pyref.dev
6
6
  Project-URL: Source, https://github.com/mangoumbrella/pyref.dev
@@ -51,7 +51,19 @@ It allows you to quickly jump to the official documentation for Python standard
51
51
  https://pyref.dev/<fully.qualified.symbol.name>
52
52
  ```
53
53
 
54
- Alternatively, you could also `pip install pyrefdev` and run the `pyrefdev` CLI tool.
54
+ You can also search for symbols using:
55
+
56
+ ```
57
+ https://pyref.dev/is?symbol=<SYMBOL>
58
+ ```
59
+
60
+ And if you are feeling lucky, ask it to redirect to the first result:
61
+
62
+ ```
63
+ https://pyref.dev/is?lucky=true&symbol=<SYMBOL>
64
+ ```
65
+
66
+ Lastly, you can `pip install pyrefdev` and run the `pyrefdev` CLI tool.
55
67
 
56
68
  ## Examples
57
69
 
@@ -14,7 +14,19 @@ It allows you to quickly jump to the official documentation for Python standard
14
14
  https://pyref.dev/<fully.qualified.symbol.name>
15
15
  ```
16
16
 
17
- Alternatively, you could also `pip install pyrefdev` and run the `pyrefdev` CLI tool.
17
+ You can also search for symbols using:
18
+
19
+ ```
20
+ https://pyref.dev/is?symbol=<SYMBOL>
21
+ ```
22
+
23
+ And if you are feeling lucky, ask it to redirect to the first result:
24
+
25
+ ```
26
+ https://pyref.dev/is?lucky=true&symbol=<SYMBOL>
27
+ ```
28
+
29
+ Lastly, you can `pip install pyrefdev` and run the `pyrefdev` CLI tool.
18
30
 
19
31
  ## Examples
20
32
 
@@ -0,0 +1 @@
1
+ version = "2025.8"
@@ -113,10 +113,11 @@ _packages = [
113
113
  Package(pypi="coverage", index_url="https://coverage.readthedocs.io/en/latest/"),
114
114
  Package(pypi="matplotlib", index_url="https://matplotlib.org/stable/api/index.html"),
115
115
  Package(pypi="networkx", index_url="https://networkx.org/documentation/stable/reference/index.html"),
116
- Package(pypi="ipython", index_url="https://ipython.readthedocs.io/en/stable/"),
116
+ Package(pypi="ipython", index_url="https://ipython.readthedocs.io/en/stable/", namespaces=["IPython"]),
117
117
  Package(pypi="httplib2", index_url="https://httplib2.readthedocs.io/en/latest/"),
118
118
  Package(pypi="cyclopts", index_url="https://cyclopts.readthedocs.io/en/latest/"),
119
119
  Package(pypi="yib", index_url="https://yib.readthedocs.io/en/latest/"),
120
+ Package(pypi="passlib", index_url="https://passlib.readthedocs.io/en/stable/"),
120
121
  # ENTRY-LINE-MARKER
121
122
  ]
122
123
  # fmt: on
@@ -3,7 +3,9 @@ import cyclopts
3
3
  import pyrefdev
4
4
  from pyrefdev.indexer.add_docs import add_docs
5
5
  from pyrefdev.indexer.crawl_docs import crawl_docs
6
+ from pyrefdev.indexer.crawl_pypi import crawl_pypi
6
7
  from pyrefdev.indexer.parse_docs import parse_docs
8
+ from pyrefdev.indexer.parse_pypi import parse_pypi
7
9
  from pyrefdev.indexer.update_docs import update_docs
8
10
  from pyrefdev.indexer.update_landing_page import update_landing_page
9
11
  from pyrefdev.config import console
@@ -17,7 +19,9 @@ app = cyclopts.App(
17
19
  )
18
20
  app.command(add_docs)
19
21
  app.command(crawl_docs)
22
+ app.command(crawl_pypi)
20
23
  app.command(parse_docs)
24
+ app.command(parse_pypi)
21
25
  app.command(update_docs)
22
26
  app.command(update_landing_page)
23
27
 
@@ -4,6 +4,7 @@ import re
4
4
 
5
5
  from pyrefdev import config
6
6
  from pyrefdev.config import console
7
+ from pyrefdev.indexer.index import Index
7
8
  from pyrefdev.indexer.update_docs import update_docs
8
9
  from pyrefdev.indexer.update_landing_page import update_landing_page_with_packages
9
10
 
@@ -14,15 +15,31 @@ _MARKER = re.compile("(\n.*ENTRY-LINE-MARKER.*\n)")
14
15
  def add_docs(
15
16
  *,
16
17
  package: str,
17
- docs_directory: Path | None = None,
18
- url: str,
18
+ url: str | None = None,
19
19
  namespaces: list[str] | None = None,
20
20
  crawl: bool = True,
21
- num_threads_per_package: int | None = None,
21
+ index: Index = Index(),
22
+ num_threads_per_package: int = 1,
22
23
  ) -> None:
24
+ """Add a new package."""
23
25
  if package in config.SUPPORTED_PACKAGES:
24
26
  console.fatal(f"Package exists: {package}")
25
27
 
28
+ if url is None:
29
+ url = index.guess_index_url(package, should_die_if_not_found=True)
30
+ update_config(package, url, namespaces)
31
+
32
+ if crawl:
33
+ importlib.reload(config)
34
+ update_docs(
35
+ index=index,
36
+ package=package,
37
+ num_threads_per_package=num_threads_per_package,
38
+ )
39
+ update_landing_page_with_packages(config.SUPPORTED_PACKAGES)
40
+
41
+
42
+ def update_config(package: str, url: str, namespaces: list[str] | None = None) -> None:
26
43
  if namespaces:
27
44
  ns_content = ", ".join(f'"{ns}"' for ns in namespaces)
28
45
  ns_str = f", namespaces=[{ns_content}]"
@@ -33,12 +50,3 @@ def add_docs(
33
50
  config_content = config_file.read_text()
34
51
  config_content = _MARKER.sub(config_entry + r"\g<1>", config_content)
35
52
  config_file.write_text(config_content)
36
-
37
- if crawl:
38
- importlib.reload(config)
39
- update_docs(
40
- docs_directory=docs_directory,
41
- package=package,
42
- num_threads_per_package=num_threads_per_package,
43
- )
44
- update_landing_page_with_packages(config.SUPPORTED_PACKAGES)
@@ -1,7 +1,6 @@
1
1
  from concurrent import futures
2
2
  from pathlib import Path
3
3
  import queue
4
- import tempfile
5
4
  import threading
6
5
  from urllib import error, parse
7
6
 
@@ -10,53 +9,43 @@ from packaging import version
10
9
  from rich.progress import Progress, TaskID
11
10
 
12
11
  from pyrefdev.config import console, get_packages, Package
13
- from pyrefdev.indexer.requests import fetch_package_version, urlopen
14
- from pyrefdev.indexer.schema import CrawlState
12
+ from pyrefdev.indexer.index import Index, IndexState, urlopen
15
13
 
16
14
 
17
15
  def crawl_docs(
18
16
  *,
19
17
  package: str | None = None,
20
- docs_directory: Path | None = None,
21
18
  force: bool = False,
19
+ index: Index = Index(),
22
20
  num_parallel_packages: int = 2,
23
- num_threads_per_package: int | None = None,
21
+ num_threads_per_package: int = 1,
24
22
  ) -> None:
25
23
  """Crawl the docs into a local directory."""
26
24
  if num_parallel_packages <= 0:
27
25
  raise ValueError(
28
26
  f"--num-parallel-packages must be > 0, found {num_parallel_packages}"
29
27
  )
30
- if num_threads_per_package is None:
31
- num_threads_per_package = 2
32
28
  if num_threads_per_package <= 0:
33
29
  raise ValueError(
34
30
  f"--num-threads-per-package must be > 0, found {num_threads_per_package}"
35
31
  )
36
32
 
37
- if docs_directory:
38
- if docs_directory.exists():
39
- if not docs_directory.is_dir():
40
- console.fatal(f"{docs_directory} is not a directory")
41
- else:
42
- docs_directory = Path(tempfile.mkdtemp(prefix="pyref.dev."))
43
-
44
- console.print(f"Crawling documents into {docs_directory}")
33
+ console.print(f"Crawling documents into {index.docs_directory}")
45
34
  packages = get_packages(package)
46
35
  with Progress(console=console) as progress:
47
36
  task = progress.add_task(
48
37
  f"Crawling {len(packages)} packages", total=len(packages)
49
38
  )
39
+
50
40
  def crawl_package(pkg: Package):
51
41
  try:
52
- package_version = fetch_package_version(pkg)
42
+ package_version = index.fetch_package_version(pkg)
53
43
  if package_version is None:
54
44
  return
55
- subdir = docs_directory / pkg.pypi
56
- subdir.mkdir(parents=True, exist_ok=True)
57
- crawl_state_file = docs_directory / f"{pkg.pypi}.json"
58
- if not force and crawl_state_file.exists():
59
- crawl_state = CrawlState.loads(crawl_state_file.read_text())
45
+ if (
46
+ not force
47
+ and (crawl_state := index.load_crawl_state(pkg.pypi)) is not None
48
+ ):
60
49
  crawled_version = version.parse(crawl_state.package_version)
61
50
  if package_version > crawled_version:
62
51
  console.print(
@@ -72,12 +61,12 @@ def crawl_docs(
72
61
  crawler = _Crawler(
73
62
  pkg,
74
63
  progress,
75
- docs_directory / pkg.pypi,
64
+ index,
76
65
  pkg.index_url,
77
66
  crawl_state,
78
67
  )
79
68
  crawler.crawl(num_threads=num_threads_per_package)
80
- crawler.save_crawl_state(package_version, crawl_state_file)
69
+ crawler.save_crawl_state(package_version, index)
81
70
  finally:
82
71
  progress.advance(task)
83
72
 
@@ -92,13 +81,13 @@ class _Crawler:
92
81
  self,
93
82
  package: Package,
94
83
  progress: Progress,
95
- docs_directory: Path,
84
+ index: Index,
96
85
  root_url: str,
97
- crawl_state: CrawlState | None,
86
+ crawl_state: IndexState | None,
98
87
  ):
99
88
  self._package = package
100
89
  self._progress = progress
101
- self._docs_directory = docs_directory
90
+ self._docs_directory = index.docs_directory / package.pypi
102
91
  self._root_url = root_url
103
92
  self._prefix = (
104
93
  root_url
@@ -162,18 +151,18 @@ class _Crawler:
162
151
  ] = url
163
152
  self._crawl_state.failed_urls = failed_urls
164
153
 
165
- def save_crawl_state(self, package_version: version.Version, output: Path) -> None:
154
+ def save_crawl_state(self, package_version: version.Version, index: Index) -> None:
166
155
  if (state := self._crawl_state) is None:
167
156
  file_to_urls = {
168
157
  str(file.relative_to(self._docs_directory)): url
169
158
  for url, file in self._crawled_url_to_files.items()
170
159
  }
171
- state = CrawlState(
160
+ state = IndexState(
172
161
  package_version=str(package_version),
173
162
  file_to_urls=file_to_urls,
174
163
  failed_urls=self._failed_urls,
175
164
  )
176
- output.write_text(state.dumps())
165
+ index.save_crawl_state(self._package.pypi, state)
177
166
 
178
167
  def _crawl_thread(self, task: TaskID) -> None:
179
168
  while True:
@@ -0,0 +1,27 @@
1
+ import json
2
+ from urllib.error import HTTPError
3
+
4
+ from rich.progress import track
5
+
6
+ from pyrefdev.config import console
7
+ from pyrefdev.indexer.index import Index, urlopen
8
+
9
+
10
+ def crawl_pypi(*, refresh: bool = True, index: Index = Index()) -> None:
11
+ """Crawl top 15000 PyPI packages."""
12
+ with urlopen(
13
+ "https://hugovk.github.io/top-pypi-packages/top-pypi-packages.min.json"
14
+ ) as f:
15
+ top_packages_data = json.load(f)
16
+ rows = top_packages_data["rows"]
17
+ for row in track(
18
+ rows, description=f"Reading {len(rows)} packages", console=console
19
+ ):
20
+ package_name = row["project"]
21
+ try:
22
+ index.fetch_pypi_data(package_name, refresh=refresh)
23
+ except HTTPError as e:
24
+ if e.code == 404:
25
+ console.warning(f"Package {package_name} not found")
26
+ continue
27
+ raise
@@ -0,0 +1,171 @@
1
+ import dataclasses
2
+ import json
3
+ import re
4
+ import random
5
+ import subprocess
6
+ import tempfile
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Literal, overload
10
+ from urllib import error, request
11
+
12
+ from packaging import version
13
+ from cyclopts import Parameter
14
+
15
+ from pyrefdev import __version__
16
+ from pyrefdev.config import Package, console
17
+
18
+
19
+ _RTD_URL_PATTERN = re.compile(r"https?://([^\s/]+\.readthedocs\.io)\b")
20
+
21
+
22
+ def urlopen(url: str):
23
+ req = request.Request(
24
+ url,
25
+ method="GET",
26
+ headers={"User-Agent": f"pyrefdev/{__version__} (+https://pyref.dev)"},
27
+ )
28
+ backoffs = [1, 2, 5, 15, 30, 60]
29
+ while True:
30
+ try:
31
+ return request.urlopen(req, timeout=60)
32
+ except error.HTTPError as e:
33
+ if e.code == 429: # Too Many Request
34
+ if not backoffs:
35
+ raise
36
+ backoff = backoffs.pop(0) * (0.9 + random.random() / 5.0)
37
+ time.sleep(backoff)
38
+ else:
39
+ raise
40
+
41
+
42
+ @dataclasses.dataclass
43
+ class IndexState:
44
+ package_version: str
45
+ file_to_urls: dict[str, str]
46
+ failed_urls: list[str]
47
+
48
+ @classmethod
49
+ def loads(cls, content: str) -> "IndexState":
50
+ return cls(**json.loads(content))
51
+
52
+ def dumps(self) -> str:
53
+ return json.dumps(dataclasses.asdict(self))
54
+
55
+
56
+ def _get_default_api_docs_directory() -> Path:
57
+ cwd = Path(__file__).parent
58
+ git_root = subprocess.check_output(
59
+ ["git", "rev-parse", "--show-toplevel"], cwd=cwd, text=True
60
+ ).strip()
61
+ git_remote = subprocess.check_output(
62
+ ["git", "remote", "get-url", "origin"], cwd=git_root, text=True
63
+ ).strip()
64
+ if "/pyref.dev" in git_remote:
65
+ return Path(git_root) / "api-docs"
66
+ else:
67
+ directory = Path(tempfile.mkdtemp(prefix="pyref.dev."))
68
+ console.print(f"Using temporary directory for API docs: {directory}")
69
+ return directory
70
+
71
+
72
+ @Parameter(name="*")
73
+ @dataclasses.dataclass
74
+ class Index:
75
+ docs_directory: Path = dataclasses.field(
76
+ default_factory=_get_default_api_docs_directory
77
+ )
78
+
79
+ def _ensure_directory(self) -> None:
80
+ self.docs_directory.mkdir(parents=True, exist_ok=True)
81
+ (self.docs_directory / "__pypi__").mkdir(parents=True, exist_ok=True)
82
+
83
+ def load_crawl_state(self, package: str) -> IndexState | None:
84
+ self._ensure_directory()
85
+ crawl_state_file = self.docs_directory / f"{package}.json"
86
+ if not crawl_state_file.exists():
87
+ return None
88
+ return IndexState.loads(crawl_state_file.read_text())
89
+
90
+ def save_crawl_state(self, package: str, crawl_state: IndexState) -> None:
91
+ self._ensure_directory()
92
+ crawl_state_file = self.docs_directory / f"{package}.json"
93
+ crawl_state_file.write_text(crawl_state.dumps())
94
+
95
+ def fetch_pypi_data(self, package: str, *, refresh: bool) -> bytes:
96
+ pypi_data_file = self.docs_directory / "__pypi__" / f"{package}.json"
97
+ if not refresh and pypi_data_file.exists():
98
+ return pypi_data_file.read_bytes()
99
+ with urlopen(f"https://pypi.org/pypi/{package}/json") as f:
100
+ data = f.read()
101
+ self._ensure_directory()
102
+ pypi_data_file.write_bytes(data)
103
+ return data
104
+
105
+ def get_pypi_packages(self) -> list[str]:
106
+ return list(f.stem for f in self.docs_directory.glob("__pypi__/*.json"))
107
+
108
+ def fetch_package_version(self, package: Package) -> version.Version | None:
109
+ if package.is_cpython():
110
+ return _fetch_latest_cpython_version()
111
+ try:
112
+ data = self.fetch_pypi_data(package.pypi, refresh=True)
113
+ pypi_info = json.loads(data)
114
+ return version.parse(pypi_info["info"]["version"])
115
+ except error.URLError as e:
116
+ console.warning(
117
+ f"Failed to fetch pypi version for {package.pypi}, error: {e}"
118
+ )
119
+ return None
120
+
121
+ @overload
122
+ def guess_index_url(
123
+ self, package: str, *, should_die_if_not_found: Literal[True]
124
+ ) -> str: ...
125
+ def guess_index_url(
126
+ self, package: str, *, should_die_if_not_found: bool
127
+ ) -> str | None:
128
+ data = self.fetch_pypi_data(package, refresh=False)
129
+ pypi_info = json.loads(data).get("info", {})
130
+ candidates = list((pypi_info.get("project_urls") or {}).values())
131
+ candidates.append(pypi_info.get("description", ""))
132
+
133
+ readthedocs_urls = set()
134
+
135
+ for url in candidates:
136
+ for match in _RTD_URL_PATTERN.findall(url):
137
+ readthedocs_urls.add(f"https://{match}")
138
+
139
+ if len(readthedocs_urls) == 1:
140
+ url = next(iter(readthedocs_urls))
141
+ try:
142
+ with urlopen(url) as f:
143
+ url = f.url # Maybe redirected URL.
144
+ return url
145
+ except error.URLError as e:
146
+ console.warning(f"Failed to fetch {url}, error: {e}")
147
+ readthedocs_urls = []
148
+
149
+ msg_fn = console.fatal if should_die_if_not_found else console.warning
150
+ if len(readthedocs_urls) == 0:
151
+ msg_fn(f"No readthedocs.io URLs found for package: {package}")
152
+ else:
153
+ msg_fn(
154
+ f"Multiple readthedocs.io URLs found for package: {package}. URLs:\n"
155
+ + "\n".join(readthedocs_urls)
156
+ )
157
+
158
+
159
+ def _fetch_latest_cpython_version() -> version.Version | None:
160
+ try:
161
+ with urlopen("https://endoflife.date/api/python.json") as f:
162
+ content = f.read().decode("utf-8")
163
+ latest_version = version.parse("3.13.5") # Known version as of 2025-06-28
164
+ cycles = json.loads(content)
165
+ for cycle in cycles:
166
+ if (latest := version.parse(cycle["latest"])) > latest_version:
167
+ latest_version = latest
168
+ return latest_version
169
+ except error.URLError as e:
170
+ console.warning(f"Failed to fetch latest CPython version, error: {e}")
171
+ return None
@@ -8,14 +8,14 @@ from pathlib import Path
8
8
  import re
9
9
  import sys
10
10
  import threading
11
- from urllib import parse
11
+ from urllib.parse import urlparse, urlunparse
12
12
 
13
13
  import bs4
14
14
  from rich.progress import Progress
15
15
 
16
16
  from pyrefdev import mapping
17
17
  from pyrefdev.config import console, get_packages, Package
18
- from pyrefdev.indexer.schema import CrawlState
18
+ from pyrefdev.indexer.index import Index
19
19
 
20
20
 
21
21
  _STDLIB_MODULES_NAMES = frozenset({*sys.stdlib_module_names, "test"})
@@ -65,11 +65,12 @@ class ProgressExecutor(futures.ThreadPoolExecutor):
65
65
  def parse_docs(
66
66
  *,
67
67
  package: str | None = None,
68
- docs_directory: Path,
69
68
  in_place: bool = False,
69
+ index: Index = Index(),
70
70
  num_parallel_packages: int = multiprocessing.cpu_count(),
71
71
  num_threads_per_package: int = multiprocessing.cpu_count(),
72
72
  ) -> None:
73
+ """Parse crawled docs and update the mapping files."""
73
74
  if sys.version_info[:2] != (3, 13):
74
75
  console.fatal("pyrefdev-indexer parse_docs must be run on Python 3.13.")
75
76
 
@@ -88,7 +89,7 @@ def parse_docs(
88
89
  _parse_package,
89
90
  executor.progress,
90
91
  pkg,
91
- docs_directory / pkg.pypi,
92
+ index,
92
93
  in_place=in_place,
93
94
  num_threads_per_package=num_threads_per_package,
94
95
  )
@@ -101,13 +102,13 @@ def parse_docs(
101
102
  def _parse_package(
102
103
  progress: Progress,
103
104
  package: Package,
104
- package_docs: Path,
105
+ index: Index,
105
106
  *,
106
107
  in_place: bool,
107
108
  num_threads_per_package: int,
108
109
  ) -> None:
109
- crawl_state_file = package_docs.parent / f"{package.pypi}.json"
110
- crawl_state = CrawlState.loads(crawl_state_file.read_text())
110
+ crawl_state = index.load_crawl_state(package.pypi)
111
+ assert crawl_state is not None, f"No crawl state for {package.pypi}"
111
112
  file_and_urls: list[tuple[str, str]] = list(crawl_state.file_to_urls.items())
112
113
  if package.is_cpython():
113
114
  symbol_to_urls: dict[str, str] = _SPECIAL_SYMBOLS.copy()
@@ -117,6 +118,7 @@ def _parse_package(
117
118
  }
118
119
  parser = _Parser(package)
119
120
  lock = threading.RLock()
121
+ package_docs = index.docs_directory / package.pypi
120
122
 
121
123
  def parse(file, url):
122
124
  if package.is_cpython:
@@ -133,15 +135,16 @@ def _parse_package(
133
135
  )
134
136
  for symbol, fragment in symbols.items():
135
137
  with lock:
136
- if symbol not in symbol_to_urls:
137
- if (
138
- fragment.startswith(_MODULE_FRAGMENT_PREFIX)
139
- and module_count == 1
140
- ):
141
- # This page contains a single module, let's redirect to this page without the anchor.
142
- symbol_to_urls[symbol] = f"{url}"
143
- else:
144
- symbol_to_urls[symbol] = f"{url}#{fragment}"
138
+ if (existing_url := symbol_to_urls.get(symbol)) and (
139
+ # More components take precedence.
140
+ existing_url.count("/") >= url.count("/")
141
+ ):
142
+ continue
143
+ if fragment.startswith(_MODULE_FRAGMENT_PREFIX) and module_count == 1:
144
+ # This page contains a single module, let's redirect to this page without the anchor.
145
+ symbol_to_urls[symbol] = f"{url}"
146
+ else:
147
+ symbol_to_urls[symbol] = f"{url}#{fragment}"
145
148
 
146
149
  with ProgressExecutor(
147
150
  f"Parsing {len(file_and_urls)} files for {package_docs}",
@@ -156,7 +159,12 @@ def _parse_package(
156
159
 
157
160
  console.print(f"Found {len(symbol_to_urls)} symbols in {package.pypi}")
158
161
  _heuristically_fillin_modules(package, symbol_to_urls)
159
- lines = _create_symbols_map(symbol_to_urls)
162
+
163
+ lines = [
164
+ f'VERSION = "{crawl_state.package_version}"',
165
+ "",
166
+ ]
167
+ lines.extend(_create_symbols_map(symbol_to_urls))
160
168
  mapping_file = Path(mapping.__file__).parent / f"{package.pypi}.py"
161
169
  if in_place:
162
170
  mapping_file.write_text("\n".join(itertools.chain(lines, [""])))
@@ -165,8 +173,13 @@ def _parse_package(
165
173
  before = mapping_file.read_text().splitlines(keepends=True)
166
174
  else:
167
175
  before = []
168
- diffs = difflib.unified_diff(
169
- before, [line + "\n" for line in lines], fromfile="before", tofile="after"
176
+ diffs = list(
177
+ difflib.unified_diff(
178
+ before,
179
+ [line + "\n" for line in lines],
180
+ fromfile="before",
181
+ tofile="after",
182
+ )
170
183
  )
171
184
  if diffs:
172
185
  console.print("".join(diffs))
@@ -175,6 +188,8 @@ def _parse_package(
175
188
  def _heuristically_fillin_modules(
176
189
  package: Package, symbol_to_urls: dict[str, str]
177
190
  ) -> None:
191
+ if package.is_cpython():
192
+ return
178
193
  extra_module_to_urls: dict[str, str] = {}
179
194
  for symbol in symbol_to_urls:
180
195
  if symbol in package.namespaces:
@@ -200,7 +215,7 @@ def _heuristically_fillin_modules(
200
215
 
201
216
 
202
217
  def _remove_fragment(url: str) -> str:
203
- return parse.urlunparse(parse.urlparse(url)._replace(fragment=""))
218
+ return urlunparse(urlparse(url)._replace(fragment=""))
204
219
 
205
220
 
206
221
  def _create_symbols_map(symbol_to_urls: dict[str, str]) -> list[str]:
@@ -210,7 +225,6 @@ def _create_symbols_map(symbol_to_urls: dict[str, str]) -> list[str]:
210
225
 
211
226
  lines = [
212
227
  "# fmt: off",
213
- "",
214
228
  "MAPPING = {",
215
229
  ]
216
230
  previous_symbol = None
@@ -0,0 +1,14 @@
1
+ from pyrefdev.indexer.index import Index
2
+ from pyrefdev.indexer.add_docs import update_config
3
+ from pyrefdev.config import SUPPORTED_PACKAGES
4
+
5
+
6
+ def parse_pypi(*, index: Index = Index()) -> None:
7
+ """Parse PyPI data and add new packages to the config."""
8
+ for pypi in index.get_pypi_packages():
9
+ if pypi in SUPPORTED_PACKAGES:
10
+ continue
11
+ url = index.guess_index_url(pypi, should_die_if_not_found=False)
12
+ if url is None:
13
+ continue
14
+ update_config(pypi, url, None)