markwork 0.1.1.dev0__py3-none-any.whl → 0.1.2.dev0__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.
Files changed (36) hide show
  1. markwork/__init__.py +22 -7
  2. markwork/__main__.py +11 -0
  3. markwork/_cli.py +279 -0
  4. markwork/_layout.py +214 -0
  5. markwork/engine/__init__.py +24 -12
  6. markwork/engine/_abs_module.py +6 -2
  7. markwork/engine/_attribute_chain.py +37 -0
  8. markwork/engine/_char_offset.py +46 -0
  9. markwork/engine/_collapse_navigation.py +42 -0
  10. markwork/engine/_detect_package.py +18 -7
  11. markwork/engine/_generate.py +15 -4
  12. markwork/engine/_highlight.py +7 -9
  13. markwork/engine/_lex_tokens.py +35 -0
  14. markwork/engine/_module_definitions.py +21 -9
  15. markwork/engine/_module_statements.py +51 -0
  16. markwork/engine/_parse_init.py +61 -20
  17. markwork/engine/_parse_source.py +37 -0
  18. markwork/engine/_read_source.py +40 -0
  19. markwork/engine/_resolve_scopes.py +165 -0
  20. markwork/engine/_rst_text.py +39 -0
  21. markwork/engine/_scope.py +119 -0
  22. markwork/engine/_source_docs.py +722 -174
  23. markwork/engine/_title.py +12 -4
  24. markwork/engine/_token_html.py +25 -10
  25. {markwork-0.1.1.dev0.dist-info → markwork-0.1.2.dev0.dist-info}/METADATA +82 -19
  26. markwork-0.1.2.dev0.dist-info/RECORD +37 -0
  27. {markwork-0.1.1.dev0.dist-info → markwork-0.1.2.dev0.dist-info}/WHEEL +1 -1
  28. markwork-0.1.2.dev0.dist-info/entry_points.txt +2 -0
  29. markwork/engine/_add_alias_site.py +0 -31
  30. markwork/engine/_bound_anywhere.py +0 -42
  31. markwork/engine/_nested_bound.py +0 -53
  32. markwork/engine/_top_level_def_lines.py +0 -34
  33. markwork/engine/_top_level_defs.py +0 -33
  34. markwork-0.1.1.dev0.dist-info/RECORD +0 -28
  35. {markwork-0.1.1.dev0.dist-info → markwork-0.1.2.dev0.dist-info}/licenses/LICENSE +0 -0
  36. {markwork-0.1.1.dev0.dist-info → markwork-0.1.2.dev0.dist-info}/top_level.txt +0 -0
markwork/__init__.py CHANGED
@@ -9,12 +9,12 @@ A project enables it with a single line in conf.py:
9
9
  extensions = ["markwork"]
10
10
 
11
11
  The generator then runs at 'builder-inited', writing one page per source
12
- file into the docs source tree before Sphinx reads it. Three configuration
12
+ file into the docs source tree before Sphinx reads it. Four configuration
13
13
  values steer it: markwork_package names the package to document (or it is
14
14
  auto-detected when a single package sits under the source root),
15
15
  markwork_src_root and markwork_tests_root give the source and test roots
16
- relative to the project, which is taken to be the parent of the docs
17
- directory.
16
+ relative to the project, and markwork_repo_root names the project root,
17
+ relative to the docs directory, which defaults to that directory's parent.
18
18
  """
19
19
  # Apache-2.0 license
20
20
  # Copyright (c) 2026 Asger Jon Vistisen
@@ -22,9 +22,9 @@ directory.
22
22
  from pathlib import Path
23
23
 
24
24
  from . import engine
25
- from .engine import generate
25
+ from .engine import collapseNavigation, generate
26
26
 
27
- __version__ = "0.1.1-dev0"
27
+ __version__ = "0.1.2-dev0"
28
28
 
29
29
  __all__ = ["setup", "generate", "engine"]
30
30
 
@@ -33,16 +33,31 @@ def setup(app: object) -> dict:
33
33
  """Register markwork as a Sphinx extension: declare its configuration
34
34
  values, run the generator before Sphinx reads sources, and ship the
35
35
  stylesheet that styles the go-to-definition anchors and the line-jump
