modelable 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of modelable might be problematic. Click here for more details.
- modelable/__init__.py +1 -0
- modelable/__main__.py +3 -0
- modelable/_pydantic_py314_compat.py +31 -0
- modelable/cli.py +41 -0
- modelable/commands/__init__.py +1 -0
- modelable/commands/apicurio.py +84 -0
- modelable/commands/codegen.py +241 -0
- modelable/commands/common.py +43 -0
- modelable/commands/compile.py +237 -0
- modelable/commands/create.py +164 -0
- modelable/commands/diff.py +82 -0
- modelable/commands/graph.py +53 -0
- modelable/commands/llm.py +564 -0
- modelable/commands/lsp.py +15 -0
- modelable/commands/runtime.py +37 -0
- modelable/commands/scenario.py +104 -0
- modelable/commands/spec.py +197 -0
- modelable/commands/workspace.py +240 -0
- modelable/compat/__init__.py +11 -0
- modelable/compat/checker.py +179 -0
- modelable/compat/diff.py +169 -0
- modelable/compiler/__init__.py +3 -0
- modelable/compiler/compiler.py +19 -0
- modelable/compiler/workspace.py +346 -0
- modelable/diagnostics/__init__.py +3 -0
- modelable/diagnostics/model.py +27 -0
- modelable/emitters/__init__.py +0 -0
- modelable/emitters/base.py +22 -0
- modelable/emitters/csharp.py +245 -0
- modelable/emitters/dbt_yaml.py +290 -0
- modelable/emitters/diagnostics.py +25 -0
- modelable/emitters/fhir.py +694 -0
- modelable/emitters/fhir_validator.py +36 -0
- modelable/emitters/go.py +334 -0
- modelable/emitters/java.py +264 -0
- modelable/emitters/json_schema.py +458 -0
- modelable/emitters/markdown.py +252 -0
- modelable/emitters/odcs.py +355 -0
- modelable/emitters/openlineage.py +315 -0
- modelable/emitters/openmetadata.py +258 -0
- modelable/emitters/python.py +282 -0
- modelable/emitters/rust.py +643 -0
- modelable/emitters/shapes.py +261 -0
- modelable/emitters/sql.py +266 -0
- modelable/emitters/targets.py +141 -0
- modelable/emitters/typescript.py +352 -0
- modelable/expressions/__init__.py +0 -0
- modelable/expressions/cel.py +547 -0
- modelable/governance/__init__.py +3 -0
- modelable/governance/checker.py +271 -0
- modelable/governance/por.py +46 -0
- modelable/grammar/__init__.py +1 -0
- modelable/grammar/modelable.lark +257 -0
- modelable/graph/__init__.py +5 -0
- modelable/graph/export.py +442 -0
- modelable/llm/__init__.py +43 -0
- modelable/llm/chat.py +255 -0
- modelable/llm/config.py +87 -0
- modelable/llm/context.py +194 -0
- modelable/llm/engine.py +976 -0
- modelable/llm/importers.py +1077 -0
- modelable/llm/provenance.py +84 -0
- modelable/llm/providers.py +182 -0
- modelable/llm/qa.py +126 -0
- modelable/llm/recommendations.py +33 -0
- modelable/llm/redaction.py +19 -0
- modelable/llm/render.py +279 -0
- modelable/llm/update_plan.py +101 -0
- modelable/llm/validation_help.py +10 -0
- modelable/lsp/__init__.py +3 -0
- modelable/lsp/__main__.py +4 -0
- modelable/lsp/code_actions.py +210 -0
- modelable/lsp/completion.py +480 -0
- modelable/lsp/definition.py +343 -0
- modelable/lsp/diagnostics.py +31 -0
- modelable/lsp/document_symbols.py +197 -0
- modelable/lsp/federation.py +261 -0
- modelable/lsp/folding.py +33 -0
- modelable/lsp/formatting.py +64 -0
- modelable/lsp/highlight.py +30 -0
- modelable/lsp/hover.py +370 -0
- modelable/lsp/inlay_hints.py +158 -0
- modelable/lsp/references.py +511 -0
- modelable/lsp/rename.py +564 -0
- modelable/lsp/semantic_tokens.py +412 -0
- modelable/lsp/server.py +370 -0
- modelable/lsp/workspace.py +83 -0
- modelable/lsp/workspace_symbols.py +104 -0
- modelable/parser/__init__.py +94 -0
- modelable/parser/ir.py +451 -0
- modelable/parser/parse.py +47 -0
- modelable/parser/transformer.py +798 -0
- modelable/parser/wire.py +68 -0
- modelable/planner/__init__.py +0 -0
- modelable/planner/lineage.py +91 -0
- modelable/planner/planner.py +134 -0
- modelable/planner/plans.py +122 -0
- modelable/py.typed +0 -0
- modelable/registry/__init__.py +9 -0
- modelable/registry/apicurio.py +166 -0
- modelable/registry/base.py +18 -0
- modelable/registry/factory.py +18 -0
- modelable/registry/index.py +419 -0
- modelable/registry/local.py +26 -0
- modelable/registry/oci.py +22 -0
- modelable/registry/resolver.py +213 -0
- modelable/registry/schema.sql +119 -0
- modelable/registry/signature.py +26 -0
- modelable/release.py +125 -0
- modelable/runtime/__init__.py +5 -0
- modelable/runtime/adapter/__init__.py +17 -0
- modelable/runtime/adapter/base.py +18 -0
- modelable/runtime/adapter/postgres.py +82 -0
- modelable/specs/__init__.py +23 -0
- modelable/specs/tracking.py +220 -0
- modelable/validation/__init__.py +3 -0
- modelable/validation/semantic.py +659 -0
- modelable-1.0.0.dist-info/METADATA +61 -0
- modelable-1.0.0.dist-info/RECORD +122 -0
- modelable-1.0.0.dist-info/WHEEL +4 -0
- modelable-1.0.0.dist-info/entry_points.txt +2 -0
- modelable-1.0.0.dist-info/licenses/LICENSE +201 -0
modelable/parser/wire.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from modelable.parser.ir import Annotation, AnnWire, WireTargetHint
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def wire_targets_from_annotations(annotations: list[Annotation]) -> dict[str, WireTargetHint]:
|
|
7
|
+
targets: dict[str, WireTargetHint] = {}
|
|
8
|
+
for annotation in annotations:
|
|
9
|
+
if isinstance(annotation, AnnWire):
|
|
10
|
+
for target, hint in annotation.targets.items():
|
|
11
|
+
merged = targets.get(target, WireTargetHint())
|
|
12
|
+
if hint.encoding is not None:
|
|
13
|
+
if merged.encoding is not None and merged.encoding != hint.encoding:
|
|
14
|
+
raise ValueError(
|
|
15
|
+
f"conflicting wire encodings for target '{target}': "
|
|
16
|
+
f"{merged.encoding!r} vs {hint.encoding!r}"
|
|
17
|
+
)
|
|
18
|
+
merged.encoding = hint.encoding
|
|
19
|
+
if hint.type is not None:
|
|
20
|
+
if merged.type is not None and merged.type != hint.type:
|
|
21
|
+
raise ValueError(
|
|
22
|
+
f"conflicting wire types for target '{target}': {merged.type!r} vs {hint.type!r}"
|
|
23
|
+
)
|
|
24
|
+
merged.type = hint.type
|
|
25
|
+
if hint.case is not None:
|
|
26
|
+
if merged.case is not None and merged.case != hint.case:
|
|
27
|
+
raise ValueError(
|
|
28
|
+
f"conflicting wire cases for target '{target}': {merged.case!r} vs {hint.case!r}"
|
|
29
|
+
)
|
|
30
|
+
merged.case = hint.case
|
|
31
|
+
if hint.overrides:
|
|
32
|
+
overlap = sorted(set(merged.overrides) & set(hint.overrides))
|
|
33
|
+
for key in overlap:
|
|
34
|
+
if merged.overrides[key] != hint.overrides[key]:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"conflicting wire override for target '{target}' member '{key}': "
|
|
37
|
+
f"{merged.overrides[key]!r} vs {hint.overrides[key]!r}"
|
|
38
|
+
)
|
|
39
|
+
merged.overrides.update(hint.overrides)
|
|
40
|
+
if hint.field_case is not None:
|
|
41
|
+
if merged.field_case is not None and merged.field_case != hint.field_case:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"conflicting wire field cases for target '{target}': "
|
|
44
|
+
f"{merged.field_case!r} vs {hint.field_case!r}"
|
|
45
|
+
)
|
|
46
|
+
merged.field_case = hint.field_case
|
|
47
|
+
targets[target] = merged
|
|
48
|
+
return targets
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def render_wire_annotation(annotation: AnnWire) -> str:
|
|
52
|
+
parts: list[str] = []
|
|
53
|
+
for target in sorted(annotation.targets):
|
|
54
|
+
hint = annotation.targets[target]
|
|
55
|
+
if hint.encoding is not None:
|
|
56
|
+
parts.append(f'{target}: "{hint.encoding}"')
|
|
57
|
+
if hint.type is not None:
|
|
58
|
+
parts.append(f'{target}.type: "{hint.type}"')
|
|
59
|
+
if hint.case is not None:
|
|
60
|
+
parts.append(f'{target}.case: "{hint.case}"')
|
|
61
|
+
if hint.overrides:
|
|
62
|
+
overrides = ", ".join(f'{key}: "{value}"' for key, value in sorted(hint.overrides.items()))
|
|
63
|
+
parts.append(f"{target}.overrides: {{ {overrides} }}")
|
|
64
|
+
if hint.field_case is not None:
|
|
65
|
+
parts.append(f'{target}.fieldCase: "{hint.field_case}"')
|
|
66
|
+
if not parts:
|
|
67
|
+
raise ValueError("AnnWire must contain at least one wire option")
|
|
68
|
+
return f"@wire({', '.join(parts)})"
|
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Projection-level lineage: maps each output field to its source field(s)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from modelable.expressions.cel import extract_field_refs, parse_cel
|
|
8
|
+
from modelable.parser.ir import ComputedMapping, DirectMapping, MdlFile, ProjectionVersion
|
|
9
|
+
from modelable.registry.resolver import resolved_version_spec
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class FieldLineage:
|
|
14
|
+
field_name: str
|
|
15
|
+
kind: str # "direct" or "computed"
|
|
16
|
+
lineage: list[str] = field(default_factory=list)
|
|
17
|
+
expression: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ProjectionLineage:
|
|
22
|
+
domain: str
|
|
23
|
+
projection: str
|
|
24
|
+
version: int
|
|
25
|
+
fields: list[FieldLineage] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_projection_lineage(
|
|
29
|
+
domain_name: str,
|
|
30
|
+
projection_name: str,
|
|
31
|
+
pv: ProjectionVersion,
|
|
32
|
+
mdl: MdlFile,
|
|
33
|
+
) -> ProjectionLineage:
|
|
34
|
+
"""Build field-level lineage for a single projection version."""
|
|
35
|
+
alias_map = _build_alias_map(pv, mdl)
|
|
36
|
+
fields: list[FieldLineage] = []
|
|
37
|
+
|
|
38
|
+
for proj_field in pv.fields:
|
|
39
|
+
mapping = proj_field.mapping
|
|
40
|
+
|
|
41
|
+
if isinstance(mapping, DirectMapping):
|
|
42
|
+
ref = alias_map.get(mapping.source_alias, mapping.source_alias)
|
|
43
|
+
lineage_refs = [f"{ref}.{mapping.source_field}"]
|
|
44
|
+
fields.append(
|
|
45
|
+
FieldLineage(
|
|
46
|
+
field_name=proj_field.name,
|
|
47
|
+
kind="direct",
|
|
48
|
+
lineage=lineage_refs,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
elif isinstance(mapping, ComputedMapping):
|
|
53
|
+
expr_ast, _ = parse_cel(mapping.expression)
|
|
54
|
+
cel_refs = extract_field_refs(expr_ast) if expr_ast is not None else []
|
|
55
|
+
lineage_refs = []
|
|
56
|
+
for alias, field_name in cel_refs:
|
|
57
|
+
model_ref = alias_map.get(alias, alias)
|
|
58
|
+
lineage_refs.append(f"{model_ref}.{field_name}")
|
|
59
|
+
fields.append(
|
|
60
|
+
FieldLineage(
|
|
61
|
+
field_name=proj_field.name,
|
|
62
|
+
kind="computed",
|
|
63
|
+
lineage=lineage_refs,
|
|
64
|
+
expression=mapping.expression,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return ProjectionLineage(
|
|
69
|
+
domain=domain_name,
|
|
70
|
+
projection=projection_name,
|
|
71
|
+
version=pv.version,
|
|
72
|
+
fields=fields,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _build_alias_map(pv: ProjectionVersion, mdl: MdlFile) -> dict[str, str]:
|
|
77
|
+
"""Return alias -> 'domain.Model@resolved_version' mapping."""
|
|
78
|
+
alias_map: dict[str, str] = {}
|
|
79
|
+
|
|
80
|
+
all_sources = [(pv.source.model, pv.source.version, pv.source.alias)]
|
|
81
|
+
for join in pv.joins:
|
|
82
|
+
all_sources.append((join.model, join.version, join.alias))
|
|
83
|
+
|
|
84
|
+
for model_ref, version_spec, alias in all_sources:
|
|
85
|
+
try:
|
|
86
|
+
resolved = resolved_version_spec(mdl, model_ref, version_spec)
|
|
87
|
+
alias_map[alias] = f"{model_ref}@{resolved.version}"
|
|
88
|
+
except LookupError:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
return alias_map
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from modelable.parser.ir import (
|
|
4
|
+
AnnClassification,
|
|
5
|
+
Annotation,
|
|
6
|
+
AnnPii,
|
|
7
|
+
AnnServer,
|
|
8
|
+
AutoProjectionTarget,
|
|
9
|
+
DirectMapping,
|
|
10
|
+
DomainDef,
|
|
11
|
+
FieldDef,
|
|
12
|
+
MdlFile,
|
|
13
|
+
ModelVersion,
|
|
14
|
+
ProjectionField,
|
|
15
|
+
ProjectionVersion,
|
|
16
|
+
SourceRef,
|
|
17
|
+
VersionExact,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def expand_auto_projections(mdl: MdlFile) -> list[str]:
|
|
22
|
+
"""Expand auto projection declarations into explicit projection versions.
|
|
23
|
+
|
|
24
|
+
Mutates ``mdl.domains`` in place, adding generated projections to each
|
|
25
|
+
domain's ``projections`` dict. Returns a list of error strings.
|
|
26
|
+
"""
|
|
27
|
+
errors: list[str] = []
|
|
28
|
+
for domain in mdl.domains:
|
|
29
|
+
errors.extend(_expand_domain_auto_projections(domain))
|
|
30
|
+
return errors
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _expand_domain_auto_projections(domain: DomainDef) -> list[str]:
|
|
34
|
+
errors: list[str] = []
|
|
35
|
+
for decl in domain.auto_projections:
|
|
36
|
+
model_versions = domain.models.get(decl.model)
|
|
37
|
+
if model_versions is None:
|
|
38
|
+
errors.append(f"{domain.name}: auto projections references unknown model '{decl.model}'")
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
model_version = next((mv for mv in model_versions if mv.version == decl.version), None)
|
|
42
|
+
if model_version is None:
|
|
43
|
+
errors.append(
|
|
44
|
+
f"{domain.name}: auto projections references {decl.model}@{decl.version} which does not exist"
|
|
45
|
+
)
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
for target in decl.targets:
|
|
49
|
+
projection_name = _generated_projection_name(decl.model, target.kind)
|
|
50
|
+
existing = domain.projections.get(projection_name)
|
|
51
|
+
if existing is not None:
|
|
52
|
+
# Skip if an explicit projection with the same name already exists.
|
|
53
|
+
# The workspace validator already checks for conflicts; this is
|
|
54
|
+
# just a safety guard.
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
fields = _build_projection_fields(target, model_version, decl.model)
|
|
58
|
+
projection = ProjectionVersion(
|
|
59
|
+
version=decl.version,
|
|
60
|
+
source=SourceRef(
|
|
61
|
+
model=f"{domain.name}.{decl.model}",
|
|
62
|
+
version=VersionExact(version=decl.version),
|
|
63
|
+
alias=_default_alias(decl.model),
|
|
64
|
+
),
|
|
65
|
+
fields=fields,
|
|
66
|
+
auto_generated=True,
|
|
67
|
+
)
|
|
68
|
+
domain.projections.setdefault(projection_name, []).append(projection)
|
|
69
|
+
|
|
70
|
+
return errors
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _generated_projection_name(model_name: str, kind: str) -> str:
|
|
74
|
+
suffixes = {
|
|
75
|
+
"db": "Db",
|
|
76
|
+
"request": "Request",
|
|
77
|
+
"reply": "Reply",
|
|
78
|
+
"event": "Event",
|
|
79
|
+
}
|
|
80
|
+
return f"{model_name}{suffixes[kind]}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _default_alias(model_name: str) -> str:
|
|
84
|
+
return model_name[0].lower() + model_name[1:]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _build_projection_fields(
|
|
88
|
+
target: AutoProjectionTarget,
|
|
89
|
+
model_version: ModelVersion,
|
|
90
|
+
model_name: str,
|
|
91
|
+
) -> list[ProjectionField]:
|
|
92
|
+
alias = _default_alias(model_name)
|
|
93
|
+
included = []
|
|
94
|
+
for field in model_version.fields:
|
|
95
|
+
if _is_excluded(field, target):
|
|
96
|
+
continue
|
|
97
|
+
included.append(
|
|
98
|
+
ProjectionField(
|
|
99
|
+
name=field.name,
|
|
100
|
+
mapping=DirectMapping(source_alias=alias, source_field=field.name),
|
|
101
|
+
annotations=list(field.annotations),
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
return included
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_excluded(field: FieldDef, target: AutoProjectionTarget) -> bool:
|
|
108
|
+
# Explicit field name exclusions
|
|
109
|
+
if field.name in target.excluded_fields:
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
# Implicit request exclusion: @server fields are excluded from request models
|
|
113
|
+
if target.kind == "request" and _has_annotation(field, AnnServer):
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
# Check excluded annotations
|
|
117
|
+
return any(_annotation_matches(field, ann) for ann in target.excluded_annotations)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _has_annotation(field: FieldDef, annotation_type: type) -> bool:
|
|
121
|
+
return any(isinstance(a, annotation_type) for a in field.annotations)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _annotation_matches(field: FieldDef, excluded: Annotation) -> bool:
|
|
125
|
+
for ann in field.annotations:
|
|
126
|
+
if type(ann) is not type(excluded):
|
|
127
|
+
continue
|
|
128
|
+
if isinstance(ann, AnnPii) and isinstance(excluded, AnnPii):
|
|
129
|
+
return True
|
|
130
|
+
if isinstance(ann, AnnServer) and isinstance(excluded, AnnServer):
|
|
131
|
+
return True
|
|
132
|
+
if isinstance(ann, AnnClassification) and isinstance(excluded, AnnClassification):
|
|
133
|
+
return True
|
|
134
|
+
return False
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Build and write projection plan documents to .modelable/plans/."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from modelable.compiler.workspace import Workspace
|
|
9
|
+
from modelable.governance.checker import build_projection_governance_findings
|
|
10
|
+
from modelable.parser.ir import ComputedMapping, DirectMapping, MdlFile, ProjectionVersion
|
|
11
|
+
from modelable.planner.lineage import ProjectionLineage, build_projection_lineage
|
|
12
|
+
from modelable.registry.resolver import resolve_model_ref
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_plan(
|
|
16
|
+
domain_name: str,
|
|
17
|
+
projection_name: str,
|
|
18
|
+
pv: ProjectionVersion,
|
|
19
|
+
lineage: ProjectionLineage,
|
|
20
|
+
mdl: MdlFile,
|
|
21
|
+
) -> dict:
|
|
22
|
+
"""Return the plan document dict for a single projection version."""
|
|
23
|
+
source_block = _resolve_source_block(pv.source.model, pv.source.version, pv.source.alias, mdl)
|
|
24
|
+
|
|
25
|
+
joins_block = [_resolve_source_block(join.model, join.version, join.alias, mdl, on=join.on) for join in pv.joins]
|
|
26
|
+
revalidation_reasons = _collect_revalidation_reasons(source_block, joins_block)
|
|
27
|
+
governance_findings = [
|
|
28
|
+
finding.as_dict() for finding in build_projection_governance_findings(domain_name, projection_name, pv, mdl)
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
lineage_by_field = {fl.field_name: fl for fl in lineage.fields}
|
|
32
|
+
|
|
33
|
+
fields_block = []
|
|
34
|
+
for proj_field in pv.fields:
|
|
35
|
+
mapping = proj_field.mapping
|
|
36
|
+
entry: dict = {"name": proj_field.name}
|
|
37
|
+
if isinstance(mapping, DirectMapping):
|
|
38
|
+
entry["kind"] = "direct"
|
|
39
|
+
entry["source_alias"] = mapping.source_alias
|
|
40
|
+
entry["source_field"] = mapping.source_field
|
|
41
|
+
elif isinstance(mapping, ComputedMapping):
|
|
42
|
+
entry["kind"] = "computed"
|
|
43
|
+
entry["expression"] = mapping.expression
|
|
44
|
+
fl = lineage_by_field.get(proj_field.name)
|
|
45
|
+
entry["lineage"] = fl.lineage if fl else []
|
|
46
|
+
fields_block.append(entry)
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
"$schema": "modelable-plan/1.0",
|
|
50
|
+
"domain": domain_name,
|
|
51
|
+
"projection": projection_name,
|
|
52
|
+
"version": pv.version,
|
|
53
|
+
"auto_generated": pv.auto_generated,
|
|
54
|
+
"requires_revalidation": bool(revalidation_reasons),
|
|
55
|
+
"revalidation_reasons": revalidation_reasons,
|
|
56
|
+
"governance_findings": governance_findings,
|
|
57
|
+
"source": source_block,
|
|
58
|
+
"joins": joins_block,
|
|
59
|
+
"group_by": pv.group_by,
|
|
60
|
+
"fields": fields_block,
|
|
61
|
+
"planner_metadata": {
|
|
62
|
+
"modelable_schema": "1.0",
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def write_plans(workspace: Workspace, plans_dir: Path) -> list[Path]:
|
|
68
|
+
"""Write a plan JSON file for every projection version in the workspace."""
|
|
69
|
+
plans_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
written: list[Path] = []
|
|
71
|
+
|
|
72
|
+
for domain in workspace.mdl.domains:
|
|
73
|
+
for projection_name, versions in domain.projections.items():
|
|
74
|
+
for pv in versions:
|
|
75
|
+
lineage = build_projection_lineage(domain.name, projection_name, pv, workspace.mdl)
|
|
76
|
+
plan = build_plan(domain.name, projection_name, pv, lineage, workspace.mdl)
|
|
77
|
+
filename = f"{domain.name}.{projection_name}.v{pv.version}.plan.json"
|
|
78
|
+
out_path = plans_dir / filename
|
|
79
|
+
out_path.write_text(
|
|
80
|
+
json.dumps(plan, indent=2, ensure_ascii=False) + "\n",
|
|
81
|
+
encoding="utf-8",
|
|
82
|
+
)
|
|
83
|
+
written.append(out_path)
|
|
84
|
+
|
|
85
|
+
return written
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _resolve_source_block(
|
|
89
|
+
model_ref: str,
|
|
90
|
+
version_spec,
|
|
91
|
+
alias: str,
|
|
92
|
+
mdl: MdlFile,
|
|
93
|
+
on: str | None = None,
|
|
94
|
+
) -> dict:
|
|
95
|
+
try:
|
|
96
|
+
resolved = resolve_model_ref(mdl, model_ref, version_spec)
|
|
97
|
+
resolved_version = resolved.version.version
|
|
98
|
+
change_kind = resolved.version.change_kind.value
|
|
99
|
+
except LookupError:
|
|
100
|
+
resolved_version = None
|
|
101
|
+
change_kind = None
|
|
102
|
+
|
|
103
|
+
block: dict = {
|
|
104
|
+
"model": model_ref,
|
|
105
|
+
"resolved_version": resolved_version,
|
|
106
|
+
"alias": alias,
|
|
107
|
+
"change_kind": change_kind,
|
|
108
|
+
}
|
|
109
|
+
if on is not None:
|
|
110
|
+
block["on"] = on
|
|
111
|
+
return block
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _collect_revalidation_reasons(source_block: dict, joins_block: list[dict]) -> list[str]:
|
|
115
|
+
reasons: list[str] = []
|
|
116
|
+
|
|
117
|
+
for block in [source_block, *joins_block]:
|
|
118
|
+
if block.get("change_kind") == "breaking" and block.get("resolved_version") is not None:
|
|
119
|
+
relation = "source" if "on" not in block else f"join {block.get('alias')}"
|
|
120
|
+
reasons.append(f"{relation} {block['model']}@{block['resolved_version']} is marked breaking")
|
|
121
|
+
|
|
122
|
+
return reasons
|
modelable/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.error import HTTPError, URLError
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
|
|
11
|
+
Transport = Callable[[str, str, dict[str, str], bytes | None, float], tuple[int, str]]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApicurioRegistryError(RuntimeError):
|
|
15
|
+
"""Raised when Apicurio Registry rejects or cannot complete a request."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ApicurioArtifact:
|
|
20
|
+
artifact_id: str
|
|
21
|
+
version: str
|
|
22
|
+
content: dict[str, object]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ApicurioRegistryClient:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
url: str,
|
|
29
|
+
token: str | None = None,
|
|
30
|
+
transport: Transport | None = None,
|
|
31
|
+
timeout: float = 30.0,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.base_url = _registry_api_url(url)
|
|
34
|
+
self.token = token
|
|
35
|
+
self.transport = transport or _urllib_transport
|
|
36
|
+
self.timeout = timeout
|
|
37
|
+
|
|
38
|
+
def publish_json_schema(self, artifact: ApicurioArtifact, group: str = "default") -> None:
|
|
39
|
+
artifact_content = json.dumps(artifact.content, indent=2, ensure_ascii=False)
|
|
40
|
+
body = json.dumps(
|
|
41
|
+
{
|
|
42
|
+
"artifactId": artifact.artifact_id,
|
|
43
|
+
"artifactType": "JSON",
|
|
44
|
+
"firstVersion": {
|
|
45
|
+
"version": artifact.version,
|
|
46
|
+
"content": {
|
|
47
|
+
"content": artifact_content,
|
|
48
|
+
"contentType": "application/json",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
indent=2,
|
|
53
|
+
ensure_ascii=False,
|
|
54
|
+
).encode("utf-8")
|
|
55
|
+
headers = self._headers(
|
|
56
|
+
{
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
"Accept": "application/json",
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
status, response_text = self.transport(
|
|
62
|
+
"POST",
|
|
63
|
+
f"{self.base_url}/groups/{_url_part(group)}/artifacts",
|
|
64
|
+
headers,
|
|
65
|
+
body,
|
|
66
|
+
self.timeout,
|
|
67
|
+
)
|
|
68
|
+
_raise_for_status(status, response_text, f"publish {artifact.artifact_id}")
|
|
69
|
+
|
|
70
|
+
def pull_json_schema(self, ref: str, group: str = "default", out_dir: Path = Path(".")) -> Path:
|
|
71
|
+
artifact_id, version = artifact_id_from_ref(ref)
|
|
72
|
+
headers = self._headers({"Accept": "application/json"})
|
|
73
|
+
status, response_text = self.transport(
|
|
74
|
+
"GET",
|
|
75
|
+
(
|
|
76
|
+
f"{self.base_url}/groups/{_url_part(group)}/artifacts/"
|
|
77
|
+
f"{_url_part(artifact_id)}/versions/{_url_part(version)}/content"
|
|
78
|
+
),
|
|
79
|
+
headers,
|
|
80
|
+
None,
|
|
81
|
+
self.timeout,
|
|
82
|
+
)
|
|
83
|
+
_raise_for_status(status, response_text, f"pull {artifact_id}")
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
content = json.loads(response_text)
|
|
87
|
+
except json.JSONDecodeError as exc:
|
|
88
|
+
raise ApicurioRegistryError(f"Apicurio returned invalid JSON for {artifact_id}: {exc}") from exc
|
|
89
|
+
|
|
90
|
+
if not isinstance(content, dict):
|
|
91
|
+
raise ApicurioRegistryError(f"Apicurio returned non-object JSON for {artifact_id}")
|
|
92
|
+
|
|
93
|
+
domain, name = _split_domain_name(artifact_id)
|
|
94
|
+
path = out_dir / domain / f"{name}.v{version}.json"
|
|
95
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
path.write_text(json.dumps(content, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
97
|
+
return path
|
|
98
|
+
|
|
99
|
+
def _headers(self, headers: dict[str, str]) -> dict[str, str]:
|
|
100
|
+
if self.token:
|
|
101
|
+
return {**headers, "Authorization": f"Bearer {self.token}"}
|
|
102
|
+
return headers
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def artifact_id_from_ref(ref: str) -> tuple[str, str]:
|
|
106
|
+
if "@" not in ref:
|
|
107
|
+
raise ApicurioRegistryError(f"expected ref in form domain.Name@version, got {ref!r}")
|
|
108
|
+
model_ref, version = ref.rsplit("@", 1)
|
|
109
|
+
if not model_ref or not version.isdigit():
|
|
110
|
+
raise ApicurioRegistryError(f"expected ref in form domain.Name@version, got {ref!r}")
|
|
111
|
+
domain, name = _split_model_ref(model_ref)
|
|
112
|
+
return f"{domain}.{name}.v{version}", version
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _registry_api_url(url: str) -> str:
|
|
116
|
+
normalized = url.rstrip("/")
|
|
117
|
+
suffix = "/apis/registry/v3"
|
|
118
|
+
if normalized.endswith(suffix):
|
|
119
|
+
return normalized
|
|
120
|
+
return f"{normalized}{suffix}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _split_model_ref(ref: str) -> tuple[str, str]:
|
|
124
|
+
try:
|
|
125
|
+
domain, name = ref.rsplit(".", 1)
|
|
126
|
+
except ValueError as exc:
|
|
127
|
+
raise ApicurioRegistryError(f"expected ref in form domain.Name@version, got {ref!r}") from exc
|
|
128
|
+
if not domain or not name:
|
|
129
|
+
raise ApicurioRegistryError(f"expected ref in form domain.Name@version, got {ref!r}")
|
|
130
|
+
return domain, name
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _split_domain_name(artifact_id: str) -> tuple[str, str]:
|
|
134
|
+
stem = artifact_id.rsplit(".v", 1)[0]
|
|
135
|
+
return _split_model_ref(stem)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _url_part(value: str) -> str:
|
|
139
|
+
return quote(value, safe="")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _raise_for_status(status: int, response_text: str, action: str) -> None:
|
|
143
|
+
if 200 <= status < 300:
|
|
144
|
+
return
|
|
145
|
+
detail = response_text.strip()
|
|
146
|
+
suffix = f": {detail}" if detail else ""
|
|
147
|
+
raise ApicurioRegistryError(f"Apicurio {action} failed with HTTP {status}{suffix}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _urllib_transport(
|
|
151
|
+
method: str,
|
|
152
|
+
url: str,
|
|
153
|
+
headers: dict[str, str],
|
|
154
|
+
body: bytes | None = None,
|
|
155
|
+
timeout: float = 30.0,
|
|
156
|
+
) -> tuple[int, str]:
|
|
157
|
+
request = Request(url, data=body, headers=headers, method=method)
|
|
158
|
+
try:
|
|
159
|
+
with urlopen(request, timeout=timeout) as response:
|
|
160
|
+
payload = response.read().decode("utf-8")
|
|
161
|
+
return response.status, payload
|
|
162
|
+
except HTTPError as exc:
|
|
163
|
+
payload = exc.read().decode("utf-8", errors="replace")
|
|
164
|
+
return exc.code, payload
|
|
165
|
+
except URLError as exc:
|
|
166
|
+
raise ApicurioRegistryError(f"Apicurio request failed: {exc.reason}") from exc
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Registry(ABC):
|
|
8
|
+
"""Abstract base class for a Modelable registry."""
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def push(self, registry_path: Path) -> None:
|
|
12
|
+
"""Push a registry index to the registry."""
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def pull(self, dest_path: Path) -> None:
|
|
17
|
+
"""Pull a registry index from the registry."""
|
|
18
|
+
...
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from modelable.registry.base import Registry
|
|
6
|
+
from modelable.registry.local import LocalRegistry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_registry(registry_path: Path) -> Registry:
|
|
10
|
+
"""Return a Registry instance based on the registry path."""
|
|
11
|
+
# If path is a file, return LocalRegistry
|
|
12
|
+
# If path looks like an OCI URL (e.g., oci://), return OCIRegistry
|
|
13
|
+
if registry_path.as_posix().startswith("oci://"):
|
|
14
|
+
from modelable.registry.oci import OCIRegistry
|
|
15
|
+
|
|
16
|
+
return OCIRegistry(registry_path.as_posix())
|
|
17
|
+
|
|
18
|
+
return LocalRegistry(registry_path)
|