shikumi-devdoc 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.
@@ -0,0 +1,23 @@
1
+ """Public surface for shikumi-devdoc."""
2
+
3
+ from .context import Context, ContextError, UnknownContextKeyError
4
+ from .norms.common import (
5
+ CanonicalSource,
6
+ VocabularyReference,
7
+ VocabularySource,
8
+ canonical,
9
+ vocabulary,
10
+ vocabulary_refs,
11
+ )
12
+
13
+ __all__ = [
14
+ "CanonicalSource",
15
+ "Context",
16
+ "ContextError",
17
+ "UnknownContextKeyError",
18
+ "VocabularyReference",
19
+ "VocabularySource",
20
+ "canonical",
21
+ "vocabulary",
22
+ "vocabulary_refs",
23
+ ]
@@ -0,0 +1,124 @@
1
+ """Lexical rules shared by devdoc placeholder consumers.
2
+
3
+ The placeholder language is intentionally tiny. ``{{KEY}}`` is semantic syntax,
4
+ while ``\\{{...}}`` emits literal double braces and ``${{...}}`` is reserved as
5
+ literal host-language syntax (notably GitHub Actions expressions). Keys beginning
6
+ with ``#`` are reserved for document section references.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable, Iterator
12
+ from dataclasses import dataclass
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class Placeholder:
17
+ """One semantic placeholder occurrence in source text."""
18
+
19
+ key: str
20
+ start: int
21
+ end: int
22
+
23
+
24
+ def placeholders(text: str) -> Iterator[Placeholder]:
25
+ """Yield semantic placeholders, excluding escaped and ``${{...}}`` forms."""
26
+
27
+ cursor = 0
28
+ while True:
29
+ start = text.find("{{", cursor)
30
+ if start < 0:
31
+ return
32
+
33
+ end_marker = text.find("}}", start + 2)
34
+ if end_marker < 0:
35
+ return
36
+ end = end_marker + 2
37
+
38
+ # ``\\{{...}}`` is the explicit literal escape. ``${{...}}`` is kept
39
+ # literal so common developer-documentation examples do not collide
40
+ # with devdoc's placeholder language.
41
+ if start > 0 and text[start - 1] in {"\\", "$"}:
42
+ cursor = end
43
+ continue
44
+
45
+ key = text[start + 2 : end_marker]
46
+ # Match the previous regex behavior: nested braces are not semantic
47
+ # placeholders. Empty keys were already impossible with ``+``.
48
+ if key and "{" not in key and "}" not in key:
49
+ yield Placeholder(key=key, start=start, end=end)
50
+
51
+ cursor = end
52
+
53
+
54
+ def placeholder_keys(text: str) -> tuple[str, ...]:
55
+ """Return semantic placeholder keys in source order."""
56
+
57
+ return tuple(placeholder.key for placeholder in placeholders(text))
58
+
59
+
60
+ def expand_placeholders(text: str, resolve: Callable[[str], str]) -> str:
61
+ """Resolve semantic placeholders and remove explicit literal escapes.
62
+
63
+ ``\\{{name}}`` becomes ``{{name}}`` without invoking *resolve*. ``${{name}}``
64
+ remains unchanged.
65
+ """
66
+
67
+ output: list[str] = []
68
+ cursor = 0
69
+
70
+ while True:
71
+ start = text.find("{{", cursor)
72
+ if start < 0:
73
+ output.append(text[cursor:])
74
+ break
75
+
76
+ end_marker = text.find("}}", start + 2)
77
+ if end_marker < 0:
78
+ output.append(text[cursor:])
79
+ break
80
+ end = end_marker + 2
81
+
82
+ if start > 0 and text[start - 1] == "\\":
83
+ # Include everything before the escape marker, then the literal
84
+ # braces without the escaping backslash.
85
+ output.append(text[cursor : start - 1])
86
+ output.append(text[start:end])
87
+ cursor = end
88
+ continue
89
+
90
+ output.append(text[cursor:start])
91
+
92
+ if start > 0 and text[start - 1] == "$":
93
+ # The '$' is already present in the preceding slice.
94
+ output.append(text[start:end])
95
+ cursor = end
96
+ continue
97
+
98
+ key = text[start + 2 : end_marker]
99
+ if key and "{" not in key and "}" not in key:
100
+ output.append(resolve(key))
101
+ else:
102
+ output.append(text[start:end])
103
+ cursor = end
104
+
105
+ return "".join(output)
106
+
107
+
108
+ def section_reference_key(key: str) -> str | None:
109
+ """Return the anchor name when *key* denotes a section reference."""
110
+
111
+ if not key.startswith("#") or len(key) == 1:
112
+ return None
113
+ return key[1:]
114
+
115
+
116
+ def section_reference_keys(text: str) -> tuple[str, ...]:
117
+ """Return referenced document anchors in source order, without duplicates."""
118
+
119
+ references: list[str] = []
120
+ for key in placeholder_keys(text):
121
+ reference = section_reference_key(key)
122
+ if reference is not None and reference not in references:
123
+ references.append(reference)
124
+ return tuple(references)
shikumi_devdoc/cli.py ADDED
@@ -0,0 +1,278 @@
1
+ """Command-line interface for shikumi-devdoc."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import inspect
8
+ from pathlib import Path
9
+ import sys
10
+ import tomllib
11
+ from types import ModuleType
12
+ from typing import Sequence
13
+
14
+ from shikumi import Diagnostic
15
+
16
+ from shikumi_devdoc.context import Context, ContextError
17
+ from shikumi_devdoc.norms.changelog import changelog_system
18
+ from shikumi_devdoc.norms.common import CanonicalSource
19
+ from shikumi_devdoc.norms.document import document
20
+ from shikumi_devdoc.norms.vocabulary import vocabulary_system
21
+ from shikumi_devdoc.realizers import (
22
+ ChangelogMarkdownRealizer,
23
+ DocumentMarkdownRealizer,
24
+ GlossaryMarkdownRealizer,
25
+ PythonReferenceModuleRealizer,
26
+ TranslationSourceRealizer,
27
+ )
28
+
29
+
30
+ def _build_parser() -> argparse.ArgumentParser:
31
+ parser = argparse.ArgumentParser(prog="shikumi-devdoc")
32
+ subparsers = parser.add_subparsers(dest="command", required=True)
33
+
34
+ terms = subparsers.add_parser(
35
+ "terms",
36
+ help="generate a human-readable Python term-reference module from a vocabulary source",
37
+ )
38
+ terms.add_argument(
39
+ "module",
40
+ help="dotted import path of the canonical vocabulary module",
41
+ )
42
+ terms.add_argument(
43
+ "-o",
44
+ "--output",
45
+ required=True,
46
+ type=Path,
47
+ help="path of the generated Python module",
48
+ )
49
+
50
+ render = subparsers.add_parser(
51
+ "render",
52
+ help="validate a canonical source and render an intermediate Markdown document",
53
+ )
54
+ render.add_argument(
55
+ "kind",
56
+ choices=("document", "glossary", "changelog"),
57
+ help="kind of canonical source to render",
58
+ )
59
+ render.add_argument(
60
+ "module",
61
+ help="dotted import path of the canonical source module",
62
+ )
63
+ render.add_argument(
64
+ "-o",
65
+ "--output",
66
+ required=True,
67
+ type=Path,
68
+ help="path of the generated Markdown file",
69
+ )
70
+ render.add_argument(
71
+ "--context",
72
+ help="JSON object containing the external-context snapshot for this rendering",
73
+ )
74
+ render.add_argument(
75
+ "--notice",
76
+ type=Path,
77
+ help="explicit TOML notice file whose [notice].content is embedded as a Markdown comment",
78
+ )
79
+ render.add_argument(
80
+ "--translation-source",
81
+ action="store_true",
82
+ help="embed translation metadata such as preserve-spelling terms in the Markdown source",
83
+ )
84
+ return parser
85
+
86
+
87
+ def _subject_name(subject: object | None) -> str | None:
88
+ if subject is None:
89
+ return None
90
+ if isinstance(subject, ModuleType):
91
+ return getattr(subject, "__name__", None)
92
+ module = getattr(subject, "__module__", None)
93
+ qualname = getattr(subject, "__qualname__", None)
94
+ if isinstance(module, str) and isinstance(qualname, str):
95
+ return f"{module}.{qualname}"
96
+ name = getattr(subject, "__name__", None)
97
+ return name if isinstance(name, str) else None
98
+
99
+
100
+ def _source_location(subject: object | None) -> str | None:
101
+ if subject is None:
102
+ return None
103
+
104
+ path: str | None = None
105
+ line: int | None = None
106
+ try:
107
+ if isinstance(subject, ModuleType):
108
+ raw = getattr(subject, "__file__", None)
109
+ path = raw if isinstance(raw, str) else None
110
+ else:
111
+ path = inspect.getsourcefile(subject)
112
+ if path is not None:
113
+ _, line = inspect.getsourcelines(subject)
114
+ except (OSError, TypeError):
115
+ pass
116
+
117
+ if path is None:
118
+ return None
119
+ source = Path(path).resolve()
120
+ try:
121
+ rendered = source.relative_to(Path.cwd().resolve()).as_posix()
122
+ except ValueError:
123
+ rendered = source.as_posix()
124
+ return f"{rendered}:{line}" if line is not None else rendered
125
+
126
+
127
+ def _format_diagnostic(diagnostic: Diagnostic) -> str:
128
+ severity = diagnostic.severity.value
129
+ code = f" [{diagnostic.code}]" if diagnostic.code else ""
130
+ location = _source_location(diagnostic.subject)
131
+ subject = _subject_name(diagnostic.subject)
132
+
133
+ header = f"{severity}{code}"
134
+ if location:
135
+ header += f" {location}"
136
+ rows = [header]
137
+ if subject:
138
+ rows.append(f" {subject}")
139
+ rows.append(f" {diagnostic.message}")
140
+ return "\n".join(rows)
141
+
142
+
143
+ def _print_diagnostics(diagnostics) -> None:
144
+ for index, diagnostic in enumerate(diagnostics):
145
+ if index:
146
+ print(file=sys.stderr)
147
+ print(_format_diagnostic(diagnostic), file=sys.stderr)
148
+
149
+
150
+ def _render_terms(module_name: str, output: Path) -> int:
151
+ module = importlib.import_module(module_name)
152
+ result = vocabulary_system.validate(module, placement=())
153
+ if result.diagnostics:
154
+ _print_diagnostics(result.diagnostics)
155
+ if not result.is_valid:
156
+ return 1
157
+
158
+ realizer = PythonReferenceModuleRealizer(module_name)
159
+ check = realizer.check(result.view)
160
+ if check.diagnostics:
161
+ _print_diagnostics(check.diagnostics)
162
+ if not check.is_realizable:
163
+ return 1
164
+
165
+ output.parent.mkdir(parents=True, exist_ok=True)
166
+ output.write_text(realizer.realize(result.view), encoding="utf-8")
167
+ print(output)
168
+ return 0
169
+
170
+
171
+ def _canonical_source(module: object) -> str:
172
+ raw = getattr(module, "__file__", None)
173
+ if not isinstance(raw, str):
174
+ return getattr(module, "__name__", "<unknown>")
175
+ path = Path(raw).resolve()
176
+ try:
177
+ return path.relative_to(Path.cwd().resolve()).as_posix()
178
+ except ValueError:
179
+ return path.as_posix()
180
+
181
+
182
+ def _load_notice(path: Path | None, canonical_source: str) -> str | None:
183
+ if path is None:
184
+ return None
185
+ with path.open("rb") as stream:
186
+ config = tomllib.load(stream)
187
+ notice = config.get("notice")
188
+ if not isinstance(notice, dict) or not isinstance(notice.get("content"), str):
189
+ raise ValueError(f"notice TOML requires [notice].content: {path}")
190
+ return notice["content"].strip().replace(
191
+ "{canonical_source}",
192
+ canonical_source,
193
+ )
194
+
195
+
196
+ def _canonical_source_from_view(view, fallback: object) -> str:
197
+ values = [
198
+ value
199
+ for item in view.entities
200
+ for value in item.values(CanonicalSource)
201
+ ]
202
+ if len(values) == 1:
203
+ return values[0]
204
+ return _canonical_source(fallback)
205
+
206
+
207
+ def _render_markdown(
208
+ kind: str,
209
+ module_name: str,
210
+ output: Path,
211
+ context_json: str | None,
212
+ notice_path: Path | None,
213
+ translation_source: bool,
214
+ ) -> int:
215
+ importlib.invalidate_caches()
216
+ module = importlib.import_module(module_name)
217
+ context = Context.from_json(context_json) if context_json is not None else Context({})
218
+
219
+ if kind == "document":
220
+ system = document
221
+ realizer_type = DocumentMarkdownRealizer
222
+ elif kind == "glossary":
223
+ system = vocabulary_system
224
+ realizer_type = GlossaryMarkdownRealizer
225
+ elif kind == "changelog":
226
+ system = changelog_system
227
+ realizer_type = ChangelogMarkdownRealizer
228
+ else: # pragma: no cover - argparse constrains this value
229
+ raise ValueError(f"unknown render kind: {kind}")
230
+
231
+ result = system.validate(module, placement=())
232
+ if result.diagnostics:
233
+ _print_diagnostics(result.diagnostics)
234
+ if not result.is_valid:
235
+ return 1
236
+
237
+ canonical_source = _canonical_source_from_view(result.view, module)
238
+ header_comment = _load_notice(notice_path, canonical_source)
239
+ realizer = realizer_type(context, header_comment=header_comment)
240
+ if translation_source:
241
+ realizer = TranslationSourceRealizer(realizer)
242
+
243
+ check = realizer.check(result.view)
244
+ if check.diagnostics:
245
+ _print_diagnostics(check.diagnostics)
246
+ if not check.is_realizable:
247
+ return 1
248
+
249
+ output.parent.mkdir(parents=True, exist_ok=True)
250
+ output.write_text(realizer.realize(result.view), encoding="utf-8")
251
+ print(output)
252
+ return 0
253
+
254
+
255
+ def main(argv: Sequence[str] | None = None) -> int:
256
+ parser = _build_parser()
257
+ args = parser.parse_args(argv)
258
+ try:
259
+ if args.command == "terms":
260
+ return _render_terms(args.module, args.output)
261
+ if args.command == "render":
262
+ return _render_markdown(
263
+ args.kind,
264
+ args.module,
265
+ args.output,
266
+ args.context,
267
+ args.notice,
268
+ args.translation_source,
269
+ )
270
+ except (OSError, ValueError, ContextError) as exc:
271
+ print(exc, file=sys.stderr)
272
+ return 1
273
+ parser.error(f"unknown command: {args.command}")
274
+ return 2
275
+
276
+
277
+ if __name__ == "__main__":
278
+ raise SystemExit(main())
@@ -0,0 +1,97 @@
1
+ """External JSON-compatible context used by document realizers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ import json
8
+ from typing import Any
9
+
10
+
11
+ class ContextError(ValueError):
12
+ """Base error for invalid or unresolved realization context."""
13
+
14
+
15
+ class UnknownContextKeyError(ContextError):
16
+ """Raised when a placeholder path cannot be resolved."""
17
+
18
+ def __init__(self, key: str) -> None:
19
+ super().__init__(f"unknown context key: {key}")
20
+ self.key = key
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class Context:
25
+ """A JSON-compatible mapping addressable through dotted paths.
26
+
27
+ ``PROJECT.version`` resolves ``{"PROJECT": {"version": ...}}``.
28
+ Numeric path components can index arrays, e.g. ``PEOPLE.0.name``.
29
+ """
30
+
31
+ data: Mapping[str, Any]
32
+
33
+ def __post_init__(self) -> None:
34
+ if not isinstance(self.data, Mapping):
35
+ raise TypeError("context root must be a mapping")
36
+
37
+ @classmethod
38
+ def from_json(cls, text: str) -> "Context":
39
+ """Create context from a JSON object string."""
40
+ try:
41
+ data = json.loads(text)
42
+ except json.JSONDecodeError as exc:
43
+ raise ContextError(f"invalid context JSON: {exc.msg}") from exc
44
+ if not isinstance(data, dict):
45
+ raise ContextError("context JSON root must be an object")
46
+ return cls(data)
47
+
48
+ @classmethod
49
+ def from_mapping(cls, data: Mapping[str, Any]) -> "Context":
50
+ return cls(data)
51
+
52
+ def resolve(self, key: str) -> str:
53
+ if not isinstance(key, str) or not key or any(not part for part in key.split(".")):
54
+ raise UnknownContextKeyError(key)
55
+
56
+ value: Any = self.data
57
+ for part in key.split("."):
58
+ if isinstance(value, Mapping):
59
+ if part not in value:
60
+ raise UnknownContextKeyError(key)
61
+ value = value[part]
62
+ continue
63
+ if (
64
+ isinstance(value, Sequence)
65
+ and not isinstance(value, (str, bytes, bytearray))
66
+ and part.isdecimal()
67
+ ):
68
+ index = int(part)
69
+ if index >= len(value):
70
+ raise UnknownContextKeyError(key)
71
+ value = value[index]
72
+ continue
73
+ raise UnknownContextKeyError(key)
74
+
75
+ if isinstance(value, str):
76
+ return value
77
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
78
+
79
+ def contains(self, key: str) -> bool:
80
+ try:
81
+ self.resolve(key)
82
+ except UnknownContextKeyError:
83
+ return False
84
+ return True
85
+
86
+
87
+ EMPTY_CONTEXT = Context({})
88
+
89
+
90
+ def normalize_context(value: Context | Mapping[str, Any] | None) -> Context:
91
+ if value is None:
92
+ return EMPTY_CONTEXT
93
+ if isinstance(value, Context):
94
+ return value
95
+ if isinstance(value, Mapping):
96
+ return Context(value)
97
+ raise TypeError("context must be a Context, mapping, or None")
@@ -0,0 +1,19 @@
1
+ """Reusable Shikumi regulations for developer documentation."""
2
+
3
+ from .common import (
4
+ CanonicalSource,
5
+ VocabularyReference,
6
+ VocabularySource,
7
+ canonical,
8
+ vocabulary,
9
+ vocabulary_refs,
10
+ )
11
+
12
+ __all__ = [
13
+ "CanonicalSource",
14
+ "VocabularyReference",
15
+ "VocabularySource",
16
+ "canonical",
17
+ "vocabulary",
18
+ "vocabulary_refs",
19
+ ]