36
- highlight, so a project manages no CSS paths of its own."""
36
+ highlight, so a project manages no CSS paths of its own.
37
+
38
+ markwork_repo_root pins the documented project root; left unset it
39
+ defaults to the parent of the docs directory, so an in-tree docs build
40
+ needs no extra configuration while the 'markwork' CLI can point the
41
+ engine at an arbitrary repository from a throwaway conf directory.
42
+ markwork_collapse_navigation (True by default) keeps every page's
43
+ navigation tree to its own branch; see 'collapseNavigation'."""
37
44
  app.add_config_value("markwork_package", None, "env")
38
45
  app.add_config_value("markwork_src_root", "src", "env")
39
46
  app.add_config_value("markwork_tests_root", "tests", "env")
47
+ app.add_config_value("markwork_repo_root", None, "env")
48
+ app.add_config_value("markwork_collapse_navigation", True, "html")
40
49
  app.connect("builder-inited", generate)
50
+ # Ahead of the theme's own handler (default priority 500), so the
51
+ # theme builds its sidebar from the collapsed tree.
52
+ app.connect("html-page-context", collapseNavigation, priority=400)
41
53
  static = str(Path(__file__).parent / "etc")
42
54
  app.config.html_static_path.append(static)
43
55
  app.add_css_file("markwork.css")
56
+ # The generator runs once, at builder-inited, before any source is
57
+ # read, and keeps nothing in the build environment, so reading and
58
+ # writing may both run in parallel ('sphinx-build -j auto').
44
59
  return {
45
60
  "version": __version__,
46
- "parallel_read_safe": False,
61
+ "parallel_read_safe": True,
47
62
  "parallel_write_safe": True,
48
63
  }
