markwork 0.1.0.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 ADDED
@@ -0,0 +1,48 @@
1
+ """markwork is a Sphinx extension that renders a project's Python source
2
+ verbatim and makes every name a link to where it is defined: a project
3
+ symbol or a same-file name jumps to its definition, a standard-library
4
+ member or a builtin links to docs.python.org. It replaces autodoc, not
5
+ Sphinx.
6
+
7
+ A project enables it with a single line in conf.py:
8
+
9
+ extensions = ["markwork"]
10
+
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
13
+ values steer it: markwork_package names the package to document (or it is
14
+ auto-detected when a single package sits under the source root),
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.
18
+ """
19
+ # Apache-2.0 license
20
+ # Copyright (c) 2026 Asger Jon Vistisen
21
+
22
+ from pathlib import Path
23
+
24
+ from . import engine
25
+ from .engine import generate
26
+
27
+ __version__ = "0.1.0-dev0"
28
+
29
+ __all__ = ["setup", "generate", "engine"]
30
+
31
+
32
+ def setup(app: object) -> dict:
33
+ """Register markwork as a Sphinx extension: declare its configuration
34
+ values, run the generator before Sphinx reads sources, and ship the
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."""
37
+ app.add_config_value("markwork_package", None, "env")
38
+ app.add_config_value("markwork_src_root", "src", "env")
39
+ app.add_config_value("markwork_tests_root", "tests", "env")
40
+ app.connect("builder-inited", generate)
41
+ static = str(Path(__file__).parent / "etc")
42
+ app.config.html_static_path.append(static)
43
+ app.add_css_file("markwork.css")
44
+ return {
45
+ "version": __version__,
46
+ "parallel_read_safe": False,
47
+ "parallel_write_safe": True,
48
+ }
@@ -0,0 +1,65 @@
1
+ """The engine behind markwork: it reads a project's source as text,
2
+ highlights it with Pygments, resolves every name to where it is defined,
3
+ and writes one navigable page per file.
4
+
5
+ The layer is built without 're', by design and as the tool's signature:
6
+ the AST, the Pygments token stream, and plain string methods do all the
7
+ work. Each leaf helper is a standalone function with no markwork state;
8
+ the stateful 'SourceDocs' class owns the registries and paths and drives
9
+ a whole run. Every symbol is re-exported here, so the engine is a public
10
+ subpackage: 'generate' is the one the 'setup(app)' entry point wires to
11
+ Sphinx, and the rest is open for reuse and is what markwork renders when
12
+ it documents itself.
13
+ """
14
+ # Apache-2.0 license
15
+ # Copyright (c) 2026 Asger Jon Vistisen
16
+
17
+ # Constants
18
+ from ._pydocs import PYDOCS
19
+ # Pure rendering helpers
20
+ from ._title import title
21
+ from ._line_starts import lineStarts
22
+ from ._css_class import cssClass
23
+ from ._token_html import tokenHtml
24
+ from ._merge_tokens import mergeTokens
25
+ from ._highlight import highlightWithLinks
26
+ # Pure AST helpers
27
+ from ._top_level_defs import topLevelDefs
28
+ from ._top_level_def_lines import topLevelDefLines
29
+ from ._parse_init import parseInit
30
+ from ._module_definitions import moduleDefinitions
31
+ from ._nested_bound import nestedBound
32
+ from ._bound_anywhere import boundAnywhere
33
+ from ._add_alias_site import addAliasSite
34
+ from ._abs_module import absModule
35
+ # Standard-library and builtin link helpers
36
+ from ._is_stdlib import isStdlib
37
+ from ._builtin_links import buildBuiltinLinks, BUILTIN_LINKS
38
+ # Package resolution and the run driver
39
+ from ._detect_package import detectPackage
40
+ from ._source_docs import SourceDocs
41
+ from ._generate import generate
42
+
43
+ __all__ = [
44
+ "PYDOCS",
45
+ "title",
46
+ "lineStarts",
47
+ "cssClass",
48
+ "tokenHtml",
49
+ "mergeTokens",
50
+ "highlightWithLinks",
51
+ "topLevelDefs",
52
+ "topLevelDefLines",
53
+ "parseInit",
54
+ "moduleDefinitions",
55
+ "nestedBound",
56
+ "boundAnywhere",
57
+ "addAliasSite",
58
+ "absModule",
59
+ "isStdlib",
60
+ "buildBuiltinLinks",
61
+ "BUILTIN_LINKS",
62
+ "detectPackage",
63
+ "SourceDocs",
64
+ "generate",
65
+ ]
@@ -0,0 +1,44 @@
1
+ """
2
+ The 'absModule' function resolves an import target to an absolute dotted
3
+ module.
4
+ """
5
+ # Apache-2.0 license
6
+ # Copyright (c) 2026 Asger Jon Vistisen
7
+
8
+ from pathlib import Path
9
+
10
+
11
+ def absModule(srcRoot: Path, pyfile: Path, level: int, module) -> str:
12
+ """Resolve an import target to an absolute dotted module.
13
+
14
+ Relative imports (level >= 1) are resolved against the file's package. A
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.
17
+
18
+ Parameters
19
+ ----------
20
+ srcRoot : Path
21
+ The source root the package lives under.
22
+ pyfile : Path
23
+ The file containing the import.
24
+ level : int
25
+ The import's relative level: 0 for an absolute import.
26
+ module : str or None
27
+ The dotted module named by the import, if any.
28
+
29
+ Returns
30
+ -------
31
+ str
32
+ The absolute dotted module, or the empty string when it cannot be
33
+ resolved.
34
+ """
35
+ if level == 0:
36
+ return module or ""
37
+ try:
38
+ pkgParts = pyfile.parent.relative_to(srcRoot).parts
39
+ except ValueError:
40
+ return ""
41
+ base = list(pkgParts[:len(pkgParts) - (level - 1)])
42
+ if module:
43
+ base += module.split(".")
44
+ return ".".join(base)
@@ -0,0 +1,31 @@
1
+ """
2
+ The 'addAliasSite' function records the source offset of an import alias'
3
+ name token.
4
+ """
5
+ # Apache-2.0 license
6
+ # Copyright (c) 2026 Asger Jon Vistisen
7
+
8
+ import ast
9
+
10
+
11
+ def addAliasSite(
12
+ sites: list, alias: ast.alias, starts: list, local: str
13
+ ) -> None:
14
+ """Record the source offset of an import alias' name token.
15
+
16
+ This makes the name as it appears in the 'import' line itself a link.
17
+ The position attributes on ast.alias are guaranteed from Python 3.10,
18
+ markwork's floor, so no presence guard is needed.
19
+
20
+ Parameters
21
+ ----------
22
+ sites : list
23
+ The list of '(offset, local)' pairs to append to.
24
+ alias : ast.alias
25
+ The import alias node.
26
+ starts : list of int
27
+ The per-line start offsets, as from 'lineStarts'.
28
+ local : str
29
+ The local name the alias binds.
30
+ """
31
+ sites.append((starts[alias.lineno - 1] + alias.col_offset, local))
@@ -0,0 +1,42 @@
1
+ """
2
+ The 'boundAnywhere' function collects every name bound anywhere in a file.
3
+ """
4
+ # Apache-2.0 license
5
+ # Copyright (c) 2026 Asger Jon Vistisen
6
+
7
+ import ast
8
+
9
+
10
+ def boundAnywhere(tree: ast.AST) -> set:
11
+ """Return every name bound anywhere in the file, at any scope.
12
+
13
+ This covers assignments and deletions, parameters, class and function
14
+ names, and import targets. A builtin whose name appears here is shadowed
15
+ by local code, so its uses are left unlinked.
16
+
17
+ Parameters
18
+ ----------
19
+ tree : ast.AST
20
+ The parsed module.
21
+
22
+ Returns
23
+ -------
24
+ set of str
25
+ Every bound name found anywhere in the tree.
26
+ """
27
+ names = set()
28
+ for node in ast.walk(tree):
29
+ if isinstance(node, ast.Name) and isinstance(
30
+ node.ctx, (ast.Store, ast.Del)
31
+ ):
32
+ names.add(node.id)
33
+ elif isinstance(node, ast.arg):
34
+ names.add(node.arg)
35
+ elif isinstance(
36
+ node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
37
+ ):
38
+ names.add(node.name)
39
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
40
+ for alias in node.names:
41
+ names.add(alias.asname or alias.name.split(".")[0])
42
+ return names
@@ -0,0 +1,67 @@
1
+ """
2
+ The 'buildBuiltinLinks' function maps every public builtin to its page on
3
+ docs.python.org.
4
+ """
5
+ # Apache-2.0 license
6
+ # Copyright (c) 2026 Asger Jon Vistisen
7
+
8
+ import builtins
9
+
10
+ from ._pydocs import PYDOCS
11
+
12
+ _CONSTANTS = frozenset(
13
+ {"True", "False", "None", "NotImplemented", "Ellipsis"}
14
+ )
15
+
16
+ # The container types are listed on the built-in functions page under a
17
+ # 'func-<name>' anchor rather than a bare '<name>' (that bare anchor lives
18
+ # on the standard types page instead). Every other builtin function or
19
+ # type uses its plain '<name>' anchor there.
20
+ _FUNC_ANCHOR = frozenset(
21
+ {
22
+ "bytearray", "bytes", "dict", "frozenset", "list", "memoryview",
23
+ "range", "set", "str", "tuple"
24
+ }
25
+ )
26
+
27
+ # Builtins added by the site module: documented in their own section of
28
+ # the constants page rather than on the functions page.
29
+ _SITE_CONSTANTS = frozenset(
30
+ {"copyright", "credits", "exit", "license", "quit"}
31
+ )
32
+
33
+
34
+ def buildBuiltinLinks() -> dict:
35
+ """Map every public builtin name to its page on docs.python.org.
36
+
37
+ Exceptions go to the exceptions page; the True/None family and the
38
+ site-module builtins (quit, exit, ...) go to the constants page; the
39
+ container types go to their 'func-<name>' anchor on the built-in
40
+ functions page; and every other function or type goes to its plain
41
+ anchor there.
42
+
43
+ Returns
44
+ -------
45
+ dict
46
+ A mapping of builtin name to its documentation URL.
47
+ """
48
+ links = {}
49
+ for name in dir(builtins):
50
+ if name.startswith("_"):
51
+ continue
52
+ if name in _CONSTANTS or name in _SITE_CONSTANTS:
53
+ links[name] = "%slibrary/constants.html#%s" % (PYDOCS, name)
54
+ continue
55
+ member = getattr(builtins, name)
56
+ if isinstance(member, type) and issubclass(member, BaseException):
57
+ links[name] = "%slibrary/exceptions.html#%s" % (PYDOCS, name)
58
+ elif name in _FUNC_ANCHOR:
59
+ links[name] = "%slibrary/functions.html#func-%s" % (PYDOCS, name)
60
+ else:
61
+ links[name] = "%slibrary/functions.html#%s" % (PYDOCS, name)
62
+ return links
63
+
64
+
65
+ # Computed once at import: the link table the resolver consults for every
66
+ # unshadowed builtin use.
67
+ BUILTIN_LINKS = buildBuiltinLinks()
@@ -0,0 +1,32 @@
1
+ """
2
+ The 'cssClass' function resolves a Pygments token type to its short CSS
3
+ class.
4
+ """
5
+ # Apache-2.0 license
6
+ # Copyright (c) 2026 Asger Jon Vistisen
7
+
8
+ from pygments.token import STANDARD_TYPES
9
+
10
+
11
+ def cssClass(ttype: object) -> str:
12
+ """Return the short Pygments CSS class for a token type.
13
+
14
+ When the exact type is not in the standard table, the lookup walks up to
15
+ a parent type. The root token maps to the empty string, meaning the
16
+ token gets no wrapping span.
17
+
18
+ Parameters
19
+ ----------
20
+ ttype : object
21
+ A Pygments token type.
22
+
23
+ Returns
24
+ -------
25
+ str
26
+ The CSS class, for example 'n', 'k' or 's2', or the empty string
27
+ for the root token.
28
+ """
29
+ node = ttype
30
+ while node not in STANDARD_TYPES:
31
+ node = node.parent
32
+ return STANDARD_TYPES[node]
@@ -0,0 +1,47 @@
1
+ """
2
+ The 'detectPackage' function resolves the package directory to document.
3
+ """
4
+ # Apache-2.0 license
5
+ # Copyright (c) 2026 Asger Jon Vistisen
6
+
7
+ from pathlib import Path
8
+
9
+
10
+ def detectPackage(srcRoot: Path, name) -> Path:
11
+ """Resolve the package directory under the source root.
12
+
13
+ A configured name wins; otherwise the source root must hold exactly one
14
+ importable package and that one is used. Anything else is ambiguous and
15
+ raises, so a misconfigured project fails loudly rather than documenting
16
+ the wrong tree.
17
+
18
+ Parameters
19
+ ----------
20
+ srcRoot : Path
21
+ The source root to search.
22
+ name : str or None
23
+ The configured package name, or None to auto-detect.
24
+
25
+ Returns
26
+ -------
27
+ Path
28
+ The resolved package directory.
29
+
30
+ Raises
31
+ ------
32
+ RuntimeError
33
+ When no name is configured and the source root does not hold
34
+ exactly one importable package.
35
+ """
36
+ if name is not None:
37
+ return srcRoot / name
38
+ candidates = sorted(
39
+ child for child in srcRoot.iterdir()
40
+ if child.is_dir() and (child / "__init__.py").exists()
41
+ )
42
+ if len(candidates) == 1:
43
+ return candidates[0]
44
+ info = ("markwork could not pick a package under %s: set "
45
+ "markwork_package in conf.py. Found: %s")
46
+ names = ", ".join(child.name for child in candidates) or "none"
47
+ raise RuntimeError(info % (srcRoot, names))
@@ -0,0 +1,35 @@
1
+ """
2
+ The 'generate' function runs a documentation pass, resolving its paths
3
+ from the Sphinx application.
4
+ """
5
+ # Apache-2.0 license
6
+ # Copyright (c) 2026 Asger Jon Vistisen
7
+
8
+ from pathlib import Path
9
+
10
+ from ._detect_package import detectPackage
11
+ from ._source_docs import SourceDocs
12
+
13
+
14
+ def generate(app: object) -> None:
15
+ """Resolve the build paths from the Sphinx application and run a
16
+ documentation pass.
17
+
18
+ Wired to 'builder-inited' by the extension's setup(app). The project
19
+ root is the parent of the docs directory; the source and tests roots and
20
+ the package come from the markwork_* configuration values.
21
+
22
+ Parameters
23
+ ----------
24
+ app : object
25
+ The Sphinx application. Its 'confdir', 'srcdir' and 'config' are
26
+ read.
27
+ """
28
+ confdir = Path(app.confdir)
29
+ repoRoot = confdir.parent
30
+ config = app.config
31
+ srcRoot = repoRoot / config.markwork_src_root
32
+ testsRoot = repoRoot / config.markwork_tests_root
33
+ pkg = detectPackage(srcRoot, config.markwork_package)
34
+ out = Path(app.srcdir) / "_source"
35
+ SourceDocs(repoRoot, srcRoot, pkg, testsRoot, out).run()
@@ -0,0 +1,71 @@
1
+ """
2
+ The 'highlightWithLinks' function renders source to highlighted HTML with
3
+ go-to-definition anchors injected.
4
+ """
5
+ # Apache-2.0 license
6
+ # Copyright (c) 2026 Asger Jon Vistisen
7
+
8
+ import bisect
9
+
10
+ from pygments.lexers import PythonLexer
11
+
12
+ from ._css_class import cssClass
13
+ from ._line_starts import lineStarts
14
+ from ._merge_tokens import mergeTokens
15
+ from ._token_html import tokenHtml
16
+
17
+ _LEXER = PythonLexer()
18
+
19
+
20
+ def highlightWithLinks(source: str, linkMap: dict) -> str:
21
+ """Render source to the highlighted HTML the Pygments HtmlFormatter
22
+ produces, wrapping linked tokens in go-to-definition anchors.
23
+
24
+ The output is a '.highlight' block with inline line numbers, each line
25
+ wrapped in '<span id="line-N">' as the '#line-N' jump target and the
26
+ ':target' highlight element. Every token whose start offset is a key of
27
+ 'linkMap' is wrapped in an anchor. The token stream from
28
+ 'get_tokens_unprocessed' carries each token's absolute offset, so the
29
+ anchors land exactly without searching the text. A token holding
30
+ newlines (a string or a run of blank lines) spans several line boxes and
31
+ is never a link target, so it is split across the lines it covers.
32
+
33
+ Parameters
34
+ ----------
35
+ source : str
36
+ The source text.
37
+ linkMap : dict
38
+ A mapping of absolute source offset to link target.
39
+
40
+ Returns
41
+ -------
42
+ str
43
+ The highlighted HTML fragment.
44
+ """
45
+ starts = lineStarts(source)
46
+ count = len(starts) - 1 if source.endswith("\n") else len(starts)
47
+ buffers = [""] * (count + 1)
48
+ for offset, ttype, value in mergeTokens(
49
+ _LEXER.get_tokens_unprocessed(source)
50
+ ):
51
+ css = cssClass(ttype)
52
+ if "\n" in value:
53
+ first = bisect.bisect_right(starts, offset)
54
+ for step, segment in enumerate(value.split("\n")):
55
+ line = first + step
56
+ if segment and line <= count:
57
+ buffers[line] += tokenHtml(css, segment, "")
58
+ continue
59
+ line = bisect.bisect_right(starts, offset)
60
+ buffers[line] += tokenHtml(css, value, linkMap.get(offset))
61
+ # Line numbers are right-aligned to the width of the largest, matching
62
+ # the Pygments HtmlFormatter so the gutter stays straight.
63
+ width = len(str(count))
64
+ parts = ['<div class="highlight"><pre><span></span>']
65
+ for line in range(1, count + 1):
66
+ parts.append(
67
+ '<span id="line-%d"><span class="linenos">%s</span>%s\n'
68
+ '</span>' % (line, str(line).rjust(width), buffers[line])
69
+ )
70
+ parts.append("</pre></div>\n")
71
+ return "".join(parts)
@@ -0,0 +1,36 @@
1
+ """
2
+ The 'isStdlib' function tests whether a module belongs to the standard
3
+ library.
4
+ """
5
+ # Apache-2.0 license
6
+ # Copyright (c) 2026 Asger Jon Vistisen
7
+
8
+ import sys
9
+
10
+ _STDLIB = sys.stdlib_module_names
11
+
12
+
13
+ def isStdlib(module: str) -> bool:
14
+ """Return whether a module belongs to the standard library.
15
+
16
+ Membership is judged by the top-level package, so a submodule such as
17
+ 'collections.abc' resolves too.
18
+
19
+ Parameters
20
+ ----------
21
+ module : str
22
+ A dotted module name, or the empty string.
23
+
24
+ Returns
25
+ -------
26
+ bool
27
+ True if the top-level package is a standard-library module.
28
+
29
+ Examples
30
+ --------
31
+ >>> isStdlib('collections.abc')
32
+ True
33
+ >>> isStdlib('')
34
+ False
35
+ """
36
+ return True if module and module.split(".")[0] in _STDLIB else False
@@ -0,0 +1,35 @@
1
+ """
2
+ The 'lineStarts' function indexes where each source line begins.
3
+ """
4
+ # Apache-2.0 license
5
+ # Copyright (c) 2026 Asger Jon Vistisen
6
+
7
+
8
+ def lineStarts(source: str) -> list:
9
+ """Return the character offset at which each line begins.
10
+
11
+ An AST node's '(lineno, col_offset)' is turned into an absolute offset
12
+ into the source as 'starts[lineno - 1] + col_offset', so the rendered
13
+ anchors land exactly without searching the text.
14
+
15
+ Parameters
16
+ ----------
17
+ source : str
18
+ The source text.
19
+
20
+ Returns
21
+ -------
22
+ list of int
23
+ The offset of the first character of each line. The first entry is
24
+ always 0.
25
+
26
+ Examples
27
+ --------
28
+ >>> lineStarts('ab\\ncd\\n')
29
+ [0, 3, 6]
30
+ """
31
+ starts = [0]
32
+ for index, char in enumerate(source):
33
+ if char == "\n":
34
+ starts.append(index + 1)
35
+ return starts
@@ -0,0 +1,36 @@
1
+ """
2
+ The 'mergeTokens' function coalesces consecutive tokens of identical type.
3
+ """
4
+ # Apache-2.0 license
5
+ # Copyright (c) 2026 Asger Jon Vistisen
6
+
7
+
8
+ def mergeTokens(stream) -> list:
9
+ """Merge consecutive tokens of identical type into one.
10
+
11
+ This is what Pygments' high-level 'get_tokens' does, so the rendered
12
+ spans group exactly as the stock HtmlFormatter groups them and the
13
+ output stays byte-identical once the anchors are stripped. The first
14
+ token's offset is kept, which is also where any go-to-definition link
15
+ for the run is anchored, since a link target always sits at a run's
16
+ start.
17
+
18
+ Parameters
19
+ ----------
20
+ stream : iterable of tuple
21
+ Tuples of '(offset, ttype, value)', as produced by
22
+ 'get_tokens_unprocessed'.
23
+
24
+ Returns
25
+ -------
26
+ list of tuple
27
+ The merged tuples, in order.
28
+ """
29
+ merged = []
30
+ for offset, ttype, value in stream:
31
+ if merged and merged[-1][1] == ttype:
32
+ prevOff, prevType, prevVal = merged[-1]
33
+ merged[-1] = (prevOff, prevType, prevVal + value)
34
+ else:
35
+ merged.append((offset, ttype, value))
36
+ return merged
@@ -0,0 +1,42 @@
1
+ """
2
+ The 'moduleDefinitions' function maps module-scope names to their first
3
+ definition line.
4
+ """
5
+ # Apache-2.0 license
6
+ # Copyright (c) 2026 Asger Jon Vistisen
7
+
8
+ import ast
9
+
10
+
11
+ def moduleDefinitions(tree: ast.AST) -> dict:
12
+ """Return each module-scope name mapped to the line it is defined on.
13
+
14
+ Classes, functions, simple assignments and annotated assignments with a
15
+ value are recorded; the first definition of a name wins. This is the
16
+ go-to-definition target for a later use of that name within the same
17
+ file.
18
+
19
+ Parameters
20
+ ----------
21
+ tree : ast.AST
22
+ The parsed module.
23
+
24
+ Returns
25
+ -------
26
+ dict
27
+ A mapping of name to its first definition line.
28
+ """
29
+ defs = {}
30
+ for node in tree.body:
31
+ if isinstance(
32
+ node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
33
+ ):
34
+ defs.setdefault(node.name, node.lineno)
35
+ elif isinstance(node, ast.Assign):
36
+ for target in node.targets:
37
+ if isinstance(target, ast.Name):
38
+ defs.setdefault(target.id, node.lineno)
39
+ elif isinstance(node, ast.AnnAssign):
40
+ if isinstance(node.target, ast.Name) and node.value is not None:
41
+ defs.setdefault(node.target.id, node.lineno)
42
+ return defs