Skip to content

Commit 96c53e3

Browse files
authored
docs: add a "glossary" section to the guide, automatically link to it (#5933)
* docs: add a "glossary" section to the guide, automatically link to it * fix link checking * try more link checking fixes * more link check fixes * final link / link checker fixes
1 parent 3a994ab commit 96c53e3

23 files changed

Lines changed: 372 additions & 54 deletions

.github/workflows/netlify-build.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@ env:
1818
jobs:
1919
guide-build:
2020
runs-on: ubuntu-latest
21-
outputs:
22-
tag_name: ${{ steps.prepare_tag.outputs.tag_name }}
2321
steps:
2422
- uses: actions/checkout@v6.0.2
2523
- uses: actions/setup-python@v6
@@ -53,7 +51,7 @@ jobs:
5351
python -m pip install --upgrade pip && pip install nox[uv]
5452
nox -s ${{ github.event_name == 'release' && 'build-guide' || 'check-guide' }}
5553
env:
56-
PYO3_VERSION_TAG: ${{ steps.prepare_tag.outputs.tag_name }}
54+
PYO3_VERSION_TAG: ${{ github.event_name == 'release' && steps.prepare_tag.outputs.tag_name || 'main' }}
5755
# allows lychee to get better rate limits from github
5856
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5957

CHANGELOG.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,11 +1535,11 @@ Prerelease of PyO3 0.21. See [the GitHub diff](https://github.com/pyo3/pyo3/comp
15351535

15361536
### Changed
15371537

1538-
- `#[classattr]` constants with a known magic method name (which is lowercase) no longer trigger lint warnings expecting constants to be uppercase. [#1969](https://github.com/PyO3/pyo3/pull/1969)
1538+
- `#[classattr]` constants with a known magic method name (which is lowercase) no longer trigger lint warnings expecting constants to be uppercase. [#1971](https://github.com/PyO3/pyo3/pull/1971)
15391539

15401540
### Fixed
15411541

1542-
- Fix creating `#[classattr]` by functions with the name of a known magic method. [#1969](https://github.com/PyO3/pyo3/pull/1969)
1542+
- Fix creating `#[classattr]` by functions with the name of a known magic method. [#1971](https://github.com/PyO3/pyo3/pull/1971)
15431543
- Fix use of `catch_unwind` in `allow_threads` which can cause fatal crashes. [#1989](https://github.com/PyO3/pyo3/pull/1989)
15441544
- Fix build failure on PyPy when abi3 features are activated. [#1991](https://github.com/PyO3/pyo3/pull/1991)
15451545
- Fix mingw platform detection. [#1993](https://github.com/PyO3/pyo3/pull/1993)
@@ -2607,4 +2607,4 @@ Yanked
26072607
[0.2.2]: https://github.com/pyo3/pyo3/compare/v0.2.1...v0.2.2
26082608
[0.2.1]: https://github.com/pyo3/pyo3/compare/v0.2.0...v0.2.1
26092609
[0.2.0]: https://github.com/pyo3/pyo3/compare/v0.1.0...v0.2.0
2610-
[0.1.0]: https://github.com/PyO3/pyo3/tree/0.1.0
2610+
[0.1.0]: https://github.com/PyO3/pyo3/tree/v0.1.0

guide/book.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ authors = ["PyO3 Project and Contributors"]
66
[preprocessor.pyo3_version]
77
command = "python3 pyo3_version.py"
88

9+
[preprocessor.glossary_linker]
10+
command = "python3 glossary_linker.py"
11+
912
[preprocessor.tabs]
1013

1114
[output.html]

guide/glossary_linker.py

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
"""mdbook preprocessor to auto-link glossary terms.
2+
3+
Parses glossary.md for terms defined using definition list syntax:
4+
5+
term A
6+
: This is a definition of term A.
7+
8+
term B
9+
: This is a definition of term B.
10+
11+
For each chapter (except the glossary itself), the first occurrence of each
12+
term is replaced with a link to the glossary entry.
13+
14+
An HTML comment block at the bottom of glossary.md maps additional
15+
terms to arbitrary URLs (e.g. upstream Python/Rust glossary entries):
16+
17+
Tested against mdbook 0.5.0.
18+
"""
19+
20+
import json
21+
import re
22+
import sys
23+
24+
25+
def slugify(term):
26+
"""Convert a term to a URL-friendly anchor slug."""
27+
return re.sub(r"[^\w\s-]", "", term.lower()).strip().replace(" ", "-")
28+
29+
30+
def parse_glossary(content):
31+
"""Parse glossary.md and return (local_terms, external_terms).
32+
33+
local_terms: dict of {term: url} for definition-list entries (link to glossary anchors).
34+
external_terms: dict of {term: url} from the <!-- external-glossary-links --> comment.
35+
"""
36+
local_terms = {}
37+
external_terms = {}
38+
39+
# Parse definition-list terms: a non-indented line followed by a line
40+
# starting with " : " (the definition).
41+
for m in re.finditer(r"(?m)^([^\s#<>!`\[].+)\n : ", content):
42+
term = m.group(1).strip()
43+
local_terms[term] = f"glossary.md#{slugify(term)}"
44+
45+
# Parse <!-- external-glossary-links ... --> block for external URLs.
46+
link_block = re.search(
47+
r"<!--\s*external-glossary-links\s*\n(.*?)-->", content, re.DOTALL
48+
)
49+
if not link_block:
50+
raise ValueError("Glossary is missing <!-- external-glossary-links --> block")
51+
52+
for line in link_block.group(1).strip().splitlines():
53+
line = line.strip()
54+
if not line:
55+
continue
56+
# "term: url" or "term | url"
57+
parts = re.split(r"\s*[:|]\s*", line, maxsplit=1)
58+
if len(parts) == 2:
59+
term, url = parts[0].strip(), parts[1].strip()
60+
if term and url:
61+
external_terms[term] = url
62+
63+
return local_terms, external_terms
64+
65+
66+
# ---------------------------------------------------------------------------
67+
# Replacement logic
68+
# ---------------------------------------------------------------------------
69+
70+
# Matches fenced code blocks, inline code, and markdown links so we can skip
71+
# them. Everything else is "plain text" we can scan for terms.
72+
_SKIP_PATTERN = re.compile(
73+
r"^```[^\n]*\n[\s\S]*?^```\s*$" # fenced code blocks (``` to ```, multiline)
74+
r"|`[^`\n]+`" # inline code (single line only)
75+
r"|\[[^\]]*\]\([^\)]*\)" # markdown links (entire [...](...))
76+
r"|<[^>\n]+>" # HTML tags (single line only)
77+
r"|^\s*>.*$" # block quotes (single line)
78+
r"|^.+(?=\n : )" # definition list term lines
79+
r"|^#{1,6}\s+.*$", # headings
80+
re.MULTILINE,
81+
)
82+
83+
84+
def link_terms_in_content(content, terms, first_only=True, url_prefix=""):
85+
"""Replace occurrences of glossary terms with markdown links.
86+
87+
If first_only is True (default), only the first occurrence of each term is
88+
linked. If False, every occurrence is linked.
89+
90+
Skips code blocks, inline code, existing links, and HTML tags.
91+
"""
92+
linked = set()
93+
94+
# Build a combined pattern matching any term, longest first so that
95+
# multi-word terms match before their sub-terms.
96+
sorted_terms = sorted(terms.keys(), key=len, reverse=True)
97+
if not sorted_terms:
98+
return content
99+
100+
# Allow optional trailing "s" so plurals like "wheels" match "wheel".
101+
escaped = [re.escape(t) + r"s?" for t in sorted_terms]
102+
term_pattern = re.compile(r"\b(" + "|".join(escaped) + r")\b")
103+
104+
# Find all protected spans.
105+
protected = []
106+
for m in _SKIP_PATTERN.finditer(content):
107+
protected.append((m.start(), m.end()))
108+
109+
# Parse <!-- no-glossary:term --> comments to suppress specific terms.
110+
# Maps line number to set of suppressed term names (lowercased).
111+
_suppressed_re = re.compile(r"<!--\s*no-glossary-link:([\w\s-]+?)\s*-->")
112+
suppressed_at_line = {}
113+
for m in _suppressed_re.finditer(content):
114+
term_name = m.group(1).strip().lower()
115+
# Find the next line after the comment.
116+
next_line_start = content.find("\n", m.end())
117+
if next_line_start == -1:
118+
continue
119+
next_line_start += 1
120+
next_line_end = content.find("\n", next_line_start)
121+
if next_line_end == -1:
122+
next_line_end = len(content)
123+
suppressed_at_line.setdefault((next_line_start, next_line_end), set()).add(
124+
term_name
125+
)
126+
127+
def in_protected(start, end):
128+
for ps, pe in protected:
129+
if start >= ps and end <= pe:
130+
return True
131+
return False
132+
133+
def is_suppressed(canonical, start):
134+
"""Check if this term is suppressed by a <!-- no-glossary:term --> on the same line."""
135+
for (ls, le), suppressed_terms in suppressed_at_line.items():
136+
if ls <= start < le and canonical.lower() in suppressed_terms:
137+
return True
138+
return False
139+
140+
if suppressed_at_line:
141+
print(
142+
f"Found {sum(len(s) for s in suppressed_at_line.values())} suppressed terms at {len(suppressed_at_line)} lines",
143+
file=sys.stderr,
144+
)
145+
146+
result = []
147+
last = 0
148+
149+
for m in term_pattern.finditer(content):
150+
matched_term = m.group(1)
151+
152+
# Find the canonical term. Try exact match, then case-insensitive,
153+
# then strip trailing "s" for plural forms.
154+
canonical = None
155+
for candidate in (matched_term, matched_term.rstrip("s")):
156+
if candidate in terms:
157+
canonical = candidate
158+
break
159+
for t in terms:
160+
if t.lower() == candidate.lower():
161+
canonical = t
162+
break
163+
if canonical is not None:
164+
break
165+
if canonical is None:
166+
continue
167+
168+
if first_only and canonical in linked:
169+
continue
170+
171+
if in_protected(m.start(), m.end()):
172+
continue
173+
174+
if is_suppressed(canonical, m.start()):
175+
continue
176+
177+
linked.add(canonical)
178+
result.append(content[last : m.start()])
179+
url = terms[canonical]
180+
if url_prefix and not url.startswith("http"):
181+
url = url_prefix + url
182+
result.append(f"[{matched_term}]({url})")
183+
last = m.end()
184+
185+
result.append(content[last:])
186+
return "".join(result)
187+
188+
189+
# ---------------------------------------------------------------------------
190+
# mdbook preprocessor interface
191+
# ---------------------------------------------------------------------------
192+
193+
194+
def find_glossary_content(items):
195+
"""Walk the book items to find and return the glossary chapter content."""
196+
for item in items:
197+
if not isinstance(item, dict) or "Chapter" not in item:
198+
continue
199+
ch = item["Chapter"]
200+
if ch.get("path") and ch["path"].endswith("glossary.md"):
201+
return ch["content"]
202+
result = find_glossary_content(ch.get("sub_items", []))
203+
if result is not None:
204+
return result
205+
return None
206+
207+
208+
def process_item(item, local_terms, external_terms):
209+
"""Recursively process a book item, linking glossary terms."""
210+
if not isinstance(item, dict) or "Chapter" not in item:
211+
return
212+
213+
ch = item["Chapter"]
214+
path = ch.get("path", "")
215+
216+
if path and path.endswith("glossary.md"):
217+
# On the glossary page itself, link external terms (all occurrences).
218+
ch["content"] = link_terms_in_content(
219+
ch["content"], external_terms, first_only=False
220+
)
221+
elif path:
222+
# Compute relative prefix for local glossary links based on depth.
223+
prefix = "../" * path.count("/")
224+
all_terms = {**local_terms, **external_terms}
225+
ch["content"] = link_terms_in_content(
226+
ch["content"], all_terms, url_prefix=prefix
227+
)
228+
229+
for sub in ch.get("sub_items", []):
230+
process_item(sub, local_terms, external_terms)
231+
232+
233+
def main():
234+
for line in sys.stdin:
235+
if not line.strip():
236+
continue
237+
[context, book] = json.loads(line)
238+
239+
# Parse glossary terms from the glossary chapter.
240+
glossary_content = find_glossary_content(book["items"])
241+
if glossary_content is None:
242+
# No glossary found; pass through unchanged.
243+
json.dump(book, fp=sys.stdout)
244+
return
245+
246+
local_terms, external_terms = parse_glossary(glossary_content)
247+
248+
# Process all chapters.
249+
for item in book["items"]:
250+
process_item(item, local_terms, external_terms)
251+
252+
json.dump(book, fp=sys.stdout)
253+
return
254+
255+
256+
if __name__ == "__main__":
257+
main()

guide/src/SUMMARY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444

4545
[Appendix A: Migration guide](migration.md)
4646

47+
[Appendix B: Glossary](glossary.md)
48+
4749
[Appendix B: Trait bounds](trait-bounds.md)
4850

4951
[Appendix C: Python typing hints](python-typing-hints.md)

guide/src/building-and-distribution.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ The PyO3 ecosystem has two main choices to abstract the process of developing Py
8686

8787
- [`maturin`] is a command-line tool to build, package and upload Python modules.
8888
It makes opinionated choices about project layout meaning it needs very little configuration.
89-
This makes it a great choice for users who are building a Python extension from scratch and don't need flexibility.
89+
This makes it a great choice for users who are building a Python extension module from scratch and don't need flexibility.
9090
- [`setuptools-rust`] is an add-on for `setuptools` which adds extra keyword arguments to the `setup.py` configuration file.
9191
It requires more configuration than `maturin`, however this gives additional flexibility for users adding Rust to an existing Python package that can't satisfy `maturin`'s constraints.
9292

@@ -97,7 +97,7 @@ There are also [`maturin-starter`] and [`setuptools-rust-starter`] examples in t
9797

9898
### Manual builds
9999

100-
To build a PyO3-based Python extension manually, start by running `cargo build` as normal in a library project with the [`cdylib` crate type](https://doc.rust-lang.org/cargo/reference/cargo-targets.html#the-crate-type-field) while the `PYO3_BUILD_EXTENSION_MODULE` environment variable is set.
100+
To build a PyO3-based Python extension module manually, start by running `cargo build` as normal in a library project with the [`cdylib` crate type](https://doc.rust-lang.org/cargo/reference/cargo-targets.html#the-crate-type-field) while the `PYO3_BUILD_EXTENSION_MODULE` environment variable is set.
101101

102102
Once built, symlink (or copy) and rename the shared library from Cargo's `target/` directory to your desired output directory:
103103

@@ -199,7 +199,7 @@ Finally, don't forget that on MacOS the `extension-module` feature will cause `c
199199

200200
By default PyO3 links to `libpython`.
201201
This makes binaries, tests, and examples "just work".
202-
However, Python extensions on Unix must not link to libpython for [manylinux](https://www.python.org/dev/peps/pep-0513/) compliance.
202+
However, Python extension modules on Unix must not link to libpython for [manylinux](https://www.python.org/dev/peps/pep-0513/) compliance.
203203

204204
The downside of not linking to `libpython` is that binaries, tests, and examples (which usually embed Python) will fail to build.
205205
As a result, PyO3 uses an envionment variable `PYO3_BUILD_EXTENSION_MODULE` to disable linking to `libpython`.
@@ -246,7 +246,7 @@ There are three steps involved in making use of `abi3` when building Python pack
246246
#### Minimum Python version for `abi3`
247247

248248
Because a single `abi3` wheel can be used with many different Python versions, PyO3 has feature flags `abi3-py38`, `abi3-py39`, `abi3-py310` etc. to set the minimum required Python version for your `abi3` wheel.
249-
For example, if you set the `abi3-py38` feature, your extension wheel can be used on all Python 3 versions from Python 3.8 and up.
249+
For example, if you set the `abi3-py38` feature, your wheel can be used on all Python 3 versions from Python 3.8 and up.
250250
`maturin` and `setuptools-rust` will give the wheel a name like `my-extension-1.0-cp38-abi3-manylinux2020_x86_64.whl`.
251251

252252
As your extension module may be run with multiple different Python versions you may occasionally find you need to check the Python version at runtime to customize behavior.
@@ -258,9 +258,9 @@ E.g., if you set `abi3-py39` and try to compile the crate with a host of Python
258258
> [!NOTE]
259259
> If you set more that one of these `abi3` version feature flags the lowest version always wins. For example, with both `abi3-py38` and `abi3-py39` set, PyO3 would build a wheel which supports Python 3.8 and up.
260260
261-
#### Building `abi3` extensions without a Python interpreter
261+
#### Building `abi3` extension modules without a Python interpreter
262262

263-
As an advanced feature, you can build PyO3 wheel without calling Python interpreter with the environment variable `PYO3_NO_PYTHON` set.
263+
As an advanced feature, you can build a PyO3 wheel without calling Python interpreter with the environment variable `PYO3_NO_PYTHON` set.
264264
Also, if the build host Python interpreter is not found or is too old or otherwise unusable, PyO3 will still attempt to compile `abi3` extension modules after displaying a warning message.
265265

266266
#### Missing features

guide/src/building-and-distribution/multiple-python-versions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,9 @@ This `#[cfg]` marks code which is running on PyPy.
9696

9797
When building with PyO3's `abi3` feature, your extension module will be compiled against a specific [minimum version](../building-and-distribution.md#minimum-python-version-for-abi3) of Python, but may be running on newer Python versions.
9898

99-
For example with PyO3's `abi3-py38` feature, your extension will be compiled as if it were for Python 3.8.
99+
For example with PyO3's `abi3-py38` feature, your extension module will be compiled as if it were for Python 3.8.
100100
If you were using `pyo3-build-config`, `#[cfg(Py_3_8)]` would be present.
101-
Your user could freely install and run your abi3 extension on Python 3.9.
101+
Your user could freely install and run your abi3 extension module on Python 3.9.
102102

103103
There's no way to detect your user doing that at compile time, so instead you need to fall back to runtime checks.
104104

guide/src/class/numeric.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Before proceeding, we should think about how we want to handle overflows.
66
There are three obvious solutions:
77

88
- We can have infinite precision just like Python's `int`.
9+
<!-- no-glossary-link:wheel -->
910
However that would be quite boring - we'd be reinventing the wheel.
1011
- We can raise exceptions whenever `Number` overflows, but that makes the API painful to use.
1112
- We can wrap around the boundary of `i32`.

guide/src/conversions/tables.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ As always, if you're not sure it's worth it in your case, benchmark it!
7878

7979
## Returning Rust values to Python
8080

81-
When returning values from functions callable from Python, [PyO3's smart pointers](../types.md#pyo3s-smart-pointers) (`Py<T>`, `Bound<'py, T>`, and `Borrowed<'a, 'py, T>`) can be used with zero cost.
81+
When returning values from functions callable from Python, PyO3's smart pointers (`Py<T>`, `Bound<'py, T>`, and `Borrowed<'a, 'py, T>`) can be used with zero cost.
8282

8383
Because `Bound<'py, T>` and `Borrowed<'a, 'py, T>` have lifetime parameters, the Rust compiler may ask for lifetime annotations to be added to your function.
8484
See the [section of the guide dedicated to this](../types.md#function-argument-lifetimes).

0 commit comments

Comments
 (0)