markwork/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ """
2
+ Run markwork as a module: 'python -m markwork <repo>' is the same
3
+ ephemeral build as the 'markwork' console script.
4
+ """
5
+ # Apache-2.0 license
6
+ # Copyright (c) 2026 Asger Jon Vistisen
7
+
8
+ from ._cli import main
9
+
10
+ if __name__ == "__main__":
11
+ raise SystemExit(main())
markwork/_cli.py ADDED
@@ -0,0 +1,279 @@
1
+ """
2
+ The markwork command line: point it at a repository and it generates the
3
+ click-through Sphinx docs, with no conf.py, index.rst or scaffolding left
4
+ behind in the project.
5
+
6
+ The build is ephemeral. A throwaway conf.py and index.rst are written into
7
+ a temporary directory that doubles as the Sphinx source directory; the
8
+ 'markwork_repo_root' config value points the engine back at the real
9
+ project, so its source is read in place and only the rendered HTML is
10
+ produced. The temporary directory is removed afterwards, so the repository
11
+ is never touched.
12
+ """
13
+ # Apache-2.0 license
14
+ # Copyright (c) 2026 Asger Jon Vistisen
15
+
16
+ import argparse
17
+ import importlib.util
18
+ import os
19
+ import shutil
20
+ import sys
21
+ import tempfile
22
+ import webbrowser
23
+ from pathlib import Path
24
+
25
+ from sphinx.application import Sphinx
26
+ from sphinx.errors import SphinxError
27
+
28
+ from ._layout import detectLayout, readPyproject
29
+
30
+
31
+ def projectMeta(repoRoot) -> tuple:
32
+ """Read the project name and version from a repo's pyproject.toml.
33
+
34
+ Falls back to the repository directory name and an empty version when
35
+ there is no pyproject.toml or it names neither, so the build always has a
36
+ heading to render.
37
+
38
+ Parameters
39
+ ----------
40
+ repoRoot : str or Path
41
+ The repository root to read.
42
+
43
+ Returns
44
+ -------
45
+ tuple
46
+ A pair '(name, version)' of strings.
47
+ """
48
+ repoRoot = Path(repoRoot)
49
+ project = readPyproject(repoRoot).get("project", {})
50
+ name, version = project.get("name"), project.get("version")
51
+ return name or repoRoot.name, version or ""
52
+
53
+
54
+ def defaultTheme() -> str:
55
+ """Return furo when it is installed, else Sphinx's built-in alabaster,
56
+ so the command works even where furo was left out.
57
+
58
+ Returns
59
+ -------
60
+ str
61
+ The HTML theme name.
62
+ """
63
+ return "furo" if importlib.util.find_spec("furo") else "alabaster"
64
+
65
+
66
+ def writeWorkspace(
67
+ work: Path, repoRoot: Path, srcRoot: str, testsRoot: str, package,
68
+ project: str, release: str, theme: str
69
+ ) -> None:
70
+ """Write the throwaway conf.py and index.rst into the work directory.
71
+
72
+ The conf enables only the markwork extension and the chosen theme, and
73
+ hands the engine the repo root and the source, tests and package
74
+ settings. The index gives the build its title and pulls in the package
75
+ index the engine writes during the build.
76
+
77
+ Parameters
78
+ ----------
79
+ work : Path
80
+ The temporary Sphinx source directory.
81
+ repoRoot : Path
82
+ The repository being documented.
83
+ srcRoot : str
84
+ The source root, relative to the repo root.
85
+ testsRoot : str
86
+ The tests root, relative to the repo root.
87
+ package : str or None
88
+ The package name, or None to auto-detect.
89
+ project : str
90
+ The documentation title.
91
+ release : str
92
+ The version string shown in the header.
93
+ theme : str
94
+ The Sphinx HTML theme.
95
+ """
96
+ conf = "\n".join(
97
+ [
98
+ "# Generated by markwork; ephemeral, safe to delete.",
99
+ "project = %r" % project,
100
+ "release = %r" % release,
101
+ "version = %r" % release,
102
+ 'extensions = ["markwork"]',
103
+ "html_theme = %r" % theme,
104
+ "markwork_repo_root = %r" % str(repoRoot),
105
+ "markwork_src_root = %r" % srcRoot,
106
+ "markwork_tests_root = %r" % testsRoot,
107
+ "markwork_package = %r" % package,
108
+ 'exclude_patterns = ["_build", ".doctrees"]',
109
+ # A page's rST is a generated wrapper around its source, so the
110
+ # theme's "view source" link and the copies behind it add weight
111
+ # and show nothing worth reading.
112
+ "html_copy_source = False",
113
+ "html_show_sourcelink = False",
114
+ "",
115
+ ]
116
+ )
117
+ (work / "conf.py").write_text(conf, encoding="utf-8")
118
+ heading = project or repoRoot.name
119
+ index = "\n".join(
120
+ [
121
+ heading,
122
+ "=" * len(heading),
123
+ "",
124
+ ".. include:: _source/_packages.txt",
125
+ "",
126
+ ]
127
+ )
128
+ (work / "index.rst").write_text(index, encoding="utf-8")
129
+
130
+
131
+ def build(
132
+ repo, *, srcRoot=None, testsRoot=None, package=None, output=None,
133
+ theme=None, quiet=False, jobs=None
134
+ ) -> Path:
135
+ """Generate the markwork HTML for a repository and return the output dir.
136
+
137
+ The source root and package are resolved by 'detectLayout' from what the
138
+ project declares and from convention, and the tests are taken from
139
+ 'tests/'; each can be named instead. A temporary directory holds the generated conf.py and
140
+ index.rst and serves as the Sphinx source directory; the engine reads the
141
+ project's source in place via 'markwork_repo_root' and the HTML is built
142
+ into the output directory. The temporary directory is removed afterwards,
143
+ so the repository is left untouched.
144
+
145
+ Parameters
146
+ ----------
147
+ repo : str or Path
148
+ The repository root to document.
149
+ srcRoot : str, optional
150
+ The source root relative to the repo; auto-detected when omitted.
151
+ testsRoot : str, optional
152
+ The tests root relative to the repo; 'tests' when omitted.
153
+ package : str, optional
154
+ The package name; auto-detected when omitted.
155
+ output : str or Path, optional
156
+ The HTML output directory; a temporary one is made when omitted.
157
+ theme : str, optional
158
+ The Sphinx HTML theme; 'defaultTheme' picks one when omitted.
159
+ quiet : bool
160
+ Suppress Sphinx's progress output when True.
161
+ jobs : int, optional
162
+ The number of parallel Sphinx workers, one per CPU when omitted.
163
+ markwork does its own work before Sphinx starts and declares itself
164
+ safe for parallel writing, which is where a large project spends its
165
+ time, so the pages come out identical either way.
166
+
167
+ Returns
168
+ -------
169
+ Path
170
+ The output directory the HTML was written to.
171
+ """
172
+ repoRoot = Path(repo).resolve()
173
+ if not repoRoot.is_dir():
174
+ raise NotADirectoryError(str(repoRoot))
175
+ srcRoot, testsRoot, package = detectLayout(
176
+ repoRoot, src=srcRoot, tests=testsRoot, package=package
177
+ )
178
+ project, release = projectMeta(repoRoot)
179
+ theme = theme or defaultTheme()
180
+ if not quiet:
181
+ where = "the repo root" if srcRoot == "." else "%s/" % srcRoot
182
+ print(
183
+ "markwork: documenting %s under %s, tests: %s"
184
+ % (package, where, testsRoot or "(none)")
185
+ )
186
+ if output is None:
187
+ output = tempfile.mkdtemp(prefix="markwork-html-")
188
+ output = Path(output).resolve()
189
+ work = Path(tempfile.mkdtemp(prefix="markwork-conf-"))
190
+ try:
191
+ writeWorkspace(
192
+ work, repoRoot, srcRoot, testsRoot or "tests", package, project,
193
+ release, theme
194
+ )
195
+ workers = jobs if jobs is not None else os.cpu_count() or 1
196
+ app = Sphinx(
197
+ str(work), str(work), str(output), str(work / ".doctrees"), "html",
198
+ status=None if quiet else sys.stdout, warning=sys.stderr,
199
+ parallel=workers if workers > 1 else 0,
200
+ )
201
+ app.build()
202
+ finally:
203
+ shutil.rmtree(work, ignore_errors=True)
204
+ return output
205
+
206
+
207
+ def main(argv=None) -> int:
208
+ """Parse arguments and run an ephemeral markwork build.
209
+
210
+ Parameters
211
+ ----------
212
+ argv : list of str, optional
213
+ The argument vector; sys.argv is used when omitted.
214
+
215
+ Returns
216
+ -------
217
+ int
218
+ The process exit code.
219
+ """
220
+ parser = argparse.ArgumentParser(
221
+ prog="markwork",
222
+ description="Generate click-through Sphinx docs for a repository, "
223
+ "with no project setup.",
224
+ )
225
+ parser.add_argument(
226
+ "repo", nargs="?", default=".", help="repository root (default: .)"
227
+ )
228
+ parser.add_argument(
229
+ "-o", "--output", default=None,
230
+ help="HTML output directory (default: a temporary directory)",
231
+ )
232
+ parser.add_argument(
233
+ "--src", default=None,
234
+ help="source root relative to the repo (default: auto-detect)",
235
+ )
236
+ parser.add_argument(
237
+ "--tests", default=None,
238
+ help="tests root relative to the repo (default: tests)",
239
+ )
240
+ parser.add_argument(
241
+ "--package", default=None,
242
+ help="package to document (default: auto-detect)",
243
+ )
244
+ parser.add_argument(
245
+ "--theme", default=None,
246
+ help="Sphinx HTML theme (default: furo if installed, else alabaster)",
247
+ )
248
+ parser.add_argument(
249
+ "--open", action="store_true", dest="open_browser",
250
+ help="open the rendered docs in a browser when done",
251
+ )
252
+ parser.add_argument(
253
+ "-q", "--quiet", action="store_true",
254
+ help="suppress Sphinx progress output",
255
+ )
256
+ parser.add_argument(
257
+ "-j", "--jobs", type=int, default=None,
258
+ help="parallel Sphinx workers (default: one per CPU)",
259
+ )
260
+ args = parser.parse_args(argv)
261
+ try:
262
+ output = build(
263
+ args.repo, srcRoot=args.src, testsRoot=args.tests,
264
+ package=args.package, output=args.output, theme=args.theme,
265
+ quiet=args.quiet, jobs=args.jobs,
266
+ )
267
+ except (RuntimeError, NotADirectoryError) as error:
268
+ print(str(error), file=sys.stderr)
269
+ return 1
270
+ except SphinxError as error:
271
+ # A bad theme, a broken extension and the like: Sphinx's own message
272
+ # is clear, so it is shown without the traceback.
273
+ print("markwork: Sphinx stopped: %s" % error, file=sys.stderr)
274
+ return 2
275
+ index = output / "index.html"
276
+ print("markwork: wrote %s" % index)
277
+ if args.open_browser:
278
+ webbrowser.open(index.as_uri())
279
+ return 0
markwork/_layout.py ADDED
@@ -0,0 +1,214 @@
1
+ """
2
+ Layout discovery for the markwork command line: work out where a
3
+ repository keeps its source, reading what the project already declares
4
+ before falling back to convention.
5
+
6
+ The source root and package come from the build configuration in
7
+ pyproject.toml (setuptools 'packages.find.where' / 'package-dir', with the
8
+ distribution name disambiguating when several packages qualify). When it
9
+ is not declared, convention decides: 'src/' if it holds a package and the
10
+ repo root otherwise. The tests are expected in 'tests/'. Nothing searches
11
+ for them: a project that keeps them elsewhere names the folder. Explicit
12
+ overrides always win, and an ambiguous package fails loudly rather than
13
+ guessing. Nothing here imports the documented project; it only reads
14
+ files.
15
+ """
16
+ # Apache-2.0 license
17
+ # Copyright (c) 2026 Asger Jon Vistisen
18
+
19
+ import tomllib
20
+ from pathlib import Path
21
+
22
+ # Conventional top-level directory names that hold tests, docs or tooling
23
+ # rather than the package, consulted only to settle an ambiguity in a
24
+ # flat layout.
25
+ _NOT_PACKAGES = frozenset(
26
+ {
27
+ "tests", "test", "testing", "docs", "doc", "examples", "example",
28
+ "benchmarks", "scripts", "tools",
29
+ }
30
+ )
31
+
32
+
33
+ def readPyproject(repoRoot) -> dict:
34
+ """Parse a repo's pyproject.toml, or return an empty mapping.
35
+
36
+ Returns an empty dict when there is no file or when it cannot be parsed,
37
+ so callers degrade to convention rather than fail.
38
+
39
+ Parameters
40
+ ----------
41
+ repoRoot : str or Path
42
+ The repository root to read from.
43
+
44
+ Returns
45
+ -------
46
+ dict
47
+ The parsed pyproject.toml, or an empty mapping.
48
+ """
49
+ path = Path(repoRoot) / "pyproject.toml"
50
+ if not path.is_file():
51
+ return {}
52
+ try:
53
+ with path.open("rb") as handle:
54
+ return tomllib.load(handle)
55
+ except (OSError, tomllib.TOMLDecodeError):
56
+ return {}
57
+
58
+
59
+ def _hasPackage(directory: Path) -> bool:
60
+ """Return whether a directory holds at least one importable package."""
61
+ return any(
62
+ child.is_dir() and (child / "__init__.py").is_file()
63
+ for child in directory.iterdir()
64
+ )
65
+
66
+
67
+ def detectSrcRoot(repoRoot, data: dict) -> str:
68
+ """Return the source root, relative to the repo root.
69
+
70
+ The setuptools 'packages.find.where' and 'package-dir' settings are read
71
+ first; failing that, 'src' is used when it holds a package and the repo
72
+ root ('.') otherwise.
73
+
74
+ Parameters
75
+ ----------
76
+ repoRoot : str or Path
77
+ The repository root.
78
+ data : dict
79
+ The parsed pyproject.toml.
80
+
81
+ Returns
82
+ -------
83
+ str
84
+ The source root relative to the repo root.
85
+ """
86
+ setuptools = data.get("tool", {}).get("setuptools", {})
87
+ packages = setuptools.get("packages", {})
88
+ if isinstance(packages, dict):
89
+ where = packages.get("find", {}).get("where")
90
+ if isinstance(where, list) and where:
91
+ return where[0]
92
+ packageDir = setuptools.get("package-dir", {})
93
+ if isinstance(packageDir, dict) and isinstance(packageDir.get(""), str):
94
+ return packageDir[""]
95
+ srcDir = Path(repoRoot) / "src"
96
+ if srcDir.is_dir() and _hasPackage(srcDir):
97
+ return "src"
98
+ return "."
99
+
100
+
101
+ def detectPackageName(srcDir, hint) -> str:
102
+ """Return the single package under a source root, or raise.
103
+
104
+ A hint (the distribution name) is honoured when it matches a package,
105
+ matching case-insensitively; otherwise a lone package wins. In a flat
106
+ layout the tests or docs often hold an __init__.py too, so when exactly
107
+ one candidate is left after setting aside the conventional names for
108
+ those ('tests', 'docs', 'examples' and the like), that one wins.
109
+ Anything else is ambiguous and raises, so a multi-package source root
110
+ fails loudly rather than documenting the wrong tree.
111
+
112
+ Parameters
113
+ ----------
114
+ srcDir : str or Path
115
+ The resolved source root directory.
116
+ hint : str or None
117
+ The expected package name, or None.
118
+
119
+ Returns
120
+ -------
121
+ str
122
+ The package name.
123
+
124
+ Raises
125
+ ------
126
+ RuntimeError
127
+ When the source root does not exist or holds no single package and
128
+ the hint does not pick one.
129
+ """
130
+ srcDir = Path(srcDir)
131
+ if not srcDir.is_dir():
132
+ raise RuntimeError("markwork: source root %s does not exist" % srcDir)
133
+ candidates = sorted(
134
+ child.name for child in srcDir.iterdir()
135
+ if child.is_dir() and (child / "__init__.py").is_file()
136
+ )
137
+ if hint:
138
+ lowered = {name.lower(): name for name in candidates}
139
+ match = lowered.get(hint.lower())
140
+ if match is not None:
141
+ return match
142
+ if len(candidates) == 1:
143
+ return candidates[0]
144
+ narrowed = [name for name in candidates if name.lower() not in _NOT_PACKAGES]
145
+ if len(narrowed) == 1:
146
+ return narrowed[0]
147
+ found = ", ".join(candidates) or "none"
148
+ raise RuntimeError(
149
+ "markwork could not pick a package under %s (found: %s); pass "
150
+ "--package (or set markwork_package)" % (srcDir, found)
151
+ )
152
+
153
+
154
+ def detectLayout(repoRoot, *, src=None, tests=None, package=None) -> tuple:
155
+ """Resolve the source root, tests root and package for a repository.
156
+
157
+ Each explicit argument is honoured as given. The source root and the
158
+ package are otherwise detected from what the project declares, then from
159
+ convention; the tests are taken from 'tests/' and never searched for.
160
+ The package is resolved to a concrete name (raising when ambiguous), so
161
+ the build is deterministic and fails before Sphinx is invoked rather
162
+ than during it.
163
+
164
+ Parameters
165
+ ----------
166
+ repoRoot : str or Path
167
+ The repository root.
168
+ src : str, optional
169
+ An explicit source root relative to the repo.
170
+ tests : str, optional
171
+ The tests root relative to the repo, for a project that keeps its
172
+ tests somewhere other than 'tests/'.
173
+ package : str, optional
174
+ An explicit package name.
175
+
176
+ Returns
177
+ -------
178
+ tuple
179
+ A triple '(srcRoot, testsRoot, package)'. 'srcRoot' and 'package' are
180
+ strings; 'testsRoot' is a string, or None when no tests root is
181
+ named and the repository has no 'tests/' folder.
182
+
183
+ Raises
184
+ ------
185
+ RuntimeError
186
+ When the package cannot be resolved, or when a named tests root
187
+ does not exist.
188
+ """
189
+ repoRoot = Path(repoRoot)
190
+ data = readPyproject(repoRoot)
191
+ srcRoot = src if src is not None else detectSrcRoot(repoRoot, data)
192
+ srcDir = repoRoot / srcRoot
193
+ if package is not None:
194
+ if not (srcDir / package).is_dir():
195
+ raise RuntimeError(
196
+ "markwork: package %r not found under %s" % (package, srcDir)
197
+ )
198
+ resolved = package
199
+ else:
200
+ name = data.get("project", {}).get("name")
201
+ hint = (
202
+ name.replace("-", "_").replace(".", "_")
203
+ if isinstance(name, str) else None
204
+ )
205
+ resolved = detectPackageName(srcDir, hint)
206
+ if tests is None:
207
+ testsRoot = "tests" if (repoRoot / "tests").is_dir() else None
208
+ elif (repoRoot / tests).is_dir():
209
+ testsRoot = tests
210
+ else:
211
+ raise RuntimeError(
212
+ "markwork: tests root %r not found under %s" % (tests, repoRoot)
213
+ )
214
+ return srcRoot, testsRoot, resolved
@@ -16,22 +16,28 @@ it documents itself.
16
16
 
