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 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
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m varda``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
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)}