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.
- markwork/__init__.py +22 -7
- markwork/__main__.py +11 -0
- markwork/_cli.py +279 -0
- markwork/_layout.py +214 -0
- markwork/engine/__init__.py +24 -12
- markwork/engine/_abs_module.py +6 -2
- markwork/engine/_attribute_chain.py +37 -0
- markwork/engine/_char_offset.py +46 -0
- markwork/engine/_collapse_navigation.py +42 -0
- markwork/engine/_detect_package.py +18 -7
- markwork/engine/_generate.py +15 -4
- markwork/engine/_highlight.py +7 -9
- markwork/engine/_lex_tokens.py +35 -0
- markwork/engine/_module_definitions.py +21 -9
- markwork/engine/_module_statements.py +51 -0
- markwork/engine/_parse_init.py +61 -20
- markwork/engine/_parse_source.py +37 -0
- markwork/engine/_read_source.py +40 -0
- markwork/engine/_resolve_scopes.py +165 -0
- markwork/engine/_rst_text.py +39 -0
- markwork/engine/_scope.py +119 -0
- markwork/engine/_source_docs.py +722 -174
- markwork/engine/_title.py +12 -4
- markwork/engine/_token_html.py +25 -10
- {markwork-0.1.1.dev0.dist-info → markwork-0.1.2.dev0.dist-info}/METADATA +82 -19
- markwork-0.1.2.dev0.dist-info/RECORD +37 -0
- {markwork-0.1.1.dev0.dist-info → markwork-0.1.2.dev0.dist-info}/WHEEL +1 -1
- markwork-0.1.2.dev0.dist-info/entry_points.txt +2 -0
- markwork/engine/_add_alias_site.py +0 -31
- markwork/engine/_bound_anywhere.py +0 -42
- markwork/engine/_nested_bound.py +0 -53
- markwork/engine/_top_level_def_lines.py +0 -34
- markwork/engine/_top_level_defs.py +0 -33
- markwork-0.1.1.dev0.dist-info/RECORD +0 -28
- {markwork-0.1.1.dev0.dist-info → markwork-0.1.2.dev0.dist-info}/licenses/LICENSE +0 -0
- {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.
|
|
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,
|
|
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.
|
|
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":
|
|
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
|
markwork/engine/__init__.py
CHANGED
|
@@ -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 .
|
|
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 .
|
|
32
|
-
from .
|
|
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
|
-
"
|
|
52
|
-
"topLevelDefLines",
|
|
53
|
-
"parseInit",
|
|
63
|
+
"moduleStatements",
|
|
54
64
|
"moduleDefinitions",
|
|
55
|
-
"
|
|
56
|
-
"
|
|
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
|
]
|
markwork/engine/_abs_module.py
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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)
|