17
17
  # Constants
18
18
  from ._pydocs import PYDOCS
19
+ # Reading source
20
+ from ._read_source import readSource
21
+ from ._parse_source import parseSource
19
22
  # Pure rendering helpers
23
+ from ._rst_text import rstText
20
24
  from ._title import title
21
25
  from ._line_starts import lineStarts
26
+ from ._char_offset import charOffset
22
27
  from ._css_class import cssClass
23
28
  from ._token_html import tokenHtml
24
29
  from ._merge_tokens import mergeTokens
30
+ from ._lex_tokens import lexTokens
25
31
  from ._highlight import highlightWithLinks
26
32
  # Pure AST helpers
27
- from ._top_level_defs import topLevelDefs
28
- from ._top_level_def_lines import topLevelDefLines
29
- from ._parse_init import parseInit
33
+ from ._module_statements import moduleStatements
30
34
  from ._module_definitions import moduleDefinitions
31
- from ._nested_bound import nestedBound
32
- from ._bound_anywhere import boundAnywhere
33
- from ._add_alias_site import addAliasSite
35
+ from ._parse_init import parseInit
36
+ from ._attribute_chain import attributeChain
34
37
  from ._abs_module import absModule
38
+ # Scope resolution
39
+ from ._scope import Scope
40
+ from ._resolve_scopes import resolveScopes
35
41
  # Standard-library and builtin link helpers
