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 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
@@ -0,0 +1,7 @@
1
+ """
2
+ title: Invoke the DoukiMD command line interface.
3
+ """
4
+
5
+ from doukimd.cli import main
6
+
7
+ main()
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 '.'))