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.

Files changed (122) hide show
  1. modelable/__init__.py +1 -0
  2. modelable/__main__.py +3 -0
  3. modelable/_pydantic_py314_compat.py +31 -0
  4. modelable/cli.py +41 -0
  5. modelable/commands/__init__.py +1 -0
  6. modelable/commands/apicurio.py +84 -0
  7. modelable/commands/codegen.py +241 -0
  8. modelable/commands/common.py +43 -0
  9. modelable/commands/compile.py +237 -0
  10. modelable/commands/create.py +164 -0
  11. modelable/commands/diff.py +82 -0
  12. modelable/commands/graph.py +53 -0
  13. modelable/commands/llm.py +564 -0
  14. modelable/commands/lsp.py +15 -0
  15. modelable/commands/runtime.py +37 -0
  16. modelable/commands/scenario.py +104 -0
  17. modelable/commands/spec.py +197 -0
  18. modelable/commands/workspace.py +240 -0
  19. modelable/compat/__init__.py +11 -0
  20. modelable/compat/checker.py +179 -0
  21. modelable/compat/diff.py +169 -0
  22. modelable/compiler/__init__.py +3 -0
  23. modelable/compiler/compiler.py +19 -0
  24. modelable/compiler/workspace.py +346 -0
  25. modelable/diagnostics/__init__.py +3 -0
  26. modelable/diagnostics/model.py +27 -0
  27. modelable/emitters/__init__.py +0 -0
  28. modelable/emitters/base.py +22 -0
  29. modelable/emitters/csharp.py +245 -0
  30. modelable/emitters/dbt_yaml.py +290 -0
  31. modelable/emitters/diagnostics.py +25 -0
  32. modelable/emitters/fhir.py +694 -0
  33. modelable/emitters/fhir_validator.py +36 -0
  34. modelable/emitters/go.py +334 -0
  35. modelable/emitters/java.py +264 -0
  36. modelable/emitters/json_schema.py +458 -0
  37. modelable/emitters/markdown.py +252 -0
  38. modelable/emitters/odcs.py +355 -0
  39. modelable/emitters/openlineage.py +315 -0
  40. modelable/emitters/openmetadata.py +258 -0
  41. modelable/emitters/python.py +282 -0
  42. modelable/emitters/rust.py +643 -0
  43. modelable/emitters/shapes.py +261 -0
  44. modelable/emitters/sql.py +266 -0
  45. modelable/emitters/targets.py +141 -0
  46. modelable/emitters/typescript.py +352 -0
  47. modelable/expressions/__init__.py +0 -0
  48. modelable/expressions/cel.py +547 -0
  49. modelable/governance/__init__.py +3 -0
  50. modelable/governance/checker.py +271 -0
  51. modelable/governance/por.py +46 -0
  52. modelable/grammar/__init__.py +1 -0
  53. modelable/grammar/modelable.lark +257 -0
  54. modelable/graph/__init__.py +5 -0
  55. modelable/graph/export.py +442 -0
  56. modelable/llm/__init__.py +43 -0
  57. modelable/llm/chat.py +255 -0
  58. modelable/llm/config.py +87 -0
  59. modelable/llm/context.py +194 -0
  60. modelable/llm/engine.py +976 -0
  61. modelable/llm/importers.py +1077 -0
  62. modelable/llm/provenance.py +84 -0
  63. modelable/llm/providers.py +182 -0
  64. modelable/llm/qa.py +126 -0
  65. modelable/llm/recommendations.py +33 -0
  66. modelable/llm/redaction.py +19 -0
  67. modelable/llm/render.py +279 -0
  68. modelable/llm/update_plan.py +101 -0
  69. modelable/llm/validation_help.py +10 -0
  70. modelable/lsp/__init__.py +3 -0
  71. modelable/lsp/__main__.py +4 -0
  72. modelable/lsp/code_actions.py +210 -0
  73. modelable/lsp/completion.py +480 -0
  74. modelable/lsp/definition.py +343 -0
  75. modelable/lsp/diagnostics.py +31 -0
  76. modelable/lsp/document_symbols.py +197 -0
  77. modelable/lsp/federation.py +261 -0
  78. modelable/lsp/folding.py +33 -0
  79. modelable/lsp/formatting.py +64 -0
  80. modelable/lsp/highlight.py +30 -0
  81. modelable/lsp/hover.py +370 -0
  82. modelable/lsp/inlay_hints.py +158 -0
  83. modelable/lsp/references.py +511 -0
  84. modelable/lsp/rename.py +564 -0
  85. modelable/lsp/semantic_tokens.py +412 -0
  86. modelable/lsp/server.py +370 -0
  87. modelable/lsp/workspace.py +83 -0
  88. modelable/lsp/workspace_symbols.py +104 -0
  89. modelable/parser/__init__.py +94 -0
  90. modelable/parser/ir.py +451 -0
  91. modelable/parser/parse.py +47 -0
  92. modelable/parser/transformer.py +798 -0
  93. modelable/parser/wire.py +68 -0
  94. modelable/planner/__init__.py +0 -0
  95. modelable/planner/lineage.py +91 -0
  96. modelable/planner/planner.py +134 -0
  97. modelable/planner/plans.py +122 -0
  98. modelable/py.typed +0 -0
  99. modelable/registry/__init__.py +9 -0
  100. modelable/registry/apicurio.py +166 -0
  101. modelable/registry/base.py +18 -0
  102. modelable/registry/factory.py +18 -0
  103. modelable/registry/index.py +419 -0
  104. modelable/registry/local.py +26 -0
  105. modelable/registry/oci.py +22 -0
  106. modelable/registry/resolver.py +213 -0
  107. modelable/registry/schema.sql +119 -0
  108. modelable/registry/signature.py +26 -0
  109. modelable/release.py +125 -0
  110. modelable/runtime/__init__.py +5 -0
  111. modelable/runtime/adapter/__init__.py +17 -0
  112. modelable/runtime/adapter/base.py +18 -0
  113. modelable/runtime/adapter/postgres.py +82 -0
  114. modelable/specs/__init__.py +23 -0
  115. modelable/specs/tracking.py +220 -0
  116. modelable/validation/__init__.py +3 -0
  117. modelable/validation/semantic.py +659 -0
  118. modelable-1.0.0.dist-info/METADATA +61 -0
  119. modelable-1.0.0.dist-info/RECORD +122 -0
  120. modelable-1.0.0.dist-info/WHEEL +4 -0
  121. modelable-1.0.0.dist-info/entry_points.txt +2 -0
  122. modelable-1.0.0.dist-info/licenses/LICENSE +201 -0
@@ -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,9 @@
1
+ __all__ = ["build_registry"]
2
+
3
+
4
+ def __getattr__(name: str):
5
+ if name == "build_registry":
6
+ from modelable.registry.index import build_registry
7
+
8
+ return build_registry
9
+ raise AttributeError(name)
@@ -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)