36
42
  from ._is_stdlib import isStdlib
37
43
  from ._builtin_links import buildBuiltinLinks, BUILTIN_LINKS
@@ -39,27 +45,33 @@ from ._builtin_links import buildBuiltinLinks, BUILTIN_LINKS
39
45
  from ._detect_package import detectPackage
40
46
  from ._source_docs import SourceDocs
41
47
  from ._generate import generate
48
+ from ._collapse_navigation import collapseNavigation
42
49
 
43
50
  __all__ = [
44
51
  "PYDOCS",
52
+ "readSource",
53
+ "parseSource",
54
+ "rstText",
45
55
  "title",
46
56
  "lineStarts",
57
+ "charOffset",
47
58
  "cssClass",
48
59
  "tokenHtml",
49
60
  "mergeTokens",
61
+ "lexTokens",
50
62
  "highlightWithLinks",
51
- "topLevelDefs",
52
- "topLevelDefLines",
53
- "parseInit",
63
+ "moduleStatements",
54
64
  "moduleDefinitions",
55
- "nestedBound",
56
- "boundAnywhere",
57
- "addAliasSite",
65
+ "parseInit",
66
+ "attributeChain",
58
67
  "absModule",
68
+ "Scope",
69
+ "resolveScopes",
59
70
  "isStdlib",
60
71
  "buildBuiltinLinks",
61
72
  "BUILTIN_LINKS",
62
73
  "detectPackage",
63
74
  "SourceDocs",
64
75
  "generate",
76
+ "collapseNavigation",
65
77
  ]
@@ -13,7 +13,8 @@ def absModule(srcRoot: Path, pyfile: Path, level: int, module) -> str:
13
13
 
14
14
  Relative imports (level >= 1) are resolved against the file's package. A
15
15
  relative import in a file outside the source root (for example a test)
16
- cannot name a project symbol, so it resolves to the empty string.
16
+ cannot name a project symbol, and one that climbs above the top-level
17
+ package fails at runtime, so both resolve to the empty string.
17
18
 
18
19
  Parameters
19
20
  ----------
@@ -38,7 +39,10 @@ def absModule(srcRoot: Path, pyfile: Path, level: int, module) -> str:
38
39
  pkgParts = pyfile.parent.relative_to(srcRoot).parts
39
40
  except ValueError:
40
41
  return ""
41
- base = list(pkgParts[:len(pkgParts) - (level - 1)])
42
+ keep = len(pkgParts) - (level - 1)
43
+ if keep < 1:
44
+ return ""
45
+ base = list(pkgParts[:keep])
42
46
  if module:
43
47
  base += module.split(".")
44
48
  return ".".join(base)