doukimd 1.0.0__tar.gz
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.
- doukimd-1.0.0/LICENSE +28 -0
- doukimd-1.0.0/PKG-INFO +88 -0
- doukimd-1.0.0/README.md +56 -0
- doukimd-1.0.0/pyproject.toml +99 -0
- doukimd-1.0.0/src/doukimd/__init__.py +28 -0
- doukimd-1.0.0/src/doukimd/__main__.py +7 -0
- doukimd-1.0.0/src/doukimd/api.py +300 -0
- doukimd-1.0.0/src/doukimd/assets.py +90 -0
- doukimd-1.0.0/src/doukimd/build.py +504 -0
- doukimd-1.0.0/src/doukimd/cli.py +74 -0
- doukimd-1.0.0/src/doukimd/collection.py +487 -0
- doukimd-1.0.0/src/doukimd/config.py +148 -0
- doukimd-1.0.0/src/doukimd/content.py +330 -0
- doukimd-1.0.0/src/doukimd/errors.py +49 -0
- doukimd-1.0.0/src/doukimd/html.py +189 -0
- doukimd-1.0.0/src/doukimd/markdown.py +432 -0
- doukimd-1.0.0/src/doukimd/models.py +435 -0
- doukimd-1.0.0/src/doukimd/navigation.py +404 -0
- doukimd-1.0.0/src/doukimd/plugins.py +83 -0
- doukimd-1.0.0/src/doukimd/py.typed +0 -0
- doukimd-1.0.0/src/doukimd/references.py +55 -0
- doukimd-1.0.0/src/doukimd/runtime.py +85 -0
- doukimd-1.0.0/src/doukimd/theme.py +95 -0
- doukimd-1.0.0/src/doukimd/themes/default/page.html +117 -0
- doukimd-1.0.0/src/doukimd/themes/default/palettes.json +122 -0
- doukimd-1.0.0/src/doukimd/themes/default/theme.css +184 -0
- doukimd-1.0.0/src/doukimd/themes/default/theme.js +63 -0
- doukimd-1.0.0/src/doukimd/themes/vendor/bootstrap/LICENSE +21 -0
- doukimd-1.0.0/src/doukimd/themes/vendor/bootstrap/README.md +11 -0
- doukimd-1.0.0/src/doukimd/themes/vendor/bootstrap/bootstrap.min.css +6 -0
- doukimd-1.0.0/src/doukimd/themes/vendor/radix/LICENSE +21 -0
- doukimd-1.0.0/src/doukimd/themes/vendor/radix/README.md +26 -0
doukimd-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, ARX
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
doukimd-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: doukimd
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Markdown documentation with first-class Douki API references
|
|
5
|
+
License-Expression: BSD-3-Clause
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: Ivan Ogasawara
|
|
8
|
+
Author-email: ivan.ogasawara@gmail.com
|
|
9
|
+
Requires-Python: >=3.10,<4
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Dist: Jinja2 (>=3.1.6,<4)
|
|
17
|
+
Requires-Dist: PyYAML (>=6,<7)
|
|
18
|
+
Requires-Dist: Pygments (>=2.18,<3)
|
|
19
|
+
Requires-Dist: atpublic (>=5,<6)
|
|
20
|
+
Requires-Dist: click (>=8.1,<9)
|
|
21
|
+
Requires-Dist: douki (>=0.12.1,<0.13)
|
|
22
|
+
Requires-Dist: griffe (>=1.15,<2)
|
|
23
|
+
Requires-Dist: jsonschema (>=4.23,<5)
|
|
24
|
+
Requires-Dist: markdown-it-py (>=3,<5)
|
|
25
|
+
Requires-Dist: mdit-py-plugins (>=0.4,<0.7)
|
|
26
|
+
Requires-Dist: pluggy (>=1.5,<2)
|
|
27
|
+
Requires-Dist: plum-dispatch (>=2.5,<3)
|
|
28
|
+
Requires-Dist: pydantic (>=2.10,<3)
|
|
29
|
+
Requires-Dist: typeguard (>=4.4,<5)
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# DoukiMD
|
|
33
|
+
|
|
34
|
+
Markdown documentation with first-class
|
|
35
|
+
[Douki](https://github.com/arxlang/douki) API references.
|
|
36
|
+
|
|
37
|
+
DoukiMD is an opinionated static documentation engine for projects using
|
|
38
|
+
Douki YAML docstrings. This initial version supports Python projects.
|
|
39
|
+
|
|
40
|
+
## Run from source
|
|
41
|
+
|
|
42
|
+
```console
|
|
43
|
+
poetry install
|
|
44
|
+
poetry run doukimd build --config examples/basic/doukimd.yaml
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Open `examples/basic/build/index.html` to view the generated site.
|
|
48
|
+
Use `doukimd check` to validate without writing files.
|
|
49
|
+
|
|
50
|
+
## Included
|
|
51
|
+
|
|
52
|
+
- Markdown pages with validated YAML front matter.
|
|
53
|
+
- Opt-in Jinja in Markdown with project, page, data, and API metadata.
|
|
54
|
+
- Static Python API extraction and validation against Douki's shipped schema.
|
|
55
|
+
- Public re-exports, properties, data declarations, and callable variants.
|
|
56
|
+
- Named API source folders with per-page and per-block selection.
|
|
57
|
+
- Native API blocks, virtual API pages, and forward API references.
|
|
58
|
+
- Ordered navbar menus and product sidebars with source-scoped API trees.
|
|
59
|
+
- Page layouts, trusted HTML opt-in, local branding, and extra stylesheets.
|
|
60
|
+
- Local link and heading validation, assets, code highlighting, and JSON
|
|
61
|
+
symbol and search inventories.
|
|
62
|
+
- A bundled theme, local Jinja theme overrides, and explicit context plugins.
|
|
63
|
+
- Safe replacement of managed output after successful builds.
|
|
64
|
+
|
|
65
|
+
See [authoring and configuration](docs/usage.md) and the complete
|
|
66
|
+
[example project](examples/basic).
|
|
67
|
+
|
|
68
|
+
## Initial scope
|
|
69
|
+
|
|
70
|
+
Python 3.10–3.14 is supported. Handwritten modules, classes, and callables
|
|
71
|
+
require valid Douki docstrings. Data declarations can use their annotations
|
|
72
|
+
and class-field documentation. Source code is never imported or rewritten.
|
|
73
|
+
Arx collection requires a future frontend documentation adapter and is
|
|
74
|
+
explicitly rejected today. Inherited members,
|
|
75
|
+
incremental builds, preview serving, and cross-project links are follow-ups.
|
|
76
|
+
|
|
77
|
+
The generated search data has no bundled interactive search UI yet.
|
|
78
|
+
|
|
79
|
+
## Development
|
|
80
|
+
|
|
81
|
+
```console
|
|
82
|
+
poetry run makim all.ci
|
|
83
|
+
poetry check --lock
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Infrastructure is adapted from [Douki](https://github.com/arxlang/douki).
|
|
87
|
+
See [contributing](docs/contributing.md) for the pinned source and workflow.
|
|
88
|
+
|
doukimd-1.0.0/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# DoukiMD
|
|
2
|
+
|
|
3
|
+
Markdown documentation with first-class
|
|
4
|
+
[Douki](https://github.com/arxlang/douki) API references.
|
|
5
|
+
|
|
6
|
+
DoukiMD is an opinionated static documentation engine for projects using
|
|
7
|
+
Douki YAML docstrings. This initial version supports Python projects.
|
|
8
|
+
|
|
9
|
+
## Run from source
|
|
10
|
+
|
|
11
|
+
```console
|
|
12
|
+
poetry install
|
|
13
|
+
poetry run doukimd build --config examples/basic/doukimd.yaml
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Open `examples/basic/build/index.html` to view the generated site.
|
|
17
|
+
Use `doukimd check` to validate without writing files.
|
|
18
|
+
|
|
19
|
+
## Included
|
|
20
|
+
|
|
21
|
+
- Markdown pages with validated YAML front matter.
|
|
22
|
+
- Opt-in Jinja in Markdown with project, page, data, and API metadata.
|
|
23
|
+
- Static Python API extraction and validation against Douki's shipped schema.
|
|
24
|
+
- Public re-exports, properties, data declarations, and callable variants.
|
|
25
|
+
- Named API source folders with per-page and per-block selection.
|
|
26
|
+
- Native API blocks, virtual API pages, and forward API references.
|
|
27
|
+
- Ordered navbar menus and product sidebars with source-scoped API trees.
|
|
28
|
+
- Page layouts, trusted HTML opt-in, local branding, and extra stylesheets.
|
|
29
|
+
- Local link and heading validation, assets, code highlighting, and JSON
|
|
30
|
+
symbol and search inventories.
|
|
31
|
+
- A bundled theme, local Jinja theme overrides, and explicit context plugins.
|
|
32
|
+
- Safe replacement of managed output after successful builds.
|
|
33
|
+
|
|
34
|
+
See [authoring and configuration](docs/usage.md) and the complete
|
|
35
|
+
[example project](examples/basic).
|
|
36
|
+
|
|
37
|
+
## Initial scope
|
|
38
|
+
|
|
39
|
+
Python 3.10–3.14 is supported. Handwritten modules, classes, and callables
|
|
40
|
+
require valid Douki docstrings. Data declarations can use their annotations
|
|
41
|
+
and class-field documentation. Source code is never imported or rewritten.
|
|
42
|
+
Arx collection requires a future frontend documentation adapter and is
|
|
43
|
+
explicitly rejected today. Inherited members,
|
|
44
|
+
incremental builds, preview serving, and cross-project links are follow-ups.
|
|
45
|
+
|
|
46
|
+
The generated search data has no bundled interactive search UI yet.
|
|
47
|
+
|
|
48
|
+
## Development
|
|
49
|
+
|
|
50
|
+
```console
|
|
51
|
+
poetry run makim all.ci
|
|
52
|
+
poetry check --lock
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Infrastructure is adapted from [Douki](https://github.com/arxlang/douki).
|
|
56
|
+
See [contributing](docs/contributing.md) for the pinned source and workflow.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "doukimd"
|
|
3
|
+
version = "1.0.0" # semantic-release
|
|
4
|
+
description = "Markdown documentation with first-class Douki API references"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{name = "Ivan Ogasawara", email = "ivan.ogasawara@gmail.com"}]
|
|
7
|
+
license = "BSD-3-Clause"
|
|
8
|
+
requires-python = ">=3.10,<4"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"atpublic>=5,<6",
|
|
11
|
+
"click>=8.1,<9",
|
|
12
|
+
"douki>=0.12.1,<0.13",
|
|
13
|
+
"griffe>=1.15,<2",
|
|
14
|
+
"Jinja2>=3.1.6,<4",
|
|
15
|
+
"jsonschema>=4.23,<5",
|
|
16
|
+
"markdown-it-py>=3,<5",
|
|
17
|
+
"mdit-py-plugins>=0.4,<0.7",
|
|
18
|
+
"pluggy>=1.5,<2",
|
|
19
|
+
"plum-dispatch>=2.5,<3",
|
|
20
|
+
"pydantic>=2.10,<3",
|
|
21
|
+
"Pygments>=2.18,<3",
|
|
22
|
+
"PyYAML>=6,<7",
|
|
23
|
+
"typeguard>=4.4,<5",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
doukimd = "doukimd.cli:main"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["poetry-core>=2,<3"]
|
|
31
|
+
build-backend = "poetry.core.masonry.api"
|
|
32
|
+
|
|
33
|
+
[tool.poetry]
|
|
34
|
+
packages = [{include = "doukimd", from = "src"}]
|
|
35
|
+
include = ["src/doukimd/py.typed", "src/doukimd/themes/**/*"]
|
|
36
|
+
|
|
37
|
+
[tool.poetry.group.dev.dependencies]
|
|
38
|
+
pytest = ">=8,<10"
|
|
39
|
+
pytest-cov = ">=5,<8"
|
|
40
|
+
pre-commit = ">=3.8,<5"
|
|
41
|
+
ruff = ">=0.9,<0.18"
|
|
42
|
+
mypy = ">=1.14,<3"
|
|
43
|
+
types-pyyaml = ">=6"
|
|
44
|
+
types-jsonschema = ">=4"
|
|
45
|
+
types-Pygments = ">=2"
|
|
46
|
+
makim = "1.27.0"
|
|
47
|
+
bandit = ">=1.8,<2"
|
|
48
|
+
vulture = ">=2.14,<3"
|
|
49
|
+
mccabe = ">=0.7,<0.8"
|
|
50
|
+
|
|
51
|
+
[tool.mypy]
|
|
52
|
+
python_version = "3.10"
|
|
53
|
+
strict = true
|
|
54
|
+
warn_unused_configs = true
|
|
55
|
+
files = ["src/doukimd"]
|
|
56
|
+
|
|
57
|
+
[[tool.mypy.overrides]]
|
|
58
|
+
module = ["doukimd.api", "doukimd.collection"]
|
|
59
|
+
disable_error_code = ["no-redef"]
|
|
60
|
+
|
|
61
|
+
[tool.pytest.ini_options]
|
|
62
|
+
testpaths = ["tests"]
|
|
63
|
+
addopts = "-ra -p no:xonsh"
|
|
64
|
+
|
|
65
|
+
[tool.coverage.run]
|
|
66
|
+
source = ["doukimd"]
|
|
67
|
+
branch = true
|
|
68
|
+
|
|
69
|
+
[tool.coverage.report]
|
|
70
|
+
show_missing = true
|
|
71
|
+
fail_under = 90
|
|
72
|
+
|
|
73
|
+
[tool.ruff]
|
|
74
|
+
target-version = "py310"
|
|
75
|
+
line-length = 79
|
|
76
|
+
|
|
77
|
+
[tool.ruff.lint]
|
|
78
|
+
select = ["E", "F", "YTT", "RUF", "I", "UP006", "UP007"]
|
|
79
|
+
|
|
80
|
+
[tool.ruff.lint.isort]
|
|
81
|
+
lines-between-types = 1
|
|
82
|
+
|
|
83
|
+
[tool.ruff.lint.per-file-ignores]
|
|
84
|
+
"src/doukimd/api.py" = ["F811"]
|
|
85
|
+
"src/doukimd/collection.py" = ["F811"]
|
|
86
|
+
|
|
87
|
+
[tool.ruff.format]
|
|
88
|
+
quote-style = "single"
|
|
89
|
+
|
|
90
|
+
[tool.bandit]
|
|
91
|
+
exclude_dirs = ["tests"]
|
|
92
|
+
|
|
93
|
+
[tool.vulture]
|
|
94
|
+
paths = ["src/doukimd"]
|
|
95
|
+
min_confidence = 80
|
|
96
|
+
ignore_decorators = ["@hookimpl", "@hookspec"]
|
|
97
|
+
|
|
98
|
+
[tool.douki]
|
|
99
|
+
exclude = ["tests/fixtures/*"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
title: DoukiMD, Markdown documentation powered by Douki.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
|
|
9
|
+
from public import public
|
|
10
|
+
|
|
11
|
+
from doukimd.runtime import typechecked
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@public
|
|
15
|
+
@typechecked
|
|
16
|
+
def get_version() -> str:
|
|
17
|
+
"""
|
|
18
|
+
title: Return the installed version or the source-tree fallback.
|
|
19
|
+
returns:
|
|
20
|
+
type: str
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
return version('doukimd')
|
|
24
|
+
except PackageNotFoundError:
|
|
25
|
+
return '1.0.0' # semantic-release
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__version__ = get_version()
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""
|
|
2
|
+
title: Collect Python declarations without importing project code.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from importlib.resources import files
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import cast
|
|
13
|
+
|
|
14
|
+
import griffe
|
|
15
|
+
|
|
16
|
+
from jsonschema import Draft202012Validator
|
|
17
|
+
from plum import dispatch
|
|
18
|
+
from public import private, public
|
|
19
|
+
|
|
20
|
+
from doukimd.config import project_path, read_yaml
|
|
21
|
+
from doukimd.errors import BuildError
|
|
22
|
+
from doukimd.models import ApiSymbol, Config
|
|
23
|
+
from doukimd.runtime import typechecked
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@private
|
|
27
|
+
@typechecked
|
|
28
|
+
def source_path(node: griffe.Object) -> Path:
|
|
29
|
+
"""
|
|
30
|
+
title: Reject namespace packages without a single source file.
|
|
31
|
+
parameters:
|
|
32
|
+
node:
|
|
33
|
+
type: griffe.Object
|
|
34
|
+
returns:
|
|
35
|
+
type: Path
|
|
36
|
+
"""
|
|
37
|
+
path = node.filepath
|
|
38
|
+
if not isinstance(path, Path):
|
|
39
|
+
raise BuildError('api', f'No single source file for {node.path}')
|
|
40
|
+
return path.resolve()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@private
|
|
44
|
+
@typechecked
|
|
45
|
+
def documentation(node: griffe.Object, root: Path) -> dict[str, object]:
|
|
46
|
+
"""
|
|
47
|
+
title: Validate a source docstring using the installed Douki schema.
|
|
48
|
+
parameters:
|
|
49
|
+
node:
|
|
50
|
+
type: griffe.Object
|
|
51
|
+
root:
|
|
52
|
+
type: Path
|
|
53
|
+
returns:
|
|
54
|
+
type: dict[str, object]
|
|
55
|
+
"""
|
|
56
|
+
path = source_path(node)
|
|
57
|
+
if not path.is_relative_to(root):
|
|
58
|
+
raise BuildError('api', 'API source escapes the project', path)
|
|
59
|
+
if node.docstring is None:
|
|
60
|
+
raise BuildError(
|
|
61
|
+
'douki', f'Missing Douki docstring: {node.path}', path
|
|
62
|
+
)
|
|
63
|
+
data = read_yaml(node.docstring.value, path)
|
|
64
|
+
schema = json.loads(
|
|
65
|
+
files('douki').joinpath('schema.json').read_text(encoding='utf-8')
|
|
66
|
+
)
|
|
67
|
+
errors = sorted(
|
|
68
|
+
Draft202012Validator(schema).iter_errors(data),
|
|
69
|
+
key=lambda error: str(list(error.path)),
|
|
70
|
+
)
|
|
71
|
+
if errors:
|
|
72
|
+
raise BuildError(
|
|
73
|
+
'douki',
|
|
74
|
+
f'{node.path}: Douki schema: '
|
|
75
|
+
+ '; '.join(error.message for error in errors),
|
|
76
|
+
path,
|
|
77
|
+
)
|
|
78
|
+
if isinstance(node, griffe.Function) and 'parameters' in data:
|
|
79
|
+
expected = {
|
|
80
|
+
parameter.name
|
|
81
|
+
for parameter in node.parameters
|
|
82
|
+
if not (
|
|
83
|
+
isinstance(node.parent, griffe.Class)
|
|
84
|
+
and 'staticmethod' not in node.labels
|
|
85
|
+
and parameter.name in {'self', 'cls'}
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
parameters = cast(dict[str, object], data['parameters'])
|
|
89
|
+
if set(parameters) != expected:
|
|
90
|
+
raise BuildError(
|
|
91
|
+
'douki',
|
|
92
|
+
f'{node.path}: documented parameters do not match signature',
|
|
93
|
+
path,
|
|
94
|
+
)
|
|
95
|
+
return data
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@private
|
|
99
|
+
@dispatch
|
|
100
|
+
@typechecked
|
|
101
|
+
def signature(node: object) -> str:
|
|
102
|
+
"""
|
|
103
|
+
title: Reject unsupported API declarations.
|
|
104
|
+
parameters:
|
|
105
|
+
node:
|
|
106
|
+
type: object
|
|
107
|
+
returns:
|
|
108
|
+
type: str
|
|
109
|
+
"""
|
|
110
|
+
raise BuildError('api', f'Unsupported declaration: {type(node).__name__}')
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dispatch
|
|
114
|
+
@typechecked
|
|
115
|
+
def signature(node: griffe.Module) -> str:
|
|
116
|
+
"""
|
|
117
|
+
title: Display a module name.
|
|
118
|
+
parameters:
|
|
119
|
+
node:
|
|
120
|
+
type: griffe.Module
|
|
121
|
+
returns:
|
|
122
|
+
type: str
|
|
123
|
+
"""
|
|
124
|
+
return node.path
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dispatch
|
|
128
|
+
@typechecked
|
|
129
|
+
def signature(node: griffe.Class | griffe.Function) -> str:
|
|
130
|
+
"""
|
|
131
|
+
title: Preserve the source signature through Griffe.
|
|
132
|
+
parameters:
|
|
133
|
+
node:
|
|
134
|
+
type: griffe.Class | griffe.Function
|
|
135
|
+
returns:
|
|
136
|
+
type: str
|
|
137
|
+
"""
|
|
138
|
+
return node.signature()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dispatch
|
|
142
|
+
@typechecked
|
|
143
|
+
def signature(node: griffe.Attribute) -> str:
|
|
144
|
+
"""
|
|
145
|
+
title: Display a data declaration without evaluating its value.
|
|
146
|
+
parameters:
|
|
147
|
+
node:
|
|
148
|
+
type: griffe.Attribute
|
|
149
|
+
returns:
|
|
150
|
+
type: str
|
|
151
|
+
"""
|
|
152
|
+
annotation = f': {node.annotation}' if node.annotation else ''
|
|
153
|
+
value = f' = {node.value}' if node.value is not None else ''
|
|
154
|
+
return f'{node.name}{annotation}{value}'
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@private
|
|
158
|
+
@typechecked
|
|
159
|
+
def hidden(node: griffe.Object) -> bool:
|
|
160
|
+
"""
|
|
161
|
+
title: Honor explicit visibility rather than underscore naming heuristics.
|
|
162
|
+
parameters:
|
|
163
|
+
node:
|
|
164
|
+
type: griffe.Object
|
|
165
|
+
returns:
|
|
166
|
+
type: bool
|
|
167
|
+
"""
|
|
168
|
+
for decorator in getattr(node, 'decorators', []):
|
|
169
|
+
name = str(decorator.value).split('(', maxsplit=1)[0]
|
|
170
|
+
try:
|
|
171
|
+
resolved = node.resolve(name)
|
|
172
|
+
except griffe.NameResolutionError:
|
|
173
|
+
resolved = name
|
|
174
|
+
if resolved == 'public.private':
|
|
175
|
+
return True
|
|
176
|
+
if node.docstring is not None:
|
|
177
|
+
data = read_yaml(node.docstring.value, source_path(node))
|
|
178
|
+
return data.get('visibility') in {'private', 'protected', 'internal'}
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@private
|
|
183
|
+
@typechecked
|
|
184
|
+
def visit(node: object, root: Path) -> list[ApiSymbol]:
|
|
185
|
+
"""
|
|
186
|
+
title: Visit a declaration using the default unscoped collector.
|
|
187
|
+
parameters:
|
|
188
|
+
node:
|
|
189
|
+
type: object
|
|
190
|
+
root:
|
|
191
|
+
type: Path
|
|
192
|
+
returns:
|
|
193
|
+
type: list[ApiSymbol]
|
|
194
|
+
"""
|
|
195
|
+
from doukimd.collection import Collector, FunctionCapture
|
|
196
|
+
|
|
197
|
+
return cast(
|
|
198
|
+
list[ApiSymbol],
|
|
199
|
+
Collector(root, root, None, FunctionCapture()).visit(
|
|
200
|
+
node, getattr(node, 'path', '')
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@public
|
|
206
|
+
@typechecked
|
|
207
|
+
def collect_api(root: Path, config: Config) -> dict[str, ApiSymbol]:
|
|
208
|
+
"""
|
|
209
|
+
title: Collect isolated source folders without importing project code.
|
|
210
|
+
parameters:
|
|
211
|
+
root:
|
|
212
|
+
type: Path
|
|
213
|
+
config:
|
|
214
|
+
type: Config
|
|
215
|
+
returns:
|
|
216
|
+
type: dict[str, ApiSymbol]
|
|
217
|
+
"""
|
|
218
|
+
from doukimd.collection import Collector, FunctionCapture
|
|
219
|
+
|
|
220
|
+
inventory: dict[str, ApiSymbol] = {}
|
|
221
|
+
names: set[str] = set()
|
|
222
|
+
for source in config.api.sources:
|
|
223
|
+
if source.name is not None:
|
|
224
|
+
if source.name in names:
|
|
225
|
+
raise BuildError('api', f'Duplicate API source: {source.name}')
|
|
226
|
+
names.add(source.name)
|
|
227
|
+
search = project_path(root, source.root)
|
|
228
|
+
if not search.is_dir():
|
|
229
|
+
raise BuildError('api', 'API source folder does not exist', search)
|
|
230
|
+
for path in search.rglob('*'):
|
|
231
|
+
if path.is_symlink() and not path.resolve().is_relative_to(search):
|
|
232
|
+
raise BuildError(
|
|
233
|
+
'api', 'API source escapes selected folder', path
|
|
234
|
+
)
|
|
235
|
+
capture = FunctionCapture()
|
|
236
|
+
modules = list(source.modules)
|
|
237
|
+
loader_search = search
|
|
238
|
+
if not modules:
|
|
239
|
+
if (search / '__init__.py').is_file():
|
|
240
|
+
loader_search = search.parent
|
|
241
|
+
modules = [search.name]
|
|
242
|
+
else:
|
|
243
|
+
for directory in search.iterdir():
|
|
244
|
+
if (
|
|
245
|
+
directory.is_dir()
|
|
246
|
+
and not (directory / '__init__.py').is_file()
|
|
247
|
+
and any(directory.rglob('*.py'))
|
|
248
|
+
):
|
|
249
|
+
raise BuildError(
|
|
250
|
+
'api',
|
|
251
|
+
'Namespace packages are unsupported',
|
|
252
|
+
directory,
|
|
253
|
+
)
|
|
254
|
+
modules = sorted(
|
|
255
|
+
[path.stem for path in search.glob('*.py')]
|
|
256
|
+
+ [
|
|
257
|
+
path.name
|
|
258
|
+
for path in search.iterdir()
|
|
259
|
+
if path.is_dir() and (path / '__init__.py').is_file()
|
|
260
|
+
]
|
|
261
|
+
)
|
|
262
|
+
if not modules:
|
|
263
|
+
raise BuildError(
|
|
264
|
+
'api', 'No Python modules in source folder', search
|
|
265
|
+
)
|
|
266
|
+
loader = griffe.GriffeLoader(
|
|
267
|
+
search_paths=[loader_search],
|
|
268
|
+
allow_inspection=False,
|
|
269
|
+
extensions=griffe.load_extensions(capture),
|
|
270
|
+
)
|
|
271
|
+
nodes: list[griffe.Object | griffe.Alias] = []
|
|
272
|
+
try:
|
|
273
|
+
for module in modules:
|
|
274
|
+
if (
|
|
275
|
+
re.fullmatch(r'[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*', module)
|
|
276
|
+
is None
|
|
277
|
+
):
|
|
278
|
+
raise BuildError('api', f'Invalid module name: {module}')
|
|
279
|
+
nodes.append(loader.load(module))
|
|
280
|
+
loader.resolve_aliases(external=False)
|
|
281
|
+
collector = Collector(root, search, source.name, capture)
|
|
282
|
+
for node in nodes:
|
|
283
|
+
for item in collector.visit(node, node.path):
|
|
284
|
+
if item.identifier in inventory:
|
|
285
|
+
raise BuildError(
|
|
286
|
+
'api', f'Duplicate API symbol: {item.identifier}'
|
|
287
|
+
)
|
|
288
|
+
inventory[item.identifier] = item
|
|
289
|
+
except (
|
|
290
|
+
griffe.LoadingError,
|
|
291
|
+
griffe.NameResolutionError,
|
|
292
|
+
griffe.AliasResolutionError,
|
|
293
|
+
griffe.CyclicAliasError,
|
|
294
|
+
ModuleNotFoundError,
|
|
295
|
+
KeyError,
|
|
296
|
+
OSError,
|
|
297
|
+
SyntaxError,
|
|
298
|
+
) as error:
|
|
299
|
+
raise BuildError('api', str(error), search) from error
|
|
300
|
+
return inventory
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
title: Copy explicitly selected documentation assets without source escapes.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import posixpath
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from public import public
|
|
13
|
+
|
|
14
|
+
from doukimd.config import project_path
|
|
15
|
+
from doukimd.errors import BuildError
|
|
16
|
+
from doukimd.models import Config
|
|
17
|
+
from doukimd.runtime import typechecked
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@public
|
|
21
|
+
@typechecked
|
|
22
|
+
def copy_assets(root: Path, config: Config) -> dict[str, bytes]:
|
|
23
|
+
"""
|
|
24
|
+
title: Copy matched docs-relative files and reject unsafe patterns.
|
|
25
|
+
parameters:
|
|
26
|
+
root:
|
|
27
|
+
type: Path
|
|
28
|
+
config:
|
|
29
|
+
type: Config
|
|
30
|
+
returns:
|
|
31
|
+
type: dict[str, bytes]
|
|
32
|
+
"""
|
|
33
|
+
docs = project_path(root, config.docs_dir)
|
|
34
|
+
result: dict[str, bytes] = {}
|
|
35
|
+
for pattern in config.assets:
|
|
36
|
+
if (
|
|
37
|
+
not pattern
|
|
38
|
+
or Path(pattern).is_absolute()
|
|
39
|
+
or '..' in Path(pattern).parts
|
|
40
|
+
or '\\' in pattern
|
|
41
|
+
):
|
|
42
|
+
raise BuildError('assets', f'Invalid asset pattern: {pattern}')
|
|
43
|
+
# pathlib glob does not recurse through linked directories. Check
|
|
44
|
+
# their containment too, so an escape is never silently omitted.
|
|
45
|
+
for path in docs.rglob('*'):
|
|
46
|
+
if path.is_symlink() and not path.resolve().is_relative_to(docs):
|
|
47
|
+
raise BuildError(
|
|
48
|
+
'assets', 'Asset escapes docs directory', path
|
|
49
|
+
)
|
|
50
|
+
matches: list[Path] = []
|
|
51
|
+
for path in docs.glob(pattern):
|
|
52
|
+
matches.extend(path.rglob('*') if path.is_dir() else [path])
|
|
53
|
+
files = [path for path in matches if path.is_file()]
|
|
54
|
+
if not files and pattern != 'assets/**':
|
|
55
|
+
raise BuildError('assets', f'No assets match: {pattern}')
|
|
56
|
+
for path in sorted(files):
|
|
57
|
+
if not path.resolve().is_relative_to(docs):
|
|
58
|
+
raise BuildError(
|
|
59
|
+
'assets', 'Asset escapes docs directory', path
|
|
60
|
+
)
|
|
61
|
+
if path.suffix.lower() in {'.md', '.ipynb'}:
|
|
62
|
+
raise BuildError(
|
|
63
|
+
'assets', 'Asset pattern selects source', path
|
|
64
|
+
)
|
|
65
|
+
result[path.relative_to(docs).as_posix()] = path.read_bytes()
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@public
|
|
70
|
+
@typechecked
|
|
71
|
+
def asset_url(
|
|
72
|
+
value: str | None, route: str, assets: dict[str, bytes]
|
|
73
|
+
) -> str | None:
|
|
74
|
+
"""
|
|
75
|
+
title: Resolve a configured docs-root asset relative to a planned page.
|
|
76
|
+
parameters:
|
|
77
|
+
value:
|
|
78
|
+
type: str | None
|
|
79
|
+
route:
|
|
80
|
+
type: str
|
|
81
|
+
assets:
|
|
82
|
+
type: dict[str, bytes]
|
|
83
|
+
returns:
|
|
84
|
+
type: str | None
|
|
85
|
+
"""
|
|
86
|
+
if value is None:
|
|
87
|
+
return None
|
|
88
|
+
if value not in assets:
|
|
89
|
+
raise BuildError('assets', f'Asset is not copied: {value}')
|
|
90
|
+
return quote(posixpath.relpath(value, posixpath.dirname(route) or '.'))
|