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.
- shikumi_devdoc/__init__.py +23 -0
- shikumi_devdoc/_placeholder_syntax.py +124 -0
- shikumi_devdoc/cli.py +278 -0
- shikumi_devdoc/context.py +97 -0
- shikumi_devdoc/norms/__init__.py +19 -0
- shikumi_devdoc/norms/changelog.py +624 -0
- shikumi_devdoc/norms/common.py +147 -0
- shikumi_devdoc/norms/document.py +263 -0
- shikumi_devdoc/norms/vocabulary.py +350 -0
- shikumi_devdoc/realizers/__init__.py +23 -0
- shikumi_devdoc/realizers/_header_comment.py +13 -0
- shikumi_devdoc/realizers/_placeholders.py +87 -0
- shikumi_devdoc/realizers/changelog_markdown.py +131 -0
- shikumi_devdoc/realizers/document_markdown.py +267 -0
- shikumi_devdoc/realizers/glossary_markdown.py +92 -0
- shikumi_devdoc/realizers/translation_source.py +149 -0
- shikumi_devdoc/realizers/vocabulary_reference_python.py +128 -0
- shikumi_devdoc-0.1.0.dist-info/METADATA +442 -0
- shikumi_devdoc-0.1.0.dist-info/RECORD +22 -0
- shikumi_devdoc-0.1.0.dist-info/WHEEL +4 -0
- shikumi_devdoc-0.1.0.dist-info/entry_points.txt +2 -0
- shikumi_devdoc-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|