venv-doc 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.
venv_doc/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ # SPDX-License-Identifier: ISC
2
+ #
3
+ # ISC License
4
+ #
5
+ # Copyright (c) 2025, Timothée Mazzucotelli and contributors
6
+ #
7
+ # Permission to use, copy, modify, and/or distribute this software for any
8
+ # purpose with or without fee is hereby granted, provided that the above
9
+ # copyright notice and this permission notice appear in all copies.
10
+ #
11
+ # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
12
+ # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
13
+ # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
14
+ # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
15
+ # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
16
+ # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
17
+ # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
18
+
19
+ """venv-doc package.
20
+
21
+ Like `cargo doc` for Python venvs.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from venv_doc._internal.cli import get_parser, main
27
+
28
+ __all__: list[str] = ["get_parser", "main"]
venv_doc/__main__.py ADDED
@@ -0,0 +1,32 @@
1
+ # SPDX-License-Identifier: ISC
2
+ #
3
+ # ISC License
4
+ #
5
+ # Copyright (c) 2025, Timothée Mazzucotelli and contributors
6
+ #
7
+ # Permission to use, copy, modify, and/or distribute this software for any
8
+ # purpose with or without fee is hereby granted, provided that the above
9
+ # copyright notice and this permission notice appear in all copies.
10
+ #
11
+ # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
12
+ # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
13
+ # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
14
+ # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
15
+ # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
16
+ # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
17
+ # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
18
+
19
+ """Entry-point module, in case you use `python -m venv_doc`.
20
+
21
+ Why does this file exist, and why `__main__`? For more info, read:
22
+
23
+ - https://www.python.org/dev/peps/pep-0338/
24
+ - https://docs.python.org/3/using/cmdline.html#cmdoption-m
25
+ """
26
+
27
+ import sys
28
+
29
+ from venv_doc._internal.cli import main
30
+
31
+ if __name__ == "__main__":
32
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,17 @@
1
+ # SPDX-License-Identifier: ISC
2
+ #
3
+ # ISC License
4
+ #
5
+ # Copyright (c) 2025, Timothée Mazzucotelli and contributors
6
+ #
7
+ # Permission to use, copy, modify, and/or distribute this software for any
8
+ # purpose with or without fee is hereby granted, provided that the above
9
+ # copyright notice and this permission notice appear in all copies.
10
+ #
11
+ # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
12
+ # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
13
+ # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
14
+ # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
15
+ # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
16
+ # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
17
+ # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
@@ -0,0 +1,485 @@
1
+ # SPDX-License-Identifier: ISC
2
+ #
3
+ # ISC License
4
+ #
5
+ # Copyright (c) 2025, Timothée Mazzucotelli and contributors
6
+ #
7
+ # Permission to use, copy, modify, and/or distribute this software for any
8
+ # purpose with or without fee is hereby granted, provided that the above
9
+ # copyright notice and this permission notice appear in all copies.
10
+ #
11
+ # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
12
+ # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
13
+ # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
14
+ # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
15
+ # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
16
+ # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
17
+ # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
18
+
19
+ # Why does this file exist, and why not put this in `__main__`?
20
+ #
21
+ # You might be tempted to import things from `__main__` later,
22
+ # but that will cause problems: the code will get executed twice:
23
+ #
24
+ # - When you run `python -m venv_doc` python will execute
25
+ # `__main__.py` as a script. That means there won't be any
26
+ # `venv_doc.__main__` in `sys.modules`.
27
+ # - When you import `__main__` it will get executed again (as a module) because
28
+ # there's no `venv_doc.__main__` in `sys.modules`.
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import os
35
+ import sys
36
+ from dataclasses import asdict
37
+ from pathlib import Path
38
+ from subprocess import run
39
+ from tempfile import TemporaryDirectory
40
+ from textwrap import dedent
41
+ from time import perf_counter
42
+ from typing import Any, cast
43
+
44
+ from griffe import (
45
+ Alias,
46
+ AliasResolutionError,
47
+ CyclicAliasError,
48
+ GriffeLoader,
49
+ Module,
50
+ Object,
51
+ Parser,
52
+ load_extensions,
53
+ )
54
+ from mkdocstrings import CollectionError
55
+ from zensical import build, serve
56
+ from zensical.compat import mkdocstrings as zensical_mkdocstrings
57
+ from zensical.config import parse_config
58
+
59
+ from venv_doc._internal import debug
60
+
61
+
62
+ class _DebugInfo(argparse.Action):
63
+ def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None:
64
+ super().__init__(nargs=nargs, **kwargs)
65
+
66
+ def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
67
+ debug._print_debug_info()
68
+ sys.exit(0)
69
+
70
+
71
+ def get_parser() -> argparse.ArgumentParser:
72
+ """Return the CLI argument parser.
73
+
74
+ Returns:
75
+ An argparse parser.
76
+ """
77
+ parser = argparse.ArgumentParser(prog="venv-doc")
78
+ parser.add_argument(
79
+ "-V",
80
+ "--version",
81
+ action="version",
82
+ version=f"%(prog)s {debug._get_version()}",
83
+ )
84
+ parser.add_argument(
85
+ "--debug-info",
86
+ action=_DebugInfo,
87
+ help="Print debug information.",
88
+ )
89
+ parser.add_argument(
90
+ "--self",
91
+ action="store_true",
92
+ help="Document packages from the environment running venv-doc instead of .venv.",
93
+ )
94
+ subparsers = parser.add_subparsers(dest="command", required=True)
95
+
96
+ build_parser = subparsers.add_parser("build", help="Build the documentation.")
97
+ build_parser.add_argument(
98
+ "-c",
99
+ "--clean",
100
+ action="store_true",
101
+ help="Clean cache.",
102
+ )
103
+ build_parser.add_argument(
104
+ "-s",
105
+ "--strict",
106
+ action="store_true",
107
+ help="Enable strict mode - abort the build on warnings.",
108
+ )
109
+
110
+ serve_parser = subparsers.add_parser(
111
+ "serve",
112
+ help="Build and serve the documentation.",
113
+ )
114
+ serve_parser.add_argument(
115
+ "-a",
116
+ "--dev-addr",
117
+ metavar="<IP:PORT>",
118
+ help="IP address and port (default: localhost:8000).",
119
+ )
120
+ serve_parser.add_argument(
121
+ "-o",
122
+ "--open",
123
+ action="store_true",
124
+ help="Open preview in default browser.",
125
+ )
126
+ serve_parser.add_argument(
127
+ "-s",
128
+ "--strict",
129
+ action="store_true",
130
+ help="Strict mode (currently unsupported).",
131
+ )
132
+ return parser
133
+
134
+
135
+ _VENV_INFO_SCRIPT = """\
136
+ import json
137
+ from importlib.metadata import distributions
138
+ from importlib.util import find_spec
139
+ from inspect import getmodulename
140
+ from pathlib import Path
141
+
142
+ package_candidates = set()
143
+ for distribution in distributions():
144
+ top_level = distribution.read_text("top_level.txt")
145
+ if top_level:
146
+ package_candidates.update(top_level.split())
147
+ else:
148
+ for path in distribution.files or []:
149
+ name = path.parts[0]
150
+ package_candidates.add(getmodulename(name) or name)
151
+
152
+ package_names = []
153
+ package_paths = set()
154
+ for package in package_candidates:
155
+ if package.startswith("_") or not package.isidentifier():
156
+ continue
157
+ spec = find_spec(package)
158
+ if spec is None:
159
+ continue
160
+ package_names.append(package)
161
+ if spec.submodule_search_locations:
162
+ package_paths.update(str(Path(path).parent) for path in spec.submodule_search_locations)
163
+ elif spec.origin:
164
+ package_paths.add(str(Path(spec.origin).parent))
165
+
166
+ print(json.dumps({"packages": sorted(package_names), "paths": sorted(package_paths)}))
167
+ """
168
+
169
+
170
+ def _venv_python(venv_path: Path) -> Path:
171
+ """Return the Python interpreter in a virtual environment."""
172
+ candidates = (
173
+ (venv_path / "Scripts" / "python.exe", venv_path / "Scripts" / "python")
174
+ if os.name == "nt"
175
+ else (venv_path / "bin" / "python",)
176
+ )
177
+ for candidate in candidates:
178
+ if candidate.is_file():
179
+ return candidate
180
+ msg = f"Could not find a Python interpreter in {venv_path}"
181
+ raise FileNotFoundError(msg)
182
+
183
+
184
+ def _venv_packages(python: Path) -> tuple[list[str], list[str]]:
185
+ """Return public packages and their import roots from a Python interpreter."""
186
+ result = run( # noqa: S603
187
+ [str(python), "-c", _VENV_INFO_SCRIPT],
188
+ capture_output=True,
189
+ check=True,
190
+ encoding="utf8",
191
+ )
192
+ info = json.loads(result.stdout)
193
+ return info["packages"], info["paths"]
194
+
195
+
196
+ def _public_modules(module: Module) -> list[Module]:
197
+ """Return the public modules below a module, in declaration order."""
198
+ modules = []
199
+ for member in module.members.values():
200
+ if member.is_alias:
201
+ continue
202
+ try:
203
+ if not member.is_module or not member.is_public:
204
+ continue
205
+ except AliasResolutionError:
206
+ continue
207
+ public_module = cast("Module", member)
208
+ modules.append(public_module)
209
+ modules.extend(_public_modules(public_module))
210
+ return modules
211
+
212
+
213
+ def _is_rendered_member(member: Object | Alias) -> bool:
214
+ """Whether a member is rendered with the generated configuration."""
215
+ try:
216
+ return member.is_public
217
+ except (AliasResolutionError, CyclicAliasError):
218
+ return False
219
+
220
+
221
+ def _preparse_docstrings(modules: list[Module]) -> int:
222
+ """Pre-parse docstrings that will be rendered on module pages."""
223
+ objects: list[Object | Alias] = list(modules)
224
+ seen_objects: set[int] = set()
225
+ seen_docstrings: set[int] = set()
226
+ parsed = 0
227
+
228
+ while objects:
229
+ obj = objects.pop()
230
+ try:
231
+ target = obj.final_target if isinstance(obj, Alias) else obj
232
+ except (AliasResolutionError, CyclicAliasError):
233
+ continue
234
+
235
+ object_id = id(target)
236
+ if object_id in seen_objects:
237
+ continue
238
+ seen_objects.add(object_id)
239
+
240
+ if target.docstring is not None:
241
+ docstring_id = id(target.docstring)
242
+ if docstring_id not in seen_docstrings:
243
+ _ = target.docstring.parsed
244
+ seen_docstrings.add(docstring_id)
245
+ parsed += 1
246
+
247
+ if target.is_module or target.is_class:
248
+ objects.extend(member for member in obj.all_members.values() if _is_rendered_member(member))
249
+
250
+ return parsed
251
+
252
+
253
+ def _noop_mkdocstrings_reset() -> None:
254
+ """Keep the pre-loaded mkdocstrings handlers alive."""
255
+
256
+
257
+ def _write_page(path: Path, identifier: str) -> None:
258
+ """Write an API documentation page for an identifier."""
259
+ path.write_text(
260
+ f"---\ntitle: {identifier}\n---\n\n::: {identifier}\n options:\n show_submodules: false\n",
261
+ encoding="utf8",
262
+ )
263
+
264
+
265
+ def _get_python_handler(config_path: Path) -> Any:
266
+ """Return Zensical's shared Python handler for a documentation build."""
267
+ config = parse_config(str(config_path))
268
+ zensical_mkdocstrings.reset()
269
+ zensical_mkdocstrings.get_mkdocstrings_extension(
270
+ **config["plugins"]["mkdocstrings"]["config"],
271
+ config=config,
272
+ )
273
+ handlers = zensical_mkdocstrings.HANDLERS
274
+ return handlers.get_handler("python") # ty:ignore[unresolved-attribute]
275
+
276
+
277
+ def _load_packages(handler: Any, packages: list[str]) -> dict[str, Module]:
278
+ """Load packages into a Python handler's shared Griffe collections."""
279
+ options = handler.get_options({})
280
+ parser = Parser(options.docstring_style) if options.docstring_style else None
281
+ parser_options = asdict(options.docstring_options) if options.docstring_options is not None else None
282
+
283
+ extensions = handler.normalize_extension_paths(options.extensions)
284
+ loader = GriffeLoader(
285
+ extensions=load_extensions(*extensions),
286
+ search_paths=handler._paths,
287
+ docstring_parser=parser,
288
+ docstring_options=parser_options, # ty:ignore[invalid-argument-type]
289
+ modules_collection=handler._modules_collection,
290
+ lines_collection=handler._lines_collection,
291
+ allow_inspection=options.allow_inspection,
292
+ force_inspection=options.force_inspection,
293
+ )
294
+
295
+ root_modules = {}
296
+ try:
297
+ for module in options.preload_modules:
298
+ if module not in handler._modules_collection:
299
+ loader.load(
300
+ module,
301
+ try_relative_path=False,
302
+ find_stubs_package=options.find_stubs_package,
303
+ )
304
+
305
+ for package in packages:
306
+ if package not in handler._modules_collection:
307
+ loader.load(
308
+ package,
309
+ try_relative_path=False,
310
+ find_stubs_package=options.find_stubs_package,
311
+ )
312
+ root_modules[package] = cast("Module", handler._modules_collection[package])
313
+ except ImportError as error:
314
+ raise CollectionError(str(error)) from error
315
+
316
+ loader.resolve_aliases(
317
+ implicit=False,
318
+ external=handler.config.load_external_modules,
319
+ )
320
+ return root_modules
321
+
322
+
323
+ def main(args: list[str] | None = None) -> int:
324
+ """Run the main program.
325
+
326
+ This function is executed when you type `venv-doc` or `python -m venv_doc`.
327
+
328
+ Parameters:
329
+ args: Arguments passed from the command line.
330
+
331
+ Returns:
332
+ An exit code.
333
+ """
334
+ parsed_args = get_parser().parse_args(args)
335
+ python = Path(sys.executable) if parsed_args.self else _venv_python(Path(".venv"))
336
+ packages, package_paths = _venv_packages(python)
337
+
338
+ with TemporaryDirectory() as tmpdir:
339
+ tmppath = Path(tmpdir)
340
+ tmppath.joinpath("docs").mkdir()
341
+ config = dedent(
342
+ f"""\
343
+ [project]
344
+ site_name = "API docs"
345
+ nav = [
346
+ {{ "API docs" = [
347
+ "index.md",
348
+ # {{packages}}
349
+ ] }},
350
+ ]
351
+ validation = false
352
+
353
+ [project.theme]
354
+ features = [
355
+ "announce.dismiss",
356
+ "content.action.edit",
357
+ "content.action.view",
358
+ "content.code.annotate",
359
+ "content.code.copy",
360
+ "content.tooltips",
361
+ "navigation.footer",
362
+ "navigation.indexes",
363
+ "navigation.instant.preview",
364
+ "navigation.path",
365
+ "navigation.top",
366
+ "search.highlight",
367
+ "search.suggest",
368
+ "toc.follow",
369
+ ]
370
+
371
+ [[project.theme.palette]]
372
+ media = "(prefers-color-scheme)"
373
+ toggle.icon = "material/brightness-auto"
374
+ toggle.name = "Switch to light mode"
375
+
376
+ [[project.theme.palette]]
377
+ media = "(prefers-color-scheme: light)"
378
+ scheme = "default"
379
+ primary = "teal"
380
+ accent = "purple"
381
+ toggle.icon = "material/weather-sunny"
382
+ toggle.name = "Switch to dark mode"
383
+
384
+ [[project.theme.palette]]
385
+ media = "(prefers-color-scheme: dark)"
386
+ scheme = "slate"
387
+ primary = "black"
388
+ accent = "lime"
389
+ toggle.icon = "material/weather-night"
390
+ toggle.name = "Switch to system preference"
391
+
392
+ [project.markdown_extensions.toc]
393
+ permalink = true
394
+
395
+ [project.markdown_extensions]
396
+ pycon = {{}}
397
+ "venv_doc._internal.sphinx_roles:_SphinxRolesExtension" = {{}}
398
+
399
+ [project.plugins.mkdocstrings.handlers.python]
400
+ inventories = ["https://docs.python.org/3/objects.inv"]
401
+ paths = {json.dumps(package_paths)}
402
+
403
+ [project.plugins.mkdocstrings.handlers.python.options]
404
+ docstring_options = {{
405
+ per_style_options = {{
406
+ google = {{ warnings = false, ignore_init_summary = true }},
407
+ numpy = {{ warnings = false, ignore_init_summary = true }},
408
+ sphinx = {{ warnings = false }},
409
+ }}
410
+ }}
411
+ docstring_section_style = "list"
412
+ docstring_style = "auto"
413
+ filters = "public"
414
+ heading_level = 1
415
+ inherited_members = true
416
+ merge_init_into_class = true
417
+ scoped_crossrefs = false
418
+ separate_signature = true
419
+ show_if_no_docstring = true
420
+ show_root_heading = true
421
+ show_root_full_path = false
422
+ show_signature_annotations = true
423
+ show_source = false
424
+ show_submodules = false
425
+ show_symbol_type_heading = true
426
+ show_symbol_type_toc = true
427
+ signature_crossrefs = true
428
+ summary = true
429
+ """,
430
+ )
431
+ config_path = tmppath.joinpath("zensical.toml")
432
+ config_path.write_text(config, encoding="utf8")
433
+ tmppath.joinpath("docs", "index.md").write_text(
434
+ "# API docs\n\nSelect a package from the navigation to view its API reference.\n",
435
+ encoding="utf8",
436
+ )
437
+ handler = _get_python_handler(config_path)
438
+ root_modules = _load_packages(handler, packages)
439
+ package_modules = {}
440
+ documented_modules = []
441
+ for package, root_module in root_modules.items():
442
+ package_modules[package] = _public_modules(root_module)
443
+ documented_modules.append(root_module)
444
+ documented_modules.extend(package_modules[package])
445
+ _write_page(tmppath.joinpath("docs", f"{package}.md"), package)
446
+ for module in package_modules[package]:
447
+ _write_page(tmppath.joinpath("docs", f"{module.path}.md"), module.path)
448
+
449
+ nav = ",\n ".join(
450
+ "{ "
451
+ + json.dumps(package)
452
+ + " = [\n "
453
+ + json.dumps(f"{package}.md")
454
+ + ",\n "
455
+ + ",\n ".join(
456
+ f"{{ {json.dumps(module.path)} = {json.dumps(f'{module.path}.md')} }}" for module in modules
457
+ )
458
+ + "\n ] }"
459
+ for package, modules in package_modules.items()
460
+ )
461
+ config_path.write_text(config.replace("# {packages}", nav), encoding="utf8")
462
+ started = perf_counter()
463
+ parsed_docstrings = _preparse_docstrings(documented_modules)
464
+ print(f"Pre-parsed {parsed_docstrings:,} docstrings in {perf_counter() - started:.2f}s", flush=True)
465
+ reset_mkdocstrings = zensical_mkdocstrings.reset
466
+ zensical_mkdocstrings.reset = _noop_mkdocstrings_reset # ty:ignore[invalid-assignment]
467
+ try:
468
+ if parsed_args.command == "build":
469
+ build(
470
+ str(config_path),
471
+ {"clean": parsed_args.clean, "strict": parsed_args.strict},
472
+ )
473
+ else:
474
+ serve(
475
+ str(config_path),
476
+ {
477
+ "dev_addr": parsed_args.dev_addr,
478
+ "open": parsed_args.open,
479
+ "strict": parsed_args.strict,
480
+ },
481
+ )
482
+ finally:
483
+ zensical_mkdocstrings.reset = reset_mkdocstrings
484
+
485
+ return 0
@@ -0,0 +1,125 @@
1
+ # SPDX-License-Identifier: ISC
2
+ #
3
+ # ISC License
4
+ #
5
+ # Copyright (c) 2025, Timothée Mazzucotelli and contributors
6
+ #
7
+ # Permission to use, copy, modify, and/or distribute this software for any
8
+ # purpose with or without fee is hereby granted, provided that the above
9
+ # copyright notice and this permission notice appear in all copies.
10
+ #
11
+ # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
12
+ # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
13
+ # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
14
+ # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
15
+ # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
16
+ # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
17
+ # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import platform
23
+ import sys
24
+ from dataclasses import dataclass
25
+ from importlib import metadata
26
+
27
+
28
+ @dataclass
29
+ class _Variable:
30
+ """Dataclass describing an environment variable."""
31
+
32
+ name: str
33
+ """Variable name."""
34
+ value: str
35
+ """Variable value."""
36
+
37
+
38
+ @dataclass
39
+ class _Package:
40
+ """Dataclass describing a Python package."""
41
+
42
+ name: str
43
+ """Package name."""
44
+ version: str
45
+ """Package version."""
46
+
47
+
48
+ @dataclass
49
+ class _Environment:
50
+ """Dataclass to store environment information."""
51
+
52
+ interpreter_name: str
53
+ """Python interpreter name."""
54
+ interpreter_version: str
55
+ """Python interpreter version."""
56
+ interpreter_path: str
57
+ """Path to Python executable."""
58
+ platform: str
59
+ """Operating System."""
60
+ packages: list[_Package]
61
+ """Installed packages."""
62
+ variables: list[_Variable]
63
+ """Environment variables."""
64
+
65
+
66
+ def _interpreter_name_version() -> tuple[str, str]:
67
+ if hasattr(sys, "implementation"):
68
+ impl = sys.implementation.version
69
+ version = f"{impl.major}.{impl.minor}.{impl.micro}"
70
+ kind = impl.releaselevel
71
+ if kind != "final":
72
+ version += kind[0] + str(impl.serial)
73
+ return sys.implementation.name, version
74
+ return "", "0.0.0"
75
+
76
+
77
+ def _get_version(dist: str = "venv-doc") -> str:
78
+ """Get version of the given distribution.
79
+
80
+ Parameters:
81
+ dist: A distribution name.
82
+
83
+ Returns:
84
+ A version number.
85
+ """
86
+ try:
87
+ return metadata.version(dist)
88
+ except metadata.PackageNotFoundError:
89
+ return "0.0.0"
90
+
91
+
92
+ def _get_debug_info() -> _Environment:
93
+ """Get debug/environment information.
94
+
95
+ Returns:
96
+ Environment information.
97
+ """
98
+ py_name, py_version = _interpreter_name_version()
99
+ packages = ["venv-doc"]
100
+ variables = ["PYTHONPATH", *[var for var in os.environ if var.startswith("VENV_DOC")]]
101
+ return _Environment(
102
+ interpreter_name=py_name,
103
+ interpreter_version=py_version,
104
+ interpreter_path=sys.executable,
105
+ platform=platform.platform(),
106
+ variables=[_Variable(var, val) for var in variables if (val := os.getenv(var))],
107
+ packages=[_Package(pkg, _get_version(pkg)) for pkg in packages],
108
+ )
109
+
110
+
111
+ def _print_debug_info() -> None:
112
+ """Print debug/environment information."""
113
+ info = _get_debug_info()
114
+ print(f"- __System__: {info.platform}")
115
+ print(f"- __Python__: {info.interpreter_name} {info.interpreter_version} ({info.interpreter_path})")
116
+ print("- __Environment variables__:")
117
+ for var in info.variables:
118
+ print(f" - `{var.name}`: `{var.value}`")
119
+ print("- __Installed packages__:")
120
+ for pkg in info.packages:
121
+ print(f" - `{pkg.name}` v{pkg.version}")
122
+
123
+
124
+ if __name__ == "__main__":
125
+ _print_debug_info()
@@ -0,0 +1,105 @@
1
+ # SPDX-License-Identifier: ISC
2
+ #
3
+ # ISC License
4
+ #
5
+ # Copyright (c) 2025, Timothée Mazzucotelli and contributors
6
+ #
7
+ # Permission to use, copy, modify, and/or distribute this software for any
8
+ # purpose with or without fee is hereby granted, provided that the above
9
+ # copyright notice and this permission notice appear in all copies.
10
+ #
11
+ # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
12
+ # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
13
+ # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
14
+ # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
15
+ # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
16
+ # ACTION OF CONTRACT, NEGLIGENCE OR OTHERWISE ARISING IN ANY WAY OUT OF THE
17
+ # USE OR PERFORMANCE OF THIS SOFTWARE.
18
+
19
+ from __future__ import annotations
20
+
21
+ from re import fullmatch
22
+ from typing import TYPE_CHECKING
23
+ from xml.etree.ElementTree import Element
24
+
25
+ from markdown.extensions import Extension
26
+ from markdown.inlinepatterns import InlineProcessor
27
+
28
+ if TYPE_CHECKING:
29
+ from re import Match
30
+
31
+ from markdown import Markdown
32
+
33
+
34
+ _PYTHON_ROLES = "attr|class|const|data|deco|exc|func|meth|mod|obj|type"
35
+ """Python-domain cross-reference roles supported by Sphinx."""
36
+
37
+ _SPHINX_ROLE_RE = rf"(?<![\w`]):(?:py:)?(?P<role>{_PYTHON_ROLES}):`(?P<content>[^`\n]+)`(?!`)"
38
+ """Match a supported short or qualified Python-domain Sphinx role."""
39
+
40
+ _PYTHON_IDENTIFIER_RE = r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*"
41
+ """Match the dotted Python object identifiers that mkdocs-autorefs can resolve."""
42
+
43
+ _EXPLICIT_TITLE_RE = r"(?P<title>.+?)\s*<(?P<target>[^<>]+)>"
44
+ """Match Sphinx's ``title <target>`` cross-reference syntax."""
45
+
46
+
47
+ class _SphinxRoleInlineProcessor(InlineProcessor):
48
+ """Turn supported Python-domain Sphinx roles into mkdocs-autorefs markers."""
49
+
50
+ def handleMatch(self, m: Match[str], data: str) -> tuple[Element, int, int]: # noqa: ARG002,N802
51
+ """Create an ``autoref`` element for a Sphinx role where possible."""
52
+ role = m.group("role")
53
+ content = m.group("content")
54
+
55
+ # ``!`` explicitly disables linking in Sphinx. It must not become an
56
+ # autoref marker because a resolvable marker would create a link again.
57
+ if content.startswith("!"):
58
+ element = Element("code")
59
+ element.text = content[1:]
60
+ return element, m.start(0), m.end(0)
61
+
62
+ explicit_title = fullmatch(_EXPLICIT_TITLE_RE, content)
63
+ if explicit_title:
64
+ title = explicit_title.group("title")
65
+ identifier = explicit_title.group("target")
66
+ else:
67
+ title = content
68
+ identifier = content
69
+
70
+ # Sphinx uses a leading ``~`` only to shorten an implicit title, and a
71
+ # leading ``.`` only to affect lookup precedence. Autorefs has no
72
+ # equivalent contextual lookup, so resolve the unprefixed identifier.
73
+ if not explicit_title:
74
+ title = title.lstrip(".")
75
+ if title.startswith("~"):
76
+ title = title[1:].rsplit(".", maxsplit=1)[-1]
77
+ identifier = identifier.strip().lstrip("~.").removesuffix("()")
78
+
79
+ if not fullmatch(_PYTHON_IDENTIFIER_RE, identifier):
80
+ # Leave syntax that cannot name an mkdocs-autorefs object to
81
+ # Markdown's normal handling rather than making an invalid marker.
82
+ element = Element("code")
83
+ element.text = content
84
+ return element, m.start(0), m.end(0)
85
+
86
+ if role == "deco":
87
+ title = f"@{title}"
88
+
89
+ element = Element("autoref", {"identifier": identifier})
90
+ element.text = title
91
+ return element, m.start(0), m.end(0)
92
+
93
+
94
+ class _SphinxRolesExtension(Extension):
95
+ """Convert Python-domain Sphinx roles to auto-references."""
96
+
97
+ name = "venv-doc-sphinx-roles"
98
+
99
+ def extendMarkdown(self, md: Markdown) -> None: # noqa: N802
100
+ """Register the role processor before Markdown's code-span processor."""
101
+ md.inlinePatterns.register(
102
+ _SphinxRoleInlineProcessor(_SPHINX_ROLE_RE, md),
103
+ self.name,
104
+ priority=191,
105
+ )
venv_doc/py.typed ADDED
File without changes
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: venv-doc
3
+ Version: 0.1.0
4
+ Summary: Like `cargo doc` for Python venvs.
5
+ Author-Email: Timothée Mazzucotelli <dev@pawamoy.fr>
6
+ License-Expression: ISC
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Programming Language :: Python :: 3.15
18
+ Classifier: Topic :: Documentation
19
+ Classifier: Topic :: Software Development
20
+ Classifier: Topic :: Utilities
21
+ Classifier: Typing :: Typed
22
+ Project-URL: Homepage, https://pawamoy.github.io/venv-doc
23
+ Project-URL: Documentation, https://pawamoy.github.io/venv-doc
24
+ Project-URL: Changelog, https://pawamoy.github.io/venv-doc/changelog
25
+ Project-URL: Repository, https://github.com/pawamoy/venv-doc
26
+ Project-URL: Issues, https://github.com/pawamoy/venv-doc/issues
27
+ Project-URL: Discussions, https://github.com/pawamoy/venv-doc/discussions
28
+ Project-URL: Gitter, https://gitter.im/venv-doc/community
29
+ Project-URL: Funding, https://github.com/sponsors/pawamoy
30
+ Requires-Python: >=3.11
31
+ Requires-Dist: griffe>=2.2
32
+ Requires-Dist: prune-source>=0.1
33
+ Requires-Dist: markdown-pycon>=1.0
34
+ Requires-Dist: mkdocstrings-python>=2.0
35
+ Requires-Dist: zensical>=0.0.57
36
+ Description-Content-Type: text/markdown
37
+
38
+ # venv-doc
39
+
40
+ [![ci](https://github.com/pawamoy/venv-doc/workflows/ci/badge.svg)](https://github.com/pawamoy/venv-doc/actions?query=workflow%3Aci)
41
+ [![documentation](https://img.shields.io/badge/docs-zensical-FF9100.svg?style=flat)](https://pawamoy.github.io/venv-doc/)
42
+ [![pypi version](https://img.shields.io/pypi/v/venv-doc.svg)](https://pypi.org/project/venv-doc/)
43
+ [![gitter](https://img.shields.io/badge/matrix-chat-4DB798.svg?style=flat)](https://app.gitter.im/#/room/#venv-doc:gitter.im)
44
+
45
+ Like `cargo doc` for Python venvs.
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install venv-doc
51
+ ```
52
+
53
+ With [`uv`](https://docs.astral.sh/uv/):
54
+
55
+ ```bash
56
+ uv tool install venv-doc
57
+ ```
58
+
59
+ ## Sponsors
60
+
61
+ <!-- sponsors-start -->
62
+ <!-- sponsors-end -->
@@ -0,0 +1,12 @@
1
+ venv_doc-0.1.0.dist-info/METADATA,sha256=5c8ssgSSRs_ondZki45DmRDfBm_O5e8W56CSaCbPcCQ,2276
2
+ venv_doc-0.1.0.dist-info/WHEEL,sha256=IuIk3wbaEnFH1cx75iM2R1CmliRI3dFLXTbRakgvgFQ,90
3
+ venv_doc-0.1.0.dist-info/entry_points.txt,sha256=c6waGinuuxmqaM4LVxN7e5Wnks0PCE5ckU4YgmtJV00,59
4
+ venv_doc-0.1.0.dist-info/licenses/LICENSE,sha256=FnCVYk610bj4MkIFuYhAXmhIBYKebHhVp2YWO-8rEVY,771
5
+ venv_doc/__init__.py,sha256=aPwPGdcepJuu-3XUkyHNRjq2bZX0cyNgvpU1EdnyOR4,1027
6
+ venv_doc/__main__.py,sha256=Tw8JJrqHjAxblqjNHvOhnFqcMyVovqWmiLs0E6Qw58E,1183
7
+ venv_doc/_internal/__init__.py,sha256=FNcSqCgny9Z6h2ijJwSvekOi5ka7WWQFmAGC7rNkmJE,831
8
+ venv_doc/_internal/cli.py,sha256=0ZJpGfpWsCHsLmA3x7GGzLxGObHFBFHx46NmAfnKHwM,16622
9
+ venv_doc/_internal/debug.py,sha256=PQzUIM1YIe5_DvnPdNyLSLqgDpO55ByL1_MAot8ip1w,3644
10
+ venv_doc/_internal/sphinx_roles.py,sha256=VpD9ZqBT9ByHXaP1U8za8aJIveVYNiccUUy6kbWQWpQ,4124
11
+ venv_doc/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ venv_doc-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: pdm-backend (2.5.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ venv-doc = venv_doc:main
3
+
4
+ [gui_scripts]
5
+
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2025, Timothée Mazzucotelli and contributors
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.