doukimd 1.0.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.
- doukimd/__init__.py +28 -0
- doukimd/__main__.py +7 -0
- doukimd/api.py +300 -0
- doukimd/assets.py +90 -0
- doukimd/build.py +504 -0
- doukimd/cli.py +74 -0
- doukimd/collection.py +487 -0
- doukimd/config.py +148 -0
- doukimd/content.py +330 -0
- doukimd/errors.py +49 -0
- doukimd/html.py +189 -0
- doukimd/markdown.py +432 -0
- doukimd/models.py +435 -0
- doukimd/navigation.py +404 -0
- doukimd/plugins.py +83 -0
- doukimd/py.typed +0 -0
- doukimd/references.py +55 -0
- doukimd/runtime.py +85 -0
- doukimd/theme.py +95 -0
- doukimd/themes/default/page.html +117 -0
- doukimd/themes/default/palettes.json +122 -0
- doukimd/themes/default/theme.css +184 -0
- doukimd/themes/default/theme.js +63 -0
- doukimd/themes/vendor/bootstrap/LICENSE +21 -0
- doukimd/themes/vendor/bootstrap/README.md +11 -0
- doukimd/themes/vendor/bootstrap/bootstrap.min.css +6 -0
- doukimd/themes/vendor/radix/LICENSE +21 -0
- doukimd/themes/vendor/radix/README.md +26 -0
- doukimd-1.0.0.dist-info/METADATA +88 -0
- doukimd-1.0.0.dist-info/RECORD +33 -0
- doukimd-1.0.0.dist-info/WHEEL +4 -0
- doukimd-1.0.0.dist-info/entry_points.txt +3 -0
- doukimd-1.0.0.dist-info/licenses/LICENSE +28 -0
doukimd/__init__.py
ADDED
|
@@ -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()
|
doukimd/__main__.py
ADDED
doukimd/api.py
ADDED
|
@@ -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
|
doukimd/assets.py
ADDED
|
@@ -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 '.'))
|