markwork 0.1.0__py3-none-any.whl
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.
- markwork/__init__.py +48 -0
- markwork/engine/__init__.py +65 -0
- markwork/engine/_abs_module.py +44 -0
- markwork/engine/_add_alias_site.py +31 -0
- markwork/engine/_bound_anywhere.py +42 -0
- markwork/engine/_builtin_links.py +67 -0
- markwork/engine/_css_class.py +32 -0
- markwork/engine/_detect_package.py +47 -0
- markwork/engine/_generate.py +35 -0
- markwork/engine/_highlight.py +71 -0
- markwork/engine/_is_stdlib.py +36 -0
- markwork/engine/_line_starts.py +35 -0
- markwork/engine/_merge_tokens.py +36 -0
- markwork/engine/_module_definitions.py +42 -0
- markwork/engine/_nested_bound.py +53 -0
- markwork/engine/_parse_init.py +52 -0
- markwork/engine/_pydocs.py +10 -0
- markwork/engine/_source_docs.py +620 -0
- markwork/engine/_title.py +26 -0
- markwork/engine/_token_html.py +58 -0
- markwork/engine/_top_level_def_lines.py +34 -0
- markwork/engine/_top_level_defs.py +33 -0
- markwork/etc/markwork.css +42 -0
- markwork-0.1.0.dist-info/METADATA +259 -0
- markwork-0.1.0.dist-info/RECORD +28 -0
- markwork-0.1.0.dist-info/WHEEL +5 -0
- markwork-0.1.0.dist-info/licenses/LICENSE +202 -0
- markwork-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The 'SourceDocs' class drives a documentation run: it holds the symbol
|
|
3
|
+
registries and resolved paths, emits a page per source file, and writes
|
|
4
|
+
the go-to-definition fragments and cross-reference sections.
|
|
5
|
+
"""
|
|
6
|
+
# Apache-2.0 license
|
|
7
|
+
# Copyright (c) 2026 Asger Jon Vistisen
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import shutil
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ._abs_module import absModule
|
|
14
|
+
from ._add_alias_site import addAliasSite
|
|
15
|
+
from ._bound_anywhere import boundAnywhere
|
|
16
|
+
from ._builtin_links import BUILTIN_LINKS
|
|
17
|
+
from ._highlight import highlightWithLinks
|
|
18
|
+
from ._is_stdlib import isStdlib
|
|
19
|
+
from ._line_starts import lineStarts
|
|
20
|
+
from ._module_definitions import moduleDefinitions
|
|
21
|
+
from ._nested_bound import nestedBound
|
|
22
|
+
from ._parse_init import parseInit
|
|
23
|
+
from ._pydocs import PYDOCS
|
|
24
|
+
from ._title import title
|
|
25
|
+
from ._top_level_def_lines import topLevelDefLines
|
|
26
|
+
from ._top_level_defs import topLevelDefs
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SourceDocs:
|
|
30
|
+
"""A single documentation run over one package and its tests.
|
|
31
|
+
|
|
32
|
+
The instance owns four registries and five filesystem roots. The
|
|
33
|
+
registries are populated as pages are emitted, then read back when the
|
|
34
|
+
deferred fragments and the cross-reference sections are written, so a
|
|
35
|
+
link can target a symbol defined in a file emitted later in the run.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
repoRoot : Path
|
|
40
|
+
The project root, the parent of the docs directory.
|
|
41
|
+
srcRoot : Path
|
|
42
|
+
The source root the package lives under.
|
|
43
|
+
pkg : Path
|
|
44
|
+
The package directory to document.
|
|
45
|
+
testsRoot : Path
|
|
46
|
+
The tests directory to mirror.
|
|
47
|
+
out : Path
|
|
48
|
+
The output directory the generated rST and fragments go to.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self, repoRoot: Path, srcRoot: Path, pkg: Path, testsRoot: Path,
|
|
53
|
+
out: Path
|
|
54
|
+
) -> None:
|
|
55
|
+
self.__repo_root__ = repoRoot
|
|
56
|
+
self.__src_root__ = srcRoot
|
|
57
|
+
self.__pkg__ = pkg
|
|
58
|
+
self.__tests_root__ = testsRoot
|
|
59
|
+
self.__out__ = out
|
|
60
|
+
# (absolute module dotted, name) -> page stub documenting the symbol.
|
|
61
|
+
self.__symbol_page__ = {}
|
|
62
|
+
# (absolute module dotted, name) -> (page stub, definition line).
|
|
63
|
+
self.__symbol_def__ = {}
|
|
64
|
+
# Source/test file (repo-relative posix) -> page stub.
|
|
65
|
+
self.__file_page__ = {}
|
|
66
|
+
# (source file, page stub) pairs whose fragment is rendered last, so
|
|
67
|
+
# a go-to-definition link can point at a file emitted later.
|
|
68
|
+
self.__pending_frags__ = []
|
|
69
|
+
|
|
70
|
+
# ----------------------------------------------------------
|
|
71
|
+
# Path / rST helpers
|
|
72
|
+
# ----------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def relPath(self, pyfile: Path) -> str:
|
|
75
|
+
"""Return the repo-relative posix path of a file.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
pyfile : Path
|
|
80
|
+
The file to relativise.
|
|
81
|
+
|
|
82
|
+
Returns
|
|
83
|
+
-------
|
|
84
|
+
str
|
|
85
|
+
The path relative to the repo root, in posix form.
|
|
86
|
+
"""
|
|
87
|
+
return pyfile.relative_to(self.__repo_root__).as_posix()
|
|
88
|
+
|
|
89
|
+
def srcDotted(self, path: Path) -> str:
|
|
90
|
+
"""Return the dotted name of a path relative to the source root.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
path : Path
|
|
95
|
+
A source file or directory under the source root.
|
|
96
|
+
|
|
97
|
+
Returns
|
|
98
|
+
-------
|
|
99
|
+
str
|
|
100
|
+
The dotted name, for example 'markwork.engine', used for symbol
|
|
101
|
+
keys and source stub names.
|
|
102
|
+
"""
|
|
103
|
+
rel = path.relative_to(self.__src_root__)
|
|
104
|
+
if path.suffix == ".py":
|
|
105
|
+
rel = rel.with_suffix("")
|
|
106
|
+
return ".".join(rel.parts)
|
|
107
|
+
|
|
108
|
+
def repoDotted(self, path: Path) -> str:
|
|
109
|
+
"""Return the dotted name of a path relative to the repo root.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
path : Path
|
|
114
|
+
A file or directory under the repo root.
|
|
115
|
+
|
|
116
|
+
Returns
|
|
117
|
+
-------
|
|
118
|
+
str
|
|
119
|
+
The dotted name, for example 'tests.test_render', used for test
|
|
120
|
+
stub names so they stay distinct from source stubs.
|
|
121
|
+
"""
|
|
122
|
+
rel = path.relative_to(self.__repo_root__)
|
|
123
|
+
if path.suffix == ".py":
|
|
124
|
+
rel = rel.with_suffix("")
|
|
125
|
+
return ".".join(rel.parts)
|
|
126
|
+
|
|
127
|
+
def write(self, stub: str, body: str) -> None:
|
|
128
|
+
"""Write one stub file, named '<stub>.rst', into the output dir.
|
|
129
|
+
|
|
130
|
+
Parameters
|
|
131
|
+
----------
|
|
132
|
+
stub : str
|
|
133
|
+
The page stub name.
|
|
134
|
+
body : str
|
|
135
|
+
The rST body, written with a trailing newline.
|
|
136
|
+
"""
|
|
137
|
+
path = self.__out__ / ("%s.rst" % stub)
|
|
138
|
+
path.write_text(body + "\n", encoding="utf-8")
|
|
139
|
+
|
|
140
|
+
def recordFilePage(self, pyfile: Path, stub: str) -> None:
|
|
141
|
+
"""Remember which page shows a given file, first page winning.
|
|
142
|
+
|
|
143
|
+
Parameters
|
|
144
|
+
----------
|
|
145
|
+
pyfile : Path
|
|
146
|
+
The source file.
|
|
147
|
+
stub : str
|
|
148
|
+
The page stub showing it.
|
|
149
|
+
"""
|
|
150
|
+
self.__file_page__.setdefault(self.relPath(pyfile), stub)
|
|
151
|
+
|
|
152
|
+
def renderSource(self, pyfile: Path, stub: str) -> str:
|
|
153
|
+
"""Record a file for deferred fragment rendering and return the rST
|
|
154
|
+
that embeds the fragment.
|
|
155
|
+
|
|
156
|
+
The fragment is embedded via ':file:' so the '<pre>' content is not
|
|
157
|
+
re-indented by rST, and it is written later, once every page (hence
|
|
158
|
+
every definition target) is known.
|
|
159
|
+
|
|
160
|
+
Parameters
|
|
161
|
+
----------
|
|
162
|
+
pyfile : Path
|
|
163
|
+
The source file to render later.
|
|
164
|
+
stub : str
|
|
165
|
+
The page stub the fragment belongs to.
|
|
166
|
+
|
|
167
|
+
Returns
|
|
168
|
+
-------
|
|
169
|
+
str
|
|
170
|
+
The rST embedding the fragment.
|
|
171
|
+
"""
|
|
172
|
+
self.__pending_frags__.append((pyfile, stub))
|
|
173
|
+
return "\n".join(
|
|
174
|
+
[
|
|
175
|
+
"*%s*" % self.relPath(pyfile),
|
|
176
|
+
"",
|
|
177
|
+
".. raw:: html",
|
|
178
|
+
" :file: %s.frag.html" % stub,
|
|
179
|
+
]
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# ----------------------------------------------------------
|
|
183
|
+
# Library page emission (driven by __all__)
|
|
184
|
+
# ----------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def emitObject(self, pkgDotted: str, public: str, pyfile: Path) -> str:
|
|
187
|
+
"""Emit a leaf page for one public name, showing the whole file.
|
|
188
|
+
|
|
189
|
+
Parameters
|
|
190
|
+
----------
|
|
191
|
+
pkgDotted : str
|
|
192
|
+
The dotted name of the owning package.
|
|
193
|
+
public : str
|
|
194
|
+
The public name being documented.
|
|
195
|
+
pyfile : Path
|
|
196
|
+
The file that defines it.
|
|
197
|
+
|
|
198
|
+
Returns
|
|
199
|
+
-------
|
|
200
|
+
str
|
|
201
|
+
The page stub.
|
|
202
|
+
"""
|
|
203
|
+
stub = "%s.%s" % (pkgDotted, public)
|
|
204
|
+
self.write(
|
|
205
|
+
stub, "%s\n\n%s\n" % (title(public), self.renderSource(pyfile, stub))
|
|
206
|
+
)
|
|
207
|
+
self.recordFilePage(pyfile, stub)
|
|
208
|
+
return stub
|
|
209
|
+
|
|
210
|
+
def emitWholeFile(self, pyfile: Path) -> str:
|
|
211
|
+
"""Emit a leaf page for a submodule pulled in via 'from . import X'.
|
|
212
|
+
|
|
213
|
+
Parameters
|
|
214
|
+
----------
|
|
215
|
+
pyfile : Path
|
|
216
|
+
The submodule file.
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
str
|
|
221
|
+
The page stub.
|
|
222
|
+
"""
|
|
223
|
+
stub = self.srcDotted(pyfile)
|
|
224
|
+
self.write(
|
|
225
|
+
stub,
|
|
226
|
+
"%s\n\n%s\n" % (title(pyfile.stem), self.renderSource(pyfile, stub)),
|
|
227
|
+
)
|
|
228
|
+
self.recordFilePage(pyfile, stub)
|
|
229
|
+
return stub
|
|
230
|
+
|
|
231
|
+
def emitPackage(self, pkgDir: Path) -> str:
|
|
232
|
+
"""Emit a package stub: its __init__ source plus a toctree of __all__.
|
|
233
|
+
|
|
234
|
+
Each public name is resolved: a child subpackage recurses, a child
|
|
235
|
+
module is shown whole, a re-exported symbol is documented from its
|
|
236
|
+
defining file, and an own definition is documented in place. Missing
|
|
237
|
+
or non-exported names produce nothing.
|
|
238
|
+
|
|
239
|
+
Parameters
|
|
240
|
+
----------
|
|
241
|
+
pkgDir : Path
|
|
242
|
+
The package directory.
|
|
243
|
+
|
|
244
|
+
Returns
|
|
245
|
+
-------
|
|
246
|
+
str
|
|
247
|
+
The package page stub.
|
|
248
|
+
"""
|
|
249
|
+
dotted = self.srcDotted(pkgDir)
|
|
250
|
+
init = pkgDir / "__init__.py"
|
|
251
|
+
allNames, sourceMap = parseInit(init)
|
|
252
|
+
ownDefs = topLevelDefs(init)
|
|
253
|
+
ownDefLines = topLevelDefLines(init)
|
|
254
|
+
children = []
|
|
255
|
+
for name in allNames:
|
|
256
|
+
entry = sourceMap.get(name)
|
|
257
|
+
if entry is None:
|
|
258
|
+
if name in ownDefs:
|
|
259
|
+
stub = self.emitObject(dotted, name, init)
|
|
260
|
+
self.__symbol_page__[(dotted, name)] = stub
|
|
261
|
+
self.__symbol_def__[(dotted, name)] = (
|
|
262
|
+
stub, ownDefLines.get(name, 1)
|
|
263
|
+
)
|
|
264
|
+
children.append(stub)
|
|
265
|
+
continue
|
|
266
|
+
if entry[0] == "child":
|
|
267
|
+
child = entry[1]
|
|
268
|
+
childDir = pkgDir / child
|
|
269
|
+
childFile = pkgDir / ("%s.py" % child)
|
|
270
|
+
if childDir.is_dir() and (childDir / "__init__.py").exists():
|
|
271
|
+
children.append(self.emitPackage(childDir))
|
|
272
|
+
elif childFile.exists():
|
|
273
|
+
children.append(self.emitWholeFile(childFile))
|
|
274
|
+
else:
|
|
275
|
+
module, origin = entry[1], entry[2]
|
|
276
|
+
modfile = pkgDir / ("%s.py" % module)
|
|
277
|
+
if modfile.exists():
|
|
278
|
+
stub = self.emitObject(dotted, name, modfile)
|
|
279
|
+
defLine = topLevelDefLines(modfile).get(origin, 1)
|
|
280
|
+
modKey = "%s.%s" % (dotted, module)
|
|
281
|
+
self.__symbol_page__[(dotted, name)] = stub
|
|
282
|
+
self.__symbol_page__[(modKey, origin)] = stub
|
|
283
|
+
self.__symbol_def__[(dotted, name)] = (stub, defLine)
|
|
284
|
+
self.__symbol_def__[(modKey, origin)] = (stub, defLine)
|
|
285
|
+
children.append(stub)
|
|
286
|
+
self.recordFilePage(init, dotted)
|
|
287
|
+
body = "%s\n\n%s\n\n.. toctree::\n :maxdepth: 1\n\n" % (
|
|
288
|
+
title(pkgDir.name), self.renderSource(init, dotted)
|
|
289
|
+
)
|
|
290
|
+
body += "\n".join(" %s" % child for child in children) + "\n"
|
|
291
|
+
self.write(dotted, body)
|
|
292
|
+
return dotted
|
|
293
|
+
|
|
294
|
+
# ----------------------------------------------------------
|
|
295
|
+
# Test tree emission (mirrors the directory layout)
|
|
296
|
+
# ----------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
def emitTestFile(self, pyfile: Path) -> str:
|
|
299
|
+
"""Emit a leaf page for one test file, shown whole.
|
|
300
|
+
|
|
301
|
+
Parameters
|
|
302
|
+
----------
|
|
303
|
+
pyfile : Path
|
|
304
|
+
The test file.
|
|
305
|
+
|
|
306
|
+
Returns
|
|
307
|
+
-------
|
|
308
|
+
str
|
|
309
|
+
The page stub.
|
|
310
|
+
"""
|
|
311
|
+
stub = self.repoDotted(pyfile)
|
|
312
|
+
self.write(
|
|
313
|
+
stub,
|
|
314
|
+
"%s\n\n%s\n" % (title(pyfile.stem), self.renderSource(pyfile, stub)),
|
|
315
|
+
)
|
|
316
|
+
self.recordFilePage(pyfile, stub)
|
|
317
|
+
return stub
|
|
318
|
+
|
|
319
|
+
def emitTestDir(self, testDir: Path) -> str:
|
|
320
|
+
"""Emit a directory stub: a title and a toctree of its tests.
|
|
321
|
+
|
|
322
|
+
The toctree lists each test file and each populated subdirectory.
|
|
323
|
+
__pycache__, __init__.py and empty directories are skipped.
|
|
324
|
+
|
|
325
|
+
Parameters
|
|
326
|
+
----------
|
|
327
|
+
testDir : Path
|
|
328
|
+
The test directory.
|
|
329
|
+
|
|
330
|
+
Returns
|
|
331
|
+
-------
|
|
332
|
+
str
|
|
333
|
+
The directory page stub.
|
|
334
|
+
"""
|
|
335
|
+
stub = self.repoDotted(testDir)
|
|
336
|
+
children = []
|
|
337
|
+
for pyfile in sorted(testDir.glob("*.py")):
|
|
338
|
+
if pyfile.name != "__init__.py":
|
|
339
|
+
children.append(self.emitTestFile(pyfile))
|
|
340
|
+
for sub in sorted(p for p in testDir.iterdir() if p.is_dir()):
|
|
341
|
+
if sub.name == "__pycache__":
|
|
342
|
+
continue
|
|
343
|
+
if any(sub.rglob("*.py")):
|
|
344
|
+
children.append(self.emitTestDir(sub))
|
|
345
|
+
parts = [
|
|
346
|
+
title(testDir.name), "", ".. toctree::", " :maxdepth: 1", ""
|
|
347
|
+
]
|
|
348
|
+
parts += [" %s" % child for child in children]
|
|
349
|
+
self.write(stub, "\n".join(parts) + "\n")
|
|
350
|
+
return stub
|
|
351
|
+
|
|
352
|
+
# ----------------------------------------------------------
|
|
353
|
+
# Cross-reference pass
|
|
354
|
+
# ----------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
def scanUsages(self, root: Path) -> dict:
|
|
357
|
+
"""Map each documented symbol's page to where it is used under a root.
|
|
358
|
+
|
|
359
|
+
An import binds a local name; the use sites are the occurrences of
|
|
360
|
+
that name. __init__.py is skipped as re-export plumbing, and a file
|
|
361
|
+
that does not parse is skipped. When a bound name is never used, the
|
|
362
|
+
import line is the fallback.
|
|
363
|
+
|
|
364
|
+
Parameters
|
|
365
|
+
----------
|
|
366
|
+
root : Path
|
|
367
|
+
The directory tree to scan.
|
|
368
|
+
|
|
369
|
+
Returns
|
|
370
|
+
-------
|
|
371
|
+
dict
|
|
372
|
+
A mapping of page stub to '{using file (repo-relative posix):
|
|
373
|
+
[use lines]}'.
|
|
374
|
+
"""
|
|
375
|
+
usage = {}
|
|
376
|
+
for pyfile in sorted(root.rglob("*.py")):
|
|
377
|
+
if pyfile.name == "__init__.py":
|
|
378
|
+
continue
|
|
379
|
+
try:
|
|
380
|
+
tree = ast.parse(pyfile.read_text(encoding="utf-8"))
|
|
381
|
+
except SyntaxError:
|
|
382
|
+
continue
|
|
383
|
+
rel = self.relPath(pyfile)
|
|
384
|
+
bindings = {}
|
|
385
|
+
importLine = {}
|
|
386
|
+
for node in ast.walk(tree):
|
|
387
|
+
if not isinstance(node, ast.ImportFrom):
|
|
388
|
+
continue
|
|
389
|
+
module = absModule(
|
|
390
|
+
self.__src_root__, pyfile, node.level, node.module
|
|
391
|
+
)
|
|
392
|
+
for alias in node.names:
|
|
393
|
+
page = self.__symbol_page__.get((module, alias.name))
|
|
394
|
+
if page is None or self.__file_page__.get(rel) == page:
|
|
395
|
+
continue
|
|
396
|
+
bindings[alias.asname or alias.name] = page
|
|
397
|
+
importLine.setdefault(page, node.lineno)
|
|
398
|
+
if not bindings:
|
|
399
|
+
continue
|
|
400
|
+
used = {}
|
|
401
|
+
for node in ast.walk(tree):
|
|
402
|
+
if isinstance(node, ast.Name) and node.id in bindings:
|
|
403
|
+
used.setdefault(bindings[node.id], set()).add(node.lineno)
|
|
404
|
+
for page in set(bindings.values()):
|
|
405
|
+
lines = used.get(page) or {importLine[page]}
|
|
406
|
+
usage.setdefault(page, {})[rel] = sorted(lines)
|
|
407
|
+
return usage
|
|
408
|
+
|
|
409
|
+
def appendSection(self, usage: dict, heading: str) -> None:
|
|
410
|
+
"""Append a cross-reference section to each symbol page.
|
|
411
|
+
|
|
412
|
+
The section is a heading and a raw-HTML list, one row per using file,
|
|
413
|
+
every use line a link to that file's page at '#line-N'. A page that
|
|
414
|
+
was never written is skipped.
|
|
415
|
+
|
|
416
|
+
Parameters
|
|
417
|
+
----------
|
|
418
|
+
usage : dict
|
|
419
|
+
The usage mapping from 'scanUsages'.
|
|
420
|
+
heading : str
|
|
421
|
+
The section heading, for example 'Used in'.
|
|
422
|
+
"""
|
|
423
|
+
for page, files in usage.items():
|
|
424
|
+
path = self.__out__ / ("%s.rst" % page)
|
|
425
|
+
if not path.exists():
|
|
426
|
+
continue
|
|
427
|
+
items = []
|
|
428
|
+
for rel in sorted(files):
|
|
429
|
+
linenos = files[rel]
|
|
430
|
+
target = self.__file_page__.get(rel)
|
|
431
|
+
if target:
|
|
432
|
+
links = ", ".join(
|
|
433
|
+
'<a href="%s.html#line-%d">%d</a>' % (target, n, n)
|
|
434
|
+
for n in linenos
|
|
435
|
+
)
|
|
436
|
+
items.append(" <li>%s: %s</li>" % (rel, links))
|
|
437
|
+
else:
|
|
438
|
+
nums = ", ".join(str(n) for n in linenos)
|
|
439
|
+
items.append(" <li>%s: %s</li>" % (rel, nums))
|
|
440
|
+
block = [
|
|
441
|
+
"", heading, "-" * len(heading), "", ".. raw:: html", "",
|
|
442
|
+
" <ul>"
|
|
443
|
+
] + items + [" </ul>"]
|
|
444
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
445
|
+
handle.write("\n".join(block) + "\n")
|
|
446
|
+
|
|
447
|
+
# ----------------------------------------------------------
|
|
448
|
+
# Go-to-definition links
|
|
449
|
+
# ----------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
def definitionLinks(self, pyfile: Path) -> dict:
|
|
452
|
+
"""Map the source offset of every linkable name to its definition.
|
|
453
|
+
|
|
454
|
+
Four kinds are resolved, in order of precedence: a name imported from
|
|
455
|
+
a documented project symbol, to that symbol's page at '#line-N'; a
|
|
456
|
+
name imported from the standard library, to its docs.python.org page;
|
|
457
|
+
a name defined at module scope in this file, back to its definition on
|
|
458
|
+
this page; and a builtin, to its docs.python.org page. A name shadowed
|
|
459
|
+
by a local binding is left unlinked, so a link never points at the
|
|
460
|
+
wrong definition.
|
|
461
|
+
|
|
462
|
+
Parameters
|
|
463
|
+
----------
|
|
464
|
+
pyfile : Path
|
|
465
|
+
The source file to resolve links for.
|
|
466
|
+
|
|
467
|
+
Returns
|
|
468
|
+
-------
|
|
469
|
+
dict
|
|
470
|
+
A mapping of absolute source offset to link target. Empty when the
|
|
471
|
+
file does not parse.
|
|
472
|
+
"""
|
|
473
|
+
source = pyfile.read_text(encoding="utf-8")
|
|
474
|
+
try:
|
|
475
|
+
tree = ast.parse(source)
|
|
476
|
+
except SyntaxError:
|
|
477
|
+
return {}
|
|
478
|
+
starts = lineStarts(source)
|
|
479
|
+
selfPage = self.__file_page__.get(self.relPath(pyfile))
|
|
480
|
+
linkMap = {}
|
|
481
|
+
|
|
482
|
+
# Imported names: project symbols and standard-library members.
|
|
483
|
+
bindings = {}
|
|
484
|
+
aliasSites = []
|
|
485
|
+
for node in ast.walk(tree):
|
|
486
|
+
if isinstance(node, ast.ImportFrom):
|
|
487
|
+
module = absModule(
|
|
488
|
+
self.__src_root__, pyfile, node.level, node.module
|
|
489
|
+
)
|
|
490
|
+
for alias in node.names:
|
|
491
|
+
href = None
|
|
492
|
+
target = self.__symbol_def__.get((module, alias.name))
|
|
493
|
+
if target is not None and target[0] != selfPage:
|
|
494
|
+
href = "%s.html#line-%d" % target
|
|
495
|
+
elif (node.level == 0 and alias.name != "*"
|
|
496
|
+
and isStdlib(node.module)):
|
|
497
|
+
# docs.python.org strips leading underscores from the module
|
|
498
|
+
# in the anchor id, so '__future__' becomes 'future__'.
|
|
499
|
+
href = "%slibrary/%s.html#%s.%s" % (
|
|
500
|
+
PYDOCS, node.module, node.module.lstrip("_"), alias.name
|
|
501
|
+
)
|
|
502
|
+
if href is None:
|
|
503
|
+
continue
|
|
504
|
+
local = alias.asname or alias.name
|
|
505
|
+
bindings[local] = href
|
|
506
|
+
addAliasSite(aliasSites, alias, starts, local)
|
|
507
|
+
elif isinstance(node, ast.Import):
|
|
508
|
+
for alias in node.names:
|
|
509
|
+
if not isStdlib(alias.name):
|
|
510
|
+
continue
|
|
511
|
+
bound = alias.asname or alias.name.split(".")[0]
|
|
512
|
+
module = alias.name if alias.asname else alias.name.split(".")[0]
|
|
513
|
+
bindings[bound] = "%slibrary/%s.html" % (PYDOCS, module)
|
|
514
|
+
addAliasSite(aliasSites, alias, starts, bound)
|
|
515
|
+
|
|
516
|
+
if bindings:
|
|
517
|
+
shadowed = set()
|
|
518
|
+
for node in ast.walk(tree):
|
|
519
|
+
if isinstance(node, ast.Name) and isinstance(
|
|
520
|
+
node.ctx, (ast.Store, ast.Del)
|
|
521
|
+
):
|
|
522
|
+
shadowed.add(node.id)
|
|
523
|
+
elif isinstance(node, ast.arg):
|
|
524
|
+
shadowed.add(node.arg)
|
|
525
|
+
for offset, name in aliasSites:
|
|
526
|
+
if name not in shadowed:
|
|
527
|
+
linkMap.setdefault(offset, bindings[name])
|
|
528
|
+
for node in ast.walk(tree):
|
|
529
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
|
|
530
|
+
if node.id in bindings and node.id not in shadowed:
|
|
531
|
+
offset = starts[node.lineno - 1] + node.col_offset
|
|
532
|
+
linkMap.setdefault(offset, bindings[node.id])
|
|
533
|
+
|
|
534
|
+
# Names defined at module scope: link each later use back to its
|
|
535
|
+
# definition on this same page.
|
|
536
|
+
moduleDefs = moduleDefinitions(tree)
|
|
537
|
+
if selfPage is not None and moduleDefs:
|
|
538
|
+
nested = nestedBound(tree)
|
|
539
|
+
for node in ast.walk(tree):
|
|
540
|
+
if not isinstance(node, ast.Name):
|
|
541
|
+
continue
|
|
542
|
+
if not isinstance(node.ctx, ast.Load):
|
|
543
|
+
continue
|
|
544
|
+
name = node.id
|
|
545
|
+
if name not in moduleDefs or name in nested:
|
|
546
|
+
continue
|
|
547
|
+
defLine = moduleDefs[name]
|
|
548
|
+
if node.lineno == defLine:
|
|
549
|
+
continue
|
|
550
|
+
offset = starts[node.lineno - 1] + node.col_offset
|
|
551
|
+
linkMap.setdefault(offset, "%s.html#line-%d" % (selfPage, defLine))
|
|
552
|
+
|
|
553
|
+
# Builtins: link every unshadowed use to docs.python.org.
|
|
554
|
+
bound = boundAnywhere(tree)
|
|
555
|
+
for node in ast.walk(tree):
|
|
556
|
+
if not isinstance(node, ast.Name) or not isinstance(
|
|
557
|
+
node.ctx, ast.Load
|
|
558
|
+
):
|
|
559
|
+
continue
|
|
560
|
+
if node.id in BUILTIN_LINKS and node.id not in bound:
|
|
561
|
+
offset = starts[node.lineno - 1] + node.col_offset
|
|
562
|
+
linkMap.setdefault(offset, BUILTIN_LINKS[node.id])
|
|
563
|
+
return linkMap
|
|
564
|
+
|
|
565
|
+
def renderPending(self) -> None:
|
|
566
|
+
"""Write the deferred HTML fragment for every recorded page.
|
|
567
|
+
|
|
568
|
+
By now the symbol-definition registry is complete, so a
|
|
569
|
+
go-to-definition link can resolve to a symbol defined anywhere in the
|
|
570
|
+
run.
|
|
571
|
+
"""
|
|
572
|
+
for pyfile, stub in self.__pending_frags__:
|
|
573
|
+
source = pyfile.read_text(encoding="utf-8")
|
|
574
|
+
fragment = highlightWithLinks(source, self.definitionLinks(pyfile))
|
|
575
|
+
path = self.__out__ / ("%s.frag.html" % stub)
|
|
576
|
+
path.write_text(fragment, encoding="utf-8")
|
|
577
|
+
|
|
578
|
+
# ----------------------------------------------------------
|
|
579
|
+
# Entry point
|
|
580
|
+
# ----------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
def run(self) -> None:
|
|
583
|
+
"""Regenerate the output tree: the package, the tests mirror, the
|
|
584
|
+
packages index and the cross-references.
|
|
585
|
+
|
|
586
|
+
The package is documented as a single tree rooted at its __init__, so
|
|
587
|
+
a flat package is rendered just as readily as a nested one. The
|
|
588
|
+
registries are cleared first, so an instance may be run more than
|
|
589
|
+
once.
|
|
590
|
+
"""
|
|
591
|
+
self.__symbol_page__.clear()
|
|
592
|
+
self.__symbol_def__.clear()
|
|
593
|
+
self.__file_page__.clear()
|
|
594
|
+
self.__pending_frags__.clear()
|
|
595
|
+
out = self.__out__
|
|
596
|
+
if out.exists():
|
|
597
|
+
shutil.rmtree(out)
|
|
598
|
+
out.mkdir(parents=True)
|
|
599
|
+
|
|
600
|
+
entries = [self.emitPackage(self.__pkg__)]
|
|
601
|
+
|
|
602
|
+
testEntry = None
|
|
603
|
+
if self.__tests_root__.is_dir():
|
|
604
|
+
testEntry = self.emitTestDir(self.__tests_root__)
|
|
605
|
+
|
|
606
|
+
lines = [".. toctree::", " :maxdepth: 1", ""]
|
|
607
|
+
lines += [" _source/%s" % entry for entry in entries]
|
|
608
|
+
if testEntry:
|
|
609
|
+
lines.append(" _source/%s" % testEntry)
|
|
610
|
+
(out / "_packages.txt").write_text(
|
|
611
|
+
"\n".join(lines) + "\n", encoding="utf-8"
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
self.renderPending()
|
|
615
|
+
|
|
616
|
+
self.appendSection(self.scanUsages(self.__pkg__), "Used in")
|
|
617
|
+
if self.__tests_root__.is_dir():
|
|
618
|
+
self.appendSection(
|
|
619
|
+
self.scanUsages(self.__tests_root__), "Usage in testing"
|
|
620
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The 'title' function builds an rST section title.
|
|
3
|
+
"""
|
|
4
|
+
# Apache-2.0 license
|
|
5
|
+
# Copyright (c) 2026 Asger Jon Vistisen
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def title(text: str) -> str:
|
|
9
|
+
"""Return an rST title: the text underlined by '=' of equal length.
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
----------
|
|
13
|
+
text : str
|
|
14
|
+
The title text.
|
|
15
|
+
|
|
16
|
+
Returns
|
|
17
|
+
-------
|
|
18
|
+
str
|
|
19
|
+
The text followed by a newline and a rule of '=' the same width.
|
|
20
|
+
|
|
21
|
+
Examples
|
|
22
|
+
--------
|
|
23
|
+
>>> title('abc')
|
|
24
|
+
'abc\\n==='
|
|
25
|
+
"""
|
|
26
|
+
return "%s\n%s" % (text, "=" * len(text))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The 'tokenHtml' function renders one Pygments token to HTML, optionally
|
|
3
|
+
wrapped in a go-to-definition anchor.
|
|
4
|
+
"""
|
|
5
|
+
# Apache-2.0 license
|
|
6
|
+
# Copyright (c) 2026 Asger Jon Vistisen
|
|
7
|
+
|
|
8
|
+
import html
|
|
9
|
+
|
|
10
|
+
# The exact character-to-entity table the Pygments HtmlFormatter uses for
|
|
11
|
+
# text content, so a token's escaped text is byte-identical to stock
|
|
12
|
+
# Pygments. It escapes the five markup-significant characters, rendering
|
|
13
|
+
# the apostrophe as the decimal ''' rather than Python html.escape's
|
|
14
|
+
# hexadecimal '''.
|
|
15
|
+
_ESCAPE = {
|
|
16
|
+
ord("&"): "&",
|
|
17
|
+
ord("<"): "<",
|
|
18
|
+
ord(">"): ">",
|
|
19
|
+
ord('"'): """,
|
|
20
|
+
ord("'"): "'",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def tokenHtml(css: str, value: str, href: str) -> str:
|
|
25
|
+
"""Return the HTML for one token.
|
|
26
|
+
|
|
27
|
+
The escaped text is wrapped in its Pygments class span when 'css' is
|
|
28
|
+
given, and wrapped again in a go-to-definition anchor when 'href' is
|
|
29
|
+
given. An external (docs.python.org) target gets a second class and
|
|
30
|
+
opens in a new tab, so a jump to the Python docs does not navigate away
|
|
31
|
+
from the project docs.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
css : str
|
|
36
|
+
The Pygments CSS class, or the empty string for no span.
|
|
37
|
+
value : str
|
|
38
|
+
The token's literal text.
|
|
39
|
+
href : str
|
|
40
|
+
The link target, or the empty string for no anchor. A target
|
|
41
|
+
starting with 'http' is treated as external.
|
|
42
|
+
|
|
43
|
+
Returns
|
|
44
|
+
-------
|
|
45
|
+
str
|
|
46
|
+
The rendered HTML fragment for the token.
|
|
47
|
+
"""
|
|
48
|
+
inner = value.translate(_ESCAPE)
|
|
49
|
+
if css:
|
|
50
|
+
inner = '<span class="%s">%s</span>' % (css, inner)
|
|
51
|
+
if not href:
|
|
52
|
+
return inner
|
|
53
|
+
if href.startswith("http"):
|
|
54
|
+
return ('<a class="srcref srcref-ext" target="_blank" rel="noopener" '
|
|
55
|
+
'href="%s">%s</a>' % (html.escape(href, quote=True), inner))
|
|
56
|
+
return '<a class="srcref" href="%s">%s</a>' % (
|
|
57
|
+
html.escape(href, quote=True), inner
|
|
58
|
+
)
|