varda 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.
- varda/__init__.py +29 -0
- varda/__main__.py +8 -0
- varda/anns.py +103 -0
- varda/cli.py +277 -0
- varda/ext.py +181 -0
- varda/gen_docs.py +90 -0
- varda/gen_sql.py +127 -0
- varda/generators.py +24 -0
- varda/model.py +259 -0
- varda/profile/varda.yaml +290 -0
- varda/py.typed +0 -0
- varda/registry.py +594 -0
- varda/rules.py +596 -0
- varda-0.1.0.dist-info/METADATA +206 -0
- varda-0.1.0.dist-info/RECORD +19 -0
- varda-0.1.0.dist-info/WHEEL +5 -0
- varda-0.1.0.dist-info/entry_points.txt +2 -0
- varda-0.1.0.dist-info/licenses/LICENSE +21 -0
- varda-0.1.0.dist-info/top_level.txt +1 -0
varda/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Varda — dimensional modeling for LinkML.
|
|
2
|
+
|
|
3
|
+
A profile of LinkML that adds the vocabulary of dimensional modeling — facts,
|
|
4
|
+
dimensions, grain, additivity, slowly-changing dimensions — plus the rules
|
|
5
|
+
that check a model against it and the generators that build from it.
|
|
6
|
+
|
|
7
|
+
Third parties import :mod:`varda.ext` and nothing else. Everything reachable
|
|
8
|
+
from there is public and versioned; everything else is internal and moves
|
|
9
|
+
without notice.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
from .ext import Context, Extension, ExtensionError, Generator
|
|
17
|
+
from .model import DimensionalModel
|
|
18
|
+
from .rules import Finding, RuleSet
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Context",
|
|
22
|
+
"DimensionalModel",
|
|
23
|
+
"Extension",
|
|
24
|
+
"ExtensionError",
|
|
25
|
+
"Finding",
|
|
26
|
+
"Generator",
|
|
27
|
+
"RuleSet",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
varda/__main__.py
ADDED
varda/anns.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Annotation access.
|
|
2
|
+
|
|
3
|
+
LinkML stores annotations as ``JsonObj`` in some code paths and plain dicts in
|
|
4
|
+
others, and the two do not share an interface. Every read of an annotation in
|
|
5
|
+
this package goes through :func:`anns`, so that inconsistency is handled in
|
|
6
|
+
exactly one place.
|
|
7
|
+
|
|
8
|
+
Reads are namespaced. A :class:`Reader` is bound to one prefix and sees only
|
|
9
|
+
that prefix's annotations, which is what lets Varda and a third-party
|
|
10
|
+
extension annotate the same class without either guessing at the other's
|
|
11
|
+
vocabulary. Varda's own reader is :data:`varda`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from jsonasobj2 import items as _ja_items
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def anns(obj: Any) -> dict[str, Any]:
|
|
23
|
+
"""Return an object's annotations as a plain ``{tag: value}`` dict."""
|
|
24
|
+
declared = getattr(obj, "annotations", None)
|
|
25
|
+
if not declared:
|
|
26
|
+
return {}
|
|
27
|
+
out: dict[str, Any] = {}
|
|
28
|
+
for tag, ann in _ja_items(declared):
|
|
29
|
+
value = (
|
|
30
|
+
ann.get("value")
|
|
31
|
+
if isinstance(ann, dict)
|
|
32
|
+
else getattr(ann, "value", ann)
|
|
33
|
+
)
|
|
34
|
+
out[str(tag)] = value
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Reader:
|
|
40
|
+
"""Reads one namespace's annotations off a LinkML object.
|
|
41
|
+
|
|
42
|
+
The prefix *is* the namespace, and the match is exact: a reader for
|
|
43
|
+
``acme`` never sees ``varda:role``, and Varda's reader never sees
|
|
44
|
+
``acme:cost_center``. Reads are deliberately strict about the prefix — an
|
|
45
|
+
unprefixed ``role:`` annotation is nobody's, not everybody's, and
|
|
46
|
+
accepting it as Varda's is how one party's vocabulary silently consumes
|
|
47
|
+
another's.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
prefix: str
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def tag(self) -> str:
|
|
54
|
+
"""The prefix with its colon, as it appears on an annotation."""
|
|
55
|
+
return f"{self.prefix}:"
|
|
56
|
+
|
|
57
|
+
def raw(self, obj: Any, key: str) -> Any:
|
|
58
|
+
"""Read one annotation, with or without the prefix on ``key``.
|
|
59
|
+
|
|
60
|
+
Returns the value as LinkML stored it. Only the converters below
|
|
61
|
+
should need this; everything else wants :meth:`get`.
|
|
62
|
+
"""
|
|
63
|
+
full = key if key.startswith(self.tag) else self.tag + key
|
|
64
|
+
return anns(obj).get(full)
|
|
65
|
+
|
|
66
|
+
def get(self, obj: Any, key: str) -> str | None:
|
|
67
|
+
"""Read one annotation as a string.
|
|
68
|
+
|
|
69
|
+
The coercion is the point. A value arrives as whatever the YAML parser
|
|
70
|
+
made of it — ``16`` is an int, ``true`` is a bool — and every caller
|
|
71
|
+
here wants a string. Doing it once means the accessors in
|
|
72
|
+
:mod:`varda.model` can promise ``str | None`` and be telling the
|
|
73
|
+
truth, rather than promising it while handing back ``Any``.
|
|
74
|
+
"""
|
|
75
|
+
value = self.raw(obj, key)
|
|
76
|
+
return None if value is None else str(value)
|
|
77
|
+
|
|
78
|
+
def present(self, obj: Any) -> bool:
|
|
79
|
+
"""Flag whether the object carries any annotation in this namespace."""
|
|
80
|
+
return any(k.startswith(self.tag) for k in anns(obj))
|
|
81
|
+
|
|
82
|
+
def keys(self, obj: Any) -> list[str]:
|
|
83
|
+
"""List this namespace's annotation names, prefix stripped."""
|
|
84
|
+
n = len(self.tag)
|
|
85
|
+
return sorted(k[n:] for k in anns(obj) if k.startswith(self.tag))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
#: Varda's own reader.
|
|
89
|
+
varda = Reader("varda")
|
|
90
|
+
|
|
91
|
+
get = varda.get
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def is_model_object(obj: Any) -> bool:
|
|
95
|
+
"""Flag whether an object participates in the dimensional model.
|
|
96
|
+
|
|
97
|
+
Deliberately *not* prefix-aware. A class is part of the model because
|
|
98
|
+
Varda says it is, and a class carrying only ``acme:`` annotations is a
|
|
99
|
+
class an extension has decorated — not one it has enrolled. Making this
|
|
100
|
+
prefix-aware would let an extension pull arbitrary classes into the model
|
|
101
|
+
by annotating them, which is exactly the authority extensions do not have.
|
|
102
|
+
"""
|
|
103
|
+
return varda.present(obj)
|
varda/cli.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""The command line.
|
|
2
|
+
|
|
3
|
+
Five commands, each doing one thing. Exit codes are part of the contract
|
|
4
|
+
because these run in CI: ``0`` success, ``1`` the model or the run failed,
|
|
5
|
+
``2`` the invocation was wrong. A tool that returns ``0`` for "I could not
|
|
6
|
+
tell" is one that turns a red build green.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import pathlib
|
|
13
|
+
import sys
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from . import registry
|
|
17
|
+
from .ext import Context, ExtensionError
|
|
18
|
+
from .model import DimensionalModel
|
|
19
|
+
from .rules import all_rules, check, unknown_codes
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
|
|
24
|
+
from .ext import Generator
|
|
25
|
+
|
|
26
|
+
EXIT_OK = 0
|
|
27
|
+
EXIT_FAIL = 1
|
|
28
|
+
EXIT_USAGE = 2
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load(path: str) -> DimensionalModel:
|
|
32
|
+
"""Load a model, resolving symbolic profile imports."""
|
|
33
|
+
return DimensionalModel.load(path, importmap=registry.importmap())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _extensions_line() -> str:
|
|
37
|
+
"""Name the active extensions, for the header of a run."""
|
|
38
|
+
names = [
|
|
39
|
+
f"{active.name} {active.version}" for active in registry.extensions()
|
|
40
|
+
]
|
|
41
|
+
return ", ".join(names)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# check
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
50
|
+
"""Validate a model against every active extension's rules."""
|
|
51
|
+
model = _load(args.model)
|
|
52
|
+
exempt = [*registry.exemptions(), *(args.exempt or [])]
|
|
53
|
+
|
|
54
|
+
stale = unknown_codes(exempt)
|
|
55
|
+
if stale:
|
|
56
|
+
where = "; ".join(stale)
|
|
57
|
+
print(
|
|
58
|
+
f"warning: exemption names no registered rule: {where}",
|
|
59
|
+
file=sys.stderr,
|
|
60
|
+
)
|
|
61
|
+
if args.strict:
|
|
62
|
+
return EXIT_FAIL
|
|
63
|
+
|
|
64
|
+
findings = check(model, exemptions=exempt)
|
|
65
|
+
for finding in findings:
|
|
66
|
+
print(finding)
|
|
67
|
+
|
|
68
|
+
errors = sum(1 for f in findings if f.severity == "error")
|
|
69
|
+
warnings = sum(1 for f in findings if f.severity == "warning")
|
|
70
|
+
tables = len(model.tables)
|
|
71
|
+
print(
|
|
72
|
+
f"\n{tables} tables checked against {len(all_rules())} rules "
|
|
73
|
+
f"({_extensions_line()}): {errors} errors, {warnings} warnings"
|
|
74
|
+
)
|
|
75
|
+
if errors or (args.strict and warnings):
|
|
76
|
+
return EXIT_FAIL
|
|
77
|
+
return EXIT_OK
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# generate
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _selected(only: Sequence[str] | None) -> list[Generator]:
|
|
86
|
+
"""Pick the generators to run, or raise on a name nobody registers."""
|
|
87
|
+
available = registry.generators()
|
|
88
|
+
if not only:
|
|
89
|
+
return list(available)
|
|
90
|
+
by_name = {g.name: g for g in available}
|
|
91
|
+
missing = [n for n in only if n not in by_name]
|
|
92
|
+
if missing:
|
|
93
|
+
known = ", ".join(sorted(by_name)) or "none"
|
|
94
|
+
msg = f"unknown generator(s): {', '.join(missing)}. Known: {known}"
|
|
95
|
+
raise KeyError(msg)
|
|
96
|
+
return [by_name[n] for n in only]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_generate(args: argparse.Namespace) -> int:
|
|
100
|
+
"""Run generators and write their output.
|
|
101
|
+
|
|
102
|
+
Two phases on purpose. Every generator runs and every result is collected
|
|
103
|
+
before a single file is written, so a generator that raises half way
|
|
104
|
+
through leaves no partial output tree behind. A half-generated estate is
|
|
105
|
+
worse than none: it looks complete, and the parts that are stale are the
|
|
106
|
+
parts nobody thinks to check.
|
|
107
|
+
"""
|
|
108
|
+
model = _load(args.model)
|
|
109
|
+
try:
|
|
110
|
+
chosen = _selected(args.only)
|
|
111
|
+
except KeyError as exc:
|
|
112
|
+
print(str(exc).strip("'\""), file=sys.stderr)
|
|
113
|
+
return EXIT_USAGE
|
|
114
|
+
if not chosen:
|
|
115
|
+
print("no generators registered", file=sys.stderr)
|
|
116
|
+
return EXIT_USAGE
|
|
117
|
+
|
|
118
|
+
ctx = Context(model=model, source=model.source, schema=args.schema)
|
|
119
|
+
collected: dict[str, str] = {}
|
|
120
|
+
for gen in chosen:
|
|
121
|
+
try:
|
|
122
|
+
produced = gen.run(ctx)
|
|
123
|
+
except Exception as exc: # noqa: BLE001
|
|
124
|
+
# Deliberately broad. A third-party generator may raise anything,
|
|
125
|
+
# and the CLI's job at this point is to fail closed with a legible
|
|
126
|
+
# message naming the culprit, rather than to hand the operator a
|
|
127
|
+
# traceback through somebody else's code. Nothing has been written
|
|
128
|
+
# yet, which is the whole reason for collecting before writing.
|
|
129
|
+
print(
|
|
130
|
+
f"{gen.name} failed: {type(exc).__name__}: {exc}\n"
|
|
131
|
+
f"nothing was written",
|
|
132
|
+
file=sys.stderr,
|
|
133
|
+
)
|
|
134
|
+
return EXIT_FAIL
|
|
135
|
+
undeclared = sorted(set(produced) - set(gen.artifacts))
|
|
136
|
+
if undeclared:
|
|
137
|
+
print(
|
|
138
|
+
f"{gen.name} wrote undeclared path(s): {', '.join(undeclared)}",
|
|
139
|
+
file=sys.stderr,
|
|
140
|
+
)
|
|
141
|
+
return EXIT_FAIL
|
|
142
|
+
collected.update(produced)
|
|
143
|
+
|
|
144
|
+
out = pathlib.Path(args.out)
|
|
145
|
+
for rel, content in sorted(collected.items()):
|
|
146
|
+
target = out / rel
|
|
147
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
target.write_text(content, encoding="utf-8")
|
|
149
|
+
print(f"wrote {target}")
|
|
150
|
+
print(f"\n{len(collected)} artifacts from {len(chosen)} generators")
|
|
151
|
+
return EXIT_OK
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# The rules, ext and importmap commands
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cmd_rules(args: argparse.Namespace) -> int:
|
|
160
|
+
"""List every registered rule."""
|
|
161
|
+
overrides = registry.severities()
|
|
162
|
+
for code, severity, title, fn in all_rules():
|
|
163
|
+
effective = overrides.get(code, severity)
|
|
164
|
+
mark = "*" if effective != severity else " "
|
|
165
|
+
print(f"{code} {effective:<7}{mark} {title}")
|
|
166
|
+
if args.verbose and fn.__doc__:
|
|
167
|
+
for line in fn.__doc__.strip().splitlines():
|
|
168
|
+
print(f" {line.strip()}")
|
|
169
|
+
print()
|
|
170
|
+
print(f"\n{len(all_rules())} rules ({_extensions_line()})")
|
|
171
|
+
return EXIT_OK
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def cmd_ext(args: argparse.Namespace) -> int:
|
|
175
|
+
"""Describe the active extensions."""
|
|
176
|
+
for active in registry.extensions():
|
|
177
|
+
if args.name and active.name != args.name:
|
|
178
|
+
continue
|
|
179
|
+
print(f"{active.name} {active.version} [{active.prefix}:]")
|
|
180
|
+
print(f" origin {active.origin or 'unknown'}")
|
|
181
|
+
print(f" rule tag {active.rule_tag}")
|
|
182
|
+
n_rules = len(active.rules.rules) if active.rules else 0
|
|
183
|
+
print(f" rules {n_rules}")
|
|
184
|
+
if active.profile:
|
|
185
|
+
print(f" profile {active.profile}")
|
|
186
|
+
for target in registry.TARGETS:
|
|
187
|
+
tags = sorted(
|
|
188
|
+
t
|
|
189
|
+
for t in registry.declared_annotations(target)
|
|
190
|
+
if t.startswith(f"{active.prefix}:")
|
|
191
|
+
)
|
|
192
|
+
if tags:
|
|
193
|
+
print(f" {target:<11} {', '.join(tags)}")
|
|
194
|
+
if active.generators:
|
|
195
|
+
names = ", ".join(g.name for g in active.generators)
|
|
196
|
+
print(f" generators {names}")
|
|
197
|
+
print()
|
|
198
|
+
return EXIT_OK
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def cmd_importmap(_: argparse.Namespace) -> int:
|
|
202
|
+
"""Print the map that resolves symbolic profile imports."""
|
|
203
|
+
for prefix, path in sorted(registry.importmap().items()):
|
|
204
|
+
print(f"{prefix}={path}")
|
|
205
|
+
return EXIT_OK
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# Wiring
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
214
|
+
"""Build the argument parser."""
|
|
215
|
+
parser = argparse.ArgumentParser(
|
|
216
|
+
prog="varda",
|
|
217
|
+
description="Dimensional modeling for LinkML.",
|
|
218
|
+
)
|
|
219
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
220
|
+
|
|
221
|
+
p_check = sub.add_parser("check", help="validate a model")
|
|
222
|
+
p_check.add_argument("model")
|
|
223
|
+
p_check.add_argument(
|
|
224
|
+
"--exempt",
|
|
225
|
+
action="append",
|
|
226
|
+
metavar="CODE",
|
|
227
|
+
help="skip a rule; repeatable",
|
|
228
|
+
)
|
|
229
|
+
p_check.add_argument(
|
|
230
|
+
"--strict",
|
|
231
|
+
action="store_true",
|
|
232
|
+
help="fail on warnings and on exemptions that name no rule",
|
|
233
|
+
)
|
|
234
|
+
p_check.set_defaults(fn=cmd_check)
|
|
235
|
+
|
|
236
|
+
p_gen = sub.add_parser("generate", help="write artifacts from a model")
|
|
237
|
+
p_gen.add_argument("model")
|
|
238
|
+
p_gen.add_argument("--out", default="out", help="output directory")
|
|
239
|
+
p_gen.add_argument("--schema", default="mart", help="SQL schema name")
|
|
240
|
+
p_gen.add_argument(
|
|
241
|
+
"--only",
|
|
242
|
+
action="append",
|
|
243
|
+
metavar="NAME",
|
|
244
|
+
help="run only this generator; repeatable",
|
|
245
|
+
)
|
|
246
|
+
p_gen.set_defaults(fn=cmd_generate)
|
|
247
|
+
|
|
248
|
+
p_rules = sub.add_parser("rules", help="list conformance rules")
|
|
249
|
+
p_rules.add_argument("-v", "--verbose", action="store_true")
|
|
250
|
+
p_rules.set_defaults(fn=cmd_rules)
|
|
251
|
+
|
|
252
|
+
p_ext = sub.add_parser("ext", help="describe active extensions")
|
|
253
|
+
p_ext.add_argument("name", nargs="?")
|
|
254
|
+
p_ext.set_defaults(fn=cmd_ext)
|
|
255
|
+
|
|
256
|
+
p_map = sub.add_parser("importmap", help="print the LinkML import map")
|
|
257
|
+
p_map.set_defaults(fn=cmd_importmap)
|
|
258
|
+
|
|
259
|
+
return parser
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
263
|
+
"""Entry point."""
|
|
264
|
+
args = build_parser().parse_args(argv)
|
|
265
|
+
try:
|
|
266
|
+
result: int = args.fn(args)
|
|
267
|
+
except ExtensionError as exc:
|
|
268
|
+
print(f"extension error: {exc}", file=sys.stderr)
|
|
269
|
+
return EXIT_FAIL
|
|
270
|
+
except FileNotFoundError as exc:
|
|
271
|
+
print(f"no such file: {exc.filename}", file=sys.stderr)
|
|
272
|
+
return EXIT_USAGE
|
|
273
|
+
return result
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
if __name__ == "__main__":
|
|
277
|
+
raise SystemExit(main())
|
varda/ext.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""The extension interface — the only module a third party imports.
|
|
2
|
+
|
|
3
|
+
An extension is one party's contribution to Varda: a namespace prefix, a
|
|
4
|
+
LinkML profile declaring the annotations that prefix permits, and optionally
|
|
5
|
+
a set of conformance rules and generators. Varda itself is an instance of
|
|
6
|
+
:class:`Extension` (see :mod:`varda.registry`), which is the test that this
|
|
7
|
+
interface is real rather than a second-class bolt-on: if the core can be
|
|
8
|
+
expressed through it, a third party is not working around anything.
|
|
9
|
+
|
|
10
|
+
The governing principle is **one party, one namespace; extensions add, they
|
|
11
|
+
never redefine**. An extension may introduce annotations, enumerations and
|
|
12
|
+
rules under its own prefix. It may not add a value to ``TableRole``, change
|
|
13
|
+
what ``semi_additive`` means, or replace a Varda generator — because every
|
|
14
|
+
generator dispatches exhaustively on those closed vocabularies and raises on
|
|
15
|
+
a value it cannot map. Widening one from outside turns that discipline into a
|
|
16
|
+
fallback path. See the note above ``enums:`` in ``profile/varda.yaml``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from functools import cached_property
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
from linkml_runtime.utils.schemaview import SchemaView
|
|
27
|
+
|
|
28
|
+
from .anns import Reader
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
import pathlib
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
|
|
34
|
+
from .model import DimensionalModel
|
|
35
|
+
from .rules import RuleSet
|
|
36
|
+
|
|
37
|
+
#: A prefix is a namespace, and it becomes a YAML key, a Python identifier and
|
|
38
|
+
#: part of a filename. The intersection of what all three accept is narrow.
|
|
39
|
+
PREFIX_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
40
|
+
|
|
41
|
+
#: A rule tag is the letters every code in a set begins with: `V` for Varda,
|
|
42
|
+
#: `ACME` for an extension whose prefix is `acme`.
|
|
43
|
+
TAG_PATTERN = re.compile(r"^[A-Z]+$")
|
|
44
|
+
|
|
45
|
+
Severity = str # "error" | "warning" | "info"
|
|
46
|
+
|
|
47
|
+
#: Declared here rather than in `rules`, because the registry validates
|
|
48
|
+
#: severity overrides too, and two copies of a closed three-value vocabulary
|
|
49
|
+
#: is exactly the drift this design exists to prevent.
|
|
50
|
+
SEVERITIES = frozenset({"error", "warning", "info"})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ExtensionError(Exception):
|
|
54
|
+
"""An extension is malformed, or collides with another one.
|
|
55
|
+
|
|
56
|
+
Always raised, never warned. The cost of raising is a clear error at
|
|
57
|
+
startup; the cost of warning is an estate that half-works for two years
|
|
58
|
+
because nobody reads startup warnings.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Context:
|
|
64
|
+
"""Everything a generator is given, and nothing more.
|
|
65
|
+
|
|
66
|
+
Deliberately a closed record rather than the CLI's argument namespace. A
|
|
67
|
+
generator that can reach the CLI's state ends up depending on flags, and
|
|
68
|
+
then its output is a function of how it was invoked rather than of the
|
|
69
|
+
model — which is the property the whole generate-and-compare arrangement
|
|
70
|
+
depends on.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
model: DimensionalModel
|
|
74
|
+
source: pathlib.Path
|
|
75
|
+
schema: str = "mart"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, eq=False)
|
|
79
|
+
class Generator:
|
|
80
|
+
"""A named producer of files, declaring what it will write.
|
|
81
|
+
|
|
82
|
+
``run`` returns ``{relative path: content}`` and never touches the disk.
|
|
83
|
+
That is what makes the fail-closed guarantee in :func:`varda.cli.generate`
|
|
84
|
+
possible: every generator is run and every result collected before
|
|
85
|
+
anything is written, so a failure half way through leaves no half-written
|
|
86
|
+
output tree behind.
|
|
87
|
+
|
|
88
|
+
Paths are declared up front, in ``artifacts``, so that two extensions
|
|
89
|
+
claiming the same output file are caught at startup rather than by one
|
|
90
|
+
silently overwriting the other's work.
|
|
91
|
+
|
|
92
|
+
``eq=False`` gives identity comparison, because ``run`` is a function and
|
|
93
|
+
two distinct generators wrapping the same function are still two
|
|
94
|
+
generators.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
name: str
|
|
98
|
+
artifacts: tuple[str, ...]
|
|
99
|
+
run: Callable[[Context], dict[str, str]]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True, eq=False)
|
|
103
|
+
class Extension:
|
|
104
|
+
"""One party's contribution to Varda.
|
|
105
|
+
|
|
106
|
+
The only required fields are ``name`` and ``prefix``. An extension that
|
|
107
|
+
declares nothing but a profile is legal and useful: it adds vocabulary
|
|
108
|
+
that ``varda check`` will then accept, which is the smallest thing an
|
|
109
|
+
organization typically wants.
|
|
110
|
+
|
|
111
|
+
``eq=False`` because ``rules`` holds a mutable :class:`RuleSet`, which
|
|
112
|
+
makes the dataclass unhashable under the default ``eq``; identity is the
|
|
113
|
+
right comparison for a registered singleton anyway.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
name: str
|
|
117
|
+
prefix: str
|
|
118
|
+
version: str = "0"
|
|
119
|
+
|
|
120
|
+
#: Path to a LinkML schema declaring this extension's annotations. Its
|
|
121
|
+
#: `default_prefix` must equal `prefix` — the registry checks, because a
|
|
122
|
+
#: profile that declares annotations under a prefix nobody reads is a
|
|
123
|
+
#: vocabulary that silently never applies.
|
|
124
|
+
profile: pathlib.Path | None = None
|
|
125
|
+
|
|
126
|
+
#: Conformance rules. Every code must begin with `rule_tag`.
|
|
127
|
+
rules: RuleSet | None = None
|
|
128
|
+
|
|
129
|
+
#: Defaults to `prefix.upper()`. Varda's is `V`, not by special-casing
|
|
130
|
+
#: but because tag uniqueness is checked across all extensions and Varda
|
|
131
|
+
#: registers first.
|
|
132
|
+
rule_tag: str = ""
|
|
133
|
+
|
|
134
|
+
#: `{rule code: severity}` — this extension's opinion about the severity
|
|
135
|
+
#: of a rule, including one it does not own. Two extensions disagreeing
|
|
136
|
+
#: about one code is refused rather than resolved; see the registry.
|
|
137
|
+
severity_defaults: dict[str, Severity] = field(default_factory=dict)
|
|
138
|
+
|
|
139
|
+
#: The importable package name, used to resolve the profile for the
|
|
140
|
+
#: LinkML import map so a domain model can write `imports: - acme`.
|
|
141
|
+
package: str = ""
|
|
142
|
+
|
|
143
|
+
generators: tuple[Generator, ...] = ()
|
|
144
|
+
|
|
145
|
+
#: Where this extension was found — entry point, config file, or
|
|
146
|
+
#: injection. Excluded from comparison because it is diagnostic only.
|
|
147
|
+
origin: str = field(default="", compare=False)
|
|
148
|
+
|
|
149
|
+
def __post_init__(self) -> None:
|
|
150
|
+
"""Derive the rule tag from the prefix when it was not given."""
|
|
151
|
+
if not self.rule_tag:
|
|
152
|
+
object.__setattr__(self, "rule_tag", self.prefix.upper())
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def reader(prefix: str) -> Reader:
|
|
156
|
+
"""Build a reader for a namespace.
|
|
157
|
+
|
|
158
|
+
Exposed here so an extension never imports :mod:`varda.anns`: the
|
|
159
|
+
whole public surface is this module, and a third party reading its
|
|
160
|
+
own annotations is the most common thing it will do.
|
|
161
|
+
"""
|
|
162
|
+
return Reader(prefix)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def anns(self) -> Reader:
|
|
166
|
+
"""A reader bound to this extension's own prefix."""
|
|
167
|
+
return Reader(self.prefix)
|
|
168
|
+
|
|
169
|
+
@cached_property
|
|
170
|
+
def profile_view(self) -> SchemaView | None:
|
|
171
|
+
"""The parsed profile, or ``None`` if this extension declares none."""
|
|
172
|
+
return None if self.profile is None else SchemaView(str(self.profile))
|
|
173
|
+
|
|
174
|
+
@cached_property
|
|
175
|
+
def profile_version(self) -> str | None:
|
|
176
|
+
"""The version string the profile declares, if it declares one."""
|
|
177
|
+
view = self.profile_view
|
|
178
|
+
if view is None:
|
|
179
|
+
return None
|
|
180
|
+
version: Any = view.schema.version
|
|
181
|
+
return None if version is None else str(version)
|
varda/gen_docs.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Documentation generation.
|
|
2
|
+
|
|
3
|
+
Emits one Markdown reference for the whole model. The audience is an analyst
|
|
4
|
+
deciding whether a table answers their question, so the ordering is facts
|
|
5
|
+
first — a fact is what someone starts from — and the grain sentence is given
|
|
6
|
+
the most prominent position it can have.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .ext import Context
|
|
15
|
+
from .model import DimensionalModel, Table
|
|
16
|
+
|
|
17
|
+
ADDITIVITY = {
|
|
18
|
+
"additive": "sums across every dimension",
|
|
19
|
+
"semi_additive": "does not sum across",
|
|
20
|
+
"non_additive": "never sum; recompute from components",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _columns(table: Table) -> list[str]:
|
|
25
|
+
rows = [
|
|
26
|
+
"| Column | Role | Type | Notes |",
|
|
27
|
+
"| --- | --- | --- | --- |",
|
|
28
|
+
]
|
|
29
|
+
for column in table.columns:
|
|
30
|
+
notes: list[str] = []
|
|
31
|
+
if column.references:
|
|
32
|
+
notes.append(f"→ `{column.references}`")
|
|
33
|
+
if column.additivity:
|
|
34
|
+
phrase = ADDITIVITY.get(column.additivity, column.additivity)
|
|
35
|
+
if column.additivity == "semi_additive" and (
|
|
36
|
+
column.semi_additive_over
|
|
37
|
+
):
|
|
38
|
+
phrase = f"{phrase} `{column.semi_additive_over}`"
|
|
39
|
+
notes.append(phrase)
|
|
40
|
+
if column.unit:
|
|
41
|
+
notes.append(f"unit: `{column.unit}`")
|
|
42
|
+
if column.description:
|
|
43
|
+
notes.append(column.description)
|
|
44
|
+
rows.append(
|
|
45
|
+
f"| `{column.physical}` | {column.role or '—'} | "
|
|
46
|
+
f"{column.range} | {'; '.join(notes) or '—'} |"
|
|
47
|
+
)
|
|
48
|
+
return rows
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _table(table: Table) -> str:
|
|
52
|
+
lines = [f"### {table.name}", ""]
|
|
53
|
+
if table.description:
|
|
54
|
+
lines += [table.description, ""]
|
|
55
|
+
facts = [f"**Physical name:** `{table.physical}`"]
|
|
56
|
+
if table.grain:
|
|
57
|
+
facts.append(f"**Grain:** {table.grain}")
|
|
58
|
+
if table.fact_type:
|
|
59
|
+
facts.append(f"**Fact type:** {table.fact_type}")
|
|
60
|
+
if table.scd:
|
|
61
|
+
facts.append(f"**Slowly-changing:** {table.scd}")
|
|
62
|
+
lines += [*facts, "", *_columns(table), ""]
|
|
63
|
+
return "\n".join(lines)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def generate(model: DimensionalModel) -> str:
|
|
67
|
+
"""Render the model as a Markdown reference."""
|
|
68
|
+
out = [
|
|
69
|
+
"# Model reference",
|
|
70
|
+
"",
|
|
71
|
+
"Generated by varda. Do not edit.",
|
|
72
|
+
"",
|
|
73
|
+
f"Source: `{model.source.name}`",
|
|
74
|
+
"",
|
|
75
|
+
]
|
|
76
|
+
for heading, tables in (
|
|
77
|
+
("Facts", model.facts),
|
|
78
|
+
("Dimensions", model.dimensions),
|
|
79
|
+
("Bridges", model.bridges),
|
|
80
|
+
):
|
|
81
|
+
if not tables:
|
|
82
|
+
continue
|
|
83
|
+
out += [f"## {heading}", ""]
|
|
84
|
+
out += [_table(t) for t in tables]
|
|
85
|
+
return "\n".join(out).rstrip() + "\n"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run(ctx: Context) -> dict[str, str]:
|
|
89
|
+
"""Run this generator and return its artifacts."""
|
|
90
|
+
return {"docs/model.md": generate(ctx.model)}
|