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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
create table domains (
|
|
2
|
+
name text primary key,
|
|
3
|
+
owner text,
|
|
4
|
+
description text
|
|
5
|
+
);
|
|
6
|
+
|
|
7
|
+
create table models (
|
|
8
|
+
domain_name text not null,
|
|
9
|
+
name text not null,
|
|
10
|
+
kind text not null,
|
|
11
|
+
primary key (domain_name, name)
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
create table model_versions (
|
|
15
|
+
domain_name text not null,
|
|
16
|
+
model_name text not null,
|
|
17
|
+
version integer not null,
|
|
18
|
+
change_kind text not null,
|
|
19
|
+
source_path text not null,
|
|
20
|
+
primary key (domain_name, model_name, version)
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
create table fields (
|
|
24
|
+
domain_name text not null,
|
|
25
|
+
model_name text not null,
|
|
26
|
+
model_version integer not null,
|
|
27
|
+
field_name text not null,
|
|
28
|
+
position integer not null,
|
|
29
|
+
type_json text not null,
|
|
30
|
+
optional integer not null,
|
|
31
|
+
is_key integer not null,
|
|
32
|
+
is_pii integer not null,
|
|
33
|
+
classification text,
|
|
34
|
+
primary key (domain_name, model_name, model_version, field_name)
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
create table projections (
|
|
38
|
+
domain_name text not null,
|
|
39
|
+
name text not null,
|
|
40
|
+
primary key (domain_name, name)
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
create table projection_versions (
|
|
44
|
+
domain_name text not null,
|
|
45
|
+
projection_name text not null,
|
|
46
|
+
version integer not null,
|
|
47
|
+
source_model text not null,
|
|
48
|
+
source_version_json text not null,
|
|
49
|
+
source_alias text not null,
|
|
50
|
+
primary key (domain_name, projection_name, version)
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
create table projection_sources (
|
|
54
|
+
domain_name text not null,
|
|
55
|
+
projection_name text not null,
|
|
56
|
+
projection_version integer not null,
|
|
57
|
+
source_kind text not null,
|
|
58
|
+
source_model text not null,
|
|
59
|
+
source_version_json text not null,
|
|
60
|
+
source_alias text not null,
|
|
61
|
+
join_on text
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
create table projection_fields (
|
|
65
|
+
domain_name text not null,
|
|
66
|
+
projection_name text not null,
|
|
67
|
+
projection_version integer not null,
|
|
68
|
+
field_name text not null,
|
|
69
|
+
position integer not null,
|
|
70
|
+
mapping_json text not null,
|
|
71
|
+
is_pii integer not null,
|
|
72
|
+
classification text,
|
|
73
|
+
primary key (domain_name, projection_name, projection_version, field_name)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
create table field_mappings (
|
|
77
|
+
domain_name text not null,
|
|
78
|
+
projection_name text not null,
|
|
79
|
+
projection_version integer not null,
|
|
80
|
+
target_field text not null,
|
|
81
|
+
mapping_kind text not null,
|
|
82
|
+
source_alias text,
|
|
83
|
+
source_field text,
|
|
84
|
+
expression text
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
create table lineage_edges (
|
|
88
|
+
source_ref text not null,
|
|
89
|
+
target_ref text not null,
|
|
90
|
+
edge_kind text not null
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
create table adapter_bindings (
|
|
94
|
+
name text primary key,
|
|
95
|
+
model_ref text not null,
|
|
96
|
+
adapter text not null,
|
|
97
|
+
table_name text
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
create table compatibility_reports (
|
|
101
|
+
domain_name text not null,
|
|
102
|
+
model_name text not null,
|
|
103
|
+
from_version integer not null,
|
|
104
|
+
to_version integer not null,
|
|
105
|
+
status text not null
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
create table access_policies (
|
|
109
|
+
subject_ref text not null,
|
|
110
|
+
action text not null,
|
|
111
|
+
grantee text not null
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
create table por_log (
|
|
115
|
+
model_ref text not null,
|
|
116
|
+
issuer text not null,
|
|
117
|
+
issued_at text not null,
|
|
118
|
+
signature text
|
|
119
|
+
);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
|
|
5
|
+
from modelable.parser.ir import ModelVersion, ProjectionVersion
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compute_version_signature(domain_name: str, model_name: str, version: ModelVersion | ProjectionVersion) -> str:
|
|
9
|
+
"""Return the canonical SHA-256 signature for a published model or projection version."""
|
|
10
|
+
from modelable.llm.render import render_model_version, render_projection_version
|
|
11
|
+
|
|
12
|
+
if isinstance(version, ModelVersion):
|
|
13
|
+
text = render_model_version(domain_name, model_name, _sorted_model_version(version))
|
|
14
|
+
elif isinstance(version, ProjectionVersion):
|
|
15
|
+
text = render_projection_version(domain_name, model_name, _sorted_projection_version(version))
|
|
16
|
+
else:
|
|
17
|
+
raise TypeError(f"unsupported version type: {type(version)!r}")
|
|
18
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _sorted_model_version(version: ModelVersion) -> ModelVersion:
|
|
22
|
+
return version.model_copy(update={"fields": sorted(version.fields, key=lambda field: field.name)})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _sorted_projection_version(version: ProjectionVersion) -> ProjectionVersion:
|
|
26
|
+
return version.model_copy(update={"fields": sorted(version.fields, key=lambda field: field.name)})
|
modelable/release.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_package_version(pyproject_path: Path) -> str:
|
|
12
|
+
with pyproject_path.open("rb") as fh:
|
|
13
|
+
data = tomllib.load(fh)
|
|
14
|
+
return cast(str, data["project"]["version"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _artifact_kind(path: Path) -> str | None:
|
|
18
|
+
if path.name.endswith(".whl"):
|
|
19
|
+
return "wheel"
|
|
20
|
+
if path.name.endswith(".tar.gz"):
|
|
21
|
+
return "sdist"
|
|
22
|
+
if path.name.endswith(".vsix"):
|
|
23
|
+
return "vsix"
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_release_manifest(
|
|
28
|
+
*,
|
|
29
|
+
dist_dir: Path,
|
|
30
|
+
commit_sha: str,
|
|
31
|
+
git_tag: str | None,
|
|
32
|
+
package_version: str,
|
|
33
|
+
python_version: str,
|
|
34
|
+
build_timestamp: str,
|
|
35
|
+
repository_url: str = "https://github.com/ktjn/modelable",
|
|
36
|
+
license_expression: str = "Apache-2.0",
|
|
37
|
+
workflow_run_url: str | None = None,
|
|
38
|
+
extension_version: str | None = None,
|
|
39
|
+
) -> dict[str, Any]:
|
|
40
|
+
tag_version = git_tag.removeprefix("v") if git_tag else None
|
|
41
|
+
if tag_version is not None and tag_version != package_version:
|
|
42
|
+
raise ValueError(f"package version {package_version} does not match release tag {git_tag}")
|
|
43
|
+
|
|
44
|
+
wheel: dict[str, str] | None = None
|
|
45
|
+
sdist: dict[str, str] | None = None
|
|
46
|
+
vsix: dict[str, str] | None = None
|
|
47
|
+
checksum_lines: list[str] = []
|
|
48
|
+
|
|
49
|
+
for artifact in sorted(dist_dir.iterdir()):
|
|
50
|
+
if not artifact.is_file() or artifact.name in {"SHA256SUMS", "release-manifest.json"}:
|
|
51
|
+
continue
|
|
52
|
+
digest = hashlib.sha256(artifact.read_bytes()).hexdigest()
|
|
53
|
+
checksum_lines.append(f"{digest} {artifact.name}")
|
|
54
|
+
kind = _artifact_kind(artifact)
|
|
55
|
+
if kind == "wheel":
|
|
56
|
+
wheel = {"filename": artifact.name, "sha256": digest}
|
|
57
|
+
elif kind == "sdist":
|
|
58
|
+
sdist = {"filename": artifact.name, "sha256": digest}
|
|
59
|
+
elif kind == "vsix":
|
|
60
|
+
vsix = {"filename": artifact.name, "sha256": digest}
|
|
61
|
+
|
|
62
|
+
if wheel is None:
|
|
63
|
+
raise ValueError(f"no wheel artifact found in {dist_dir}")
|
|
64
|
+
if sdist is None:
|
|
65
|
+
raise ValueError(f"no sdist artifact found in {dist_dir}")
|
|
66
|
+
if extension_version is not None and vsix is None:
|
|
67
|
+
raise ValueError(f"no VS Code extension artifact found in {dist_dir}")
|
|
68
|
+
|
|
69
|
+
manifest = {
|
|
70
|
+
"package_name": "modelable",
|
|
71
|
+
"package_version": package_version,
|
|
72
|
+
"git_sha": commit_sha,
|
|
73
|
+
"git_tag": git_tag,
|
|
74
|
+
"python_version": python_version,
|
|
75
|
+
"build_timestamp": build_timestamp,
|
|
76
|
+
"repository_url": repository_url,
|
|
77
|
+
"license": license_expression,
|
|
78
|
+
"workflow_run_url": workflow_run_url,
|
|
79
|
+
"extension_version": extension_version,
|
|
80
|
+
"artifacts": {
|
|
81
|
+
"wheel": wheel,
|
|
82
|
+
"sdist": sdist,
|
|
83
|
+
**({"vsix": vsix} if vsix is not None else {}),
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
(dist_dir / "SHA256SUMS").write_text("\n".join(checksum_lines) + "\n", encoding="utf-8")
|
|
88
|
+
(dist_dir / "release-manifest.json").write_text(
|
|
89
|
+
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
|
|
90
|
+
encoding="utf-8",
|
|
91
|
+
)
|
|
92
|
+
return manifest
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main(argv: list[str] | None = None) -> int:
|
|
96
|
+
parser = argparse.ArgumentParser()
|
|
97
|
+
parser.add_argument("--dist", required=True, type=Path)
|
|
98
|
+
parser.add_argument("--commit-sha", required=True)
|
|
99
|
+
parser.add_argument("--git-tag")
|
|
100
|
+
parser.add_argument("--package-version", required=True)
|
|
101
|
+
parser.add_argument("--python-version", required=True)
|
|
102
|
+
parser.add_argument("--build-timestamp", required=True)
|
|
103
|
+
parser.add_argument("--repository-url", default="https://github.com/ktjn/modelable")
|
|
104
|
+
parser.add_argument("--license-expression", default="Apache-2.0")
|
|
105
|
+
parser.add_argument("--workflow-run-url")
|
|
106
|
+
parser.add_argument("--extension-version")
|
|
107
|
+
args = parser.parse_args(argv)
|
|
108
|
+
|
|
109
|
+
build_release_manifest(
|
|
110
|
+
dist_dir=args.dist,
|
|
111
|
+
commit_sha=args.commit_sha,
|
|
112
|
+
git_tag=args.git_tag,
|
|
113
|
+
package_version=args.package_version,
|
|
114
|
+
python_version=args.python_version,
|
|
115
|
+
build_timestamp=args.build_timestamp,
|
|
116
|
+
repository_url=args.repository_url,
|
|
117
|
+
license_expression=args.license_expression,
|
|
118
|
+
workflow_run_url=args.workflow_run_url,
|
|
119
|
+
extension_version=args.extension_version,
|
|
120
|
+
)
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .base import RuntimeAdapter
|
|
4
|
+
from .postgres import PostgresAdapter
|
|
5
|
+
|
|
6
|
+
__all__ = ["RuntimeAdapter", "get_adapter"]
|
|
7
|
+
|
|
8
|
+
_ADAPTERS = {
|
|
9
|
+
"postgres": PostgresAdapter,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_adapter(adapter_type: str) -> RuntimeAdapter:
|
|
14
|
+
adapter_class = _ADAPTERS.get(adapter_type)
|
|
15
|
+
if not adapter_class:
|
|
16
|
+
raise ValueError(f"Unknown adapter type: {adapter_type}")
|
|
17
|
+
return adapter_class()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RuntimeAdapter(ABC):
|
|
8
|
+
"""Base interface for Modelable runtime adapters."""
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def bootstrap(self, config: dict[str, Any]) -> None:
|
|
12
|
+
"""Initialize the target environment."""
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def materialize(self, projection_plan: dict[str, Any], data: Any) -> None:
|
|
17
|
+
"""Stream or update data into the target materialization."""
|
|
18
|
+
...
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import psycopg
|
|
6
|
+
|
|
7
|
+
from modelable.parser.ir import DecimalType, EnumType, FieldType, PrimitiveType
|
|
8
|
+
|
|
9
|
+
from .base import RuntimeAdapter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def type_to_postgres(field_type: FieldType) -> str:
|
|
13
|
+
# ... (rest of implementation)
|
|
14
|
+
mapping = {
|
|
15
|
+
"string": "TEXT",
|
|
16
|
+
"int": "INTEGER",
|
|
17
|
+
"float": "DOUBLE PRECISION",
|
|
18
|
+
"bool": "BOOLEAN",
|
|
19
|
+
"date": "DATE",
|
|
20
|
+
"time": "TIME",
|
|
21
|
+
"timestamp": "TIMESTAMP",
|
|
22
|
+
"uuid": "UUID",
|
|
23
|
+
"duration": "INTERVAL",
|
|
24
|
+
"binary": "BYTEA",
|
|
25
|
+
}
|
|
26
|
+
if isinstance(field_type, PrimitiveType):
|
|
27
|
+
return mapping.get(field_type.kind, "TEXT")
|
|
28
|
+
if isinstance(field_type, DecimalType):
|
|
29
|
+
return f"NUMERIC({field_type.precision}, {field_type.scale})"
|
|
30
|
+
if isinstance(field_type, EnumType):
|
|
31
|
+
return "TEXT"
|
|
32
|
+
return "TEXT"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PostgresAdapter(RuntimeAdapter):
|
|
36
|
+
"""PostgreSQL runtime adapter."""
|
|
37
|
+
|
|
38
|
+
def bootstrap(self, config: dict[str, Any]) -> None:
|
|
39
|
+
"""Initialize the PostgreSQL environment."""
|
|
40
|
+
conn_str = config["connection_string"]
|
|
41
|
+
with psycopg.connect(conn_str) as conn, conn.cursor() as cur:
|
|
42
|
+
# Example: create a schema if configured
|
|
43
|
+
schema = config.get("schema", "public")
|
|
44
|
+
cur.execute(f"CREATE SCHEMA IF NOT EXISTS {schema}")
|
|
45
|
+
conn.commit()
|
|
46
|
+
|
|
47
|
+
def materialize(self, projection_plan: dict[str, Any], data: Any) -> None:
|
|
48
|
+
"""Stream or update data into the target materialization."""
|
|
49
|
+
# This implementation assumes data is a list of dictionaries where keys match projection field names
|
|
50
|
+
# and projection_plan contains 'table_name' and 'keys'.
|
|
51
|
+
table_name = projection_plan["table_name"]
|
|
52
|
+
keys = projection_plan["keys"]
|
|
53
|
+
conn_str = projection_plan["connection_string"]
|
|
54
|
+
|
|
55
|
+
with psycopg.connect(conn_str) as conn, conn.cursor() as cur:
|
|
56
|
+
for record in data:
|
|
57
|
+
columns = record.keys()
|
|
58
|
+
values = [record[col] for col in columns]
|
|
59
|
+
|
|
60
|
+
# Construct UPSERT
|
|
61
|
+
col_names = ", ".join(columns)
|
|
62
|
+
placeholders = ", ".join(["%s"] * len(columns))
|
|
63
|
+
update_stmt = ", ".join([f"{col} = EXCLUDED.{col}" for col in columns if col not in keys])
|
|
64
|
+
|
|
65
|
+
query = f"""
|
|
66
|
+
INSERT INTO {table_name} ({col_names})
|
|
67
|
+
VALUES ({placeholders})
|
|
68
|
+
ON CONFLICT ({", ".join(keys)})
|
|
69
|
+
DO UPDATE SET {update_stmt};
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
cur.execute(query, values)
|
|
73
|
+
conn.commit()
|
|
74
|
+
|
|
75
|
+
def generate_table_ddl(self, table_name: str, fields: list[Any]) -> str:
|
|
76
|
+
"""Generate DDL for a projection table."""
|
|
77
|
+
columns = []
|
|
78
|
+
for field in fields:
|
|
79
|
+
pg_type = type_to_postgres(field.type)
|
|
80
|
+
columns.append(f"{field.name} {pg_type}")
|
|
81
|
+
|
|
82
|
+
return f"CREATE TABLE IF NOT EXISTS {table_name} (\n {', '.join(columns)}\n);"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .tracking import (
|
|
4
|
+
SpecEntry,
|
|
5
|
+
SpecEvaluation,
|
|
6
|
+
SpecSourceError,
|
|
7
|
+
add_spec,
|
|
8
|
+
evaluate_spec,
|
|
9
|
+
load_spec_config,
|
|
10
|
+
select_specs,
|
|
11
|
+
spec_config_path,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"SpecEntry",
|
|
16
|
+
"SpecEvaluation",
|
|
17
|
+
"SpecSourceError",
|
|
18
|
+
"add_spec",
|
|
19
|
+
"evaluate_spec",
|
|
20
|
+
"load_spec_config",
|
|
21
|
+
"select_specs",
|
|
22
|
+
"spec_config_path",
|
|
23
|
+
]
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from modelable.llm.engine import AttachResult, attach_external_version
|
|
12
|
+
|
|
13
|
+
SUPPORTED_SPEC_KINDS = {"dbt", "fhir", "odcs"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SpecSourceError(RuntimeError):
|
|
17
|
+
"""Raised when a spec source cannot be resolved."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class SpecEntry:
|
|
22
|
+
id: str
|
|
23
|
+
kind: str
|
|
24
|
+
source: str
|
|
25
|
+
ref: str
|
|
26
|
+
source_name: str | None = None
|
|
27
|
+
update_policy: str = "preview"
|
|
28
|
+
|
|
29
|
+
def as_config_dict(self) -> dict[str, str]:
|
|
30
|
+
data = {
|
|
31
|
+
"id": self.id,
|
|
32
|
+
"kind": self.kind,
|
|
33
|
+
"source": self.source,
|
|
34
|
+
"ref": self.ref,
|
|
35
|
+
}
|
|
36
|
+
if self.source_name is not None:
|
|
37
|
+
data["source_name"] = self.source_name
|
|
38
|
+
data["update_policy"] = self.update_policy
|
|
39
|
+
return data
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class SpecEvaluation:
|
|
44
|
+
entry: SpecEntry
|
|
45
|
+
status: str
|
|
46
|
+
source_hash: str | None
|
|
47
|
+
change_kind: str | None
|
|
48
|
+
change_count: int
|
|
49
|
+
result: AttachResult | None = None
|
|
50
|
+
error: str | None = None
|
|
51
|
+
|
|
52
|
+
def as_status_dict(self) -> dict[str, object]:
|
|
53
|
+
return {
|
|
54
|
+
"id": self.entry.id,
|
|
55
|
+
"kind": self.entry.kind,
|
|
56
|
+
"source": self.entry.source,
|
|
57
|
+
"ref": self.entry.ref,
|
|
58
|
+
"source_name": self.entry.source_name,
|
|
59
|
+
"status": self.status,
|
|
60
|
+
"source_hash": self.source_hash,
|
|
61
|
+
"change_kind": self.change_kind,
|
|
62
|
+
"change_count": self.change_count,
|
|
63
|
+
"error": self.error,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def spec_config_path(workspace_path: Path) -> Path:
|
|
68
|
+
return workspace_path / ".modelable" / "specs.yml"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_spec_config(workspace_path: Path) -> list[SpecEntry]:
|
|
72
|
+
path = spec_config_path(workspace_path)
|
|
73
|
+
if not path.exists():
|
|
74
|
+
return []
|
|
75
|
+
doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
76
|
+
specs = doc.get("specs") or []
|
|
77
|
+
return [_entry_from_dict(item) for item in specs]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def write_spec_config(workspace_path: Path, specs: list[SpecEntry]) -> Path:
|
|
81
|
+
path = spec_config_path(workspace_path)
|
|
82
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
doc = {"specs": [entry.as_config_dict() for entry in specs]}
|
|
84
|
+
path.write_text(yaml.safe_dump(doc, sort_keys=False), encoding="utf-8")
|
|
85
|
+
return path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def add_spec(workspace_path: Path, entry: SpecEntry) -> Path:
|
|
89
|
+
if entry.kind not in SUPPORTED_SPEC_KINDS:
|
|
90
|
+
supported = ", ".join(sorted(SUPPORTED_SPEC_KINDS))
|
|
91
|
+
raise ValueError(f"Unsupported tracked spec kind '{entry.kind}'. Supported kinds: {supported}")
|
|
92
|
+
specs = load_spec_config(workspace_path)
|
|
93
|
+
if any(item.id == entry.id for item in specs):
|
|
94
|
+
raise ValueError(f"Tracked spec '{entry.id}' already exists")
|
|
95
|
+
specs.append(entry)
|
|
96
|
+
return write_spec_config(workspace_path, specs)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def select_specs(workspace_path: Path, spec_id: str | None) -> list[SpecEntry]:
|
|
100
|
+
specs = load_spec_config(workspace_path)
|
|
101
|
+
if spec_id is None:
|
|
102
|
+
return specs
|
|
103
|
+
selected = [entry for entry in specs if entry.id == spec_id]
|
|
104
|
+
if not selected:
|
|
105
|
+
raise ValueError(f"Tracked spec '{spec_id}' not found")
|
|
106
|
+
return selected
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def evaluate_spec(
|
|
110
|
+
workspace_path: Path,
|
|
111
|
+
entry: SpecEntry,
|
|
112
|
+
*,
|
|
113
|
+
write: bool = False,
|
|
114
|
+
token: str | None = None,
|
|
115
|
+
) -> SpecEvaluation:
|
|
116
|
+
try:
|
|
117
|
+
source_text, source_path = _resolve_source(workspace_path, entry, token=token)
|
|
118
|
+
source_hash = hashlib.sha256(source_text.encode("utf-8")).hexdigest()
|
|
119
|
+
result = attach_external_version(
|
|
120
|
+
workspace_path,
|
|
121
|
+
entry.ref,
|
|
122
|
+
source_path,
|
|
123
|
+
entry.kind,
|
|
124
|
+
source_name=entry.source_name,
|
|
125
|
+
write=write,
|
|
126
|
+
)
|
|
127
|
+
status = "drifted" if result.attached else "clean"
|
|
128
|
+
return SpecEvaluation(
|
|
129
|
+
entry=entry,
|
|
130
|
+
status=status,
|
|
131
|
+
source_hash=source_hash,
|
|
132
|
+
change_kind=result.change_kind,
|
|
133
|
+
change_count=len(result.changes),
|
|
134
|
+
result=result,
|
|
135
|
+
)
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
return SpecEvaluation(
|
|
138
|
+
entry=entry,
|
|
139
|
+
status="error",
|
|
140
|
+
source_hash=None,
|
|
141
|
+
change_kind=None,
|
|
142
|
+
change_count=0,
|
|
143
|
+
error=str(exc),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _spec_cache_dir(workspace_path: Path, spec_id: str) -> Path:
|
|
148
|
+
sanitized = _sanitize_id(spec_id)
|
|
149
|
+
return workspace_path / ".modelable" / "specs-cache" / sanitized
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _sanitize_id(spec_id: str) -> str:
|
|
153
|
+
return "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in spec_id)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _is_remote_source(source: str) -> bool:
|
|
157
|
+
return source.startswith(("http://", "https://"))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _resolve_source(
|
|
161
|
+
workspace_path: Path,
|
|
162
|
+
entry: SpecEntry,
|
|
163
|
+
*,
|
|
164
|
+
token: str | None = None,
|
|
165
|
+
) -> tuple[str, Path]:
|
|
166
|
+
if _is_remote_source(entry.source):
|
|
167
|
+
return _fetch_remote_source(entry.source, workspace_path, entry.id, token=token)
|
|
168
|
+
path = _resolve_local_source_path(workspace_path, entry.source)
|
|
169
|
+
return path.read_text(encoding="utf-8"), path
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _fetch_remote_source(
|
|
173
|
+
url: str,
|
|
174
|
+
workspace_path: Path,
|
|
175
|
+
spec_id: str,
|
|
176
|
+
*,
|
|
177
|
+
token: str | None = None,
|
|
178
|
+
) -> tuple[str, Path]:
|
|
179
|
+
cache_dir = _spec_cache_dir(workspace_path, spec_id)
|
|
180
|
+
cache_path = cache_dir / "source"
|
|
181
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
|
|
183
|
+
req = urllib.request.Request(url)
|
|
184
|
+
if token:
|
|
185
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
with urllib.request.urlopen(req, timeout=30) as response:
|
|
189
|
+
content = response.read().decode("utf-8")
|
|
190
|
+
except urllib.error.HTTPError as exc:
|
|
191
|
+
raise SpecSourceError(f"Failed to fetch remote spec {url}: HTTP {exc.code} {exc.reason}") from exc
|
|
192
|
+
except urllib.error.URLError as exc:
|
|
193
|
+
raise SpecSourceError(f"Failed to fetch remote spec {url}: {exc.reason}") from exc
|
|
194
|
+
except OSError as exc:
|
|
195
|
+
raise SpecSourceError(f"Failed to fetch remote spec {url}: {exc}") from exc
|
|
196
|
+
|
|
197
|
+
cache_path.write_text(content, encoding="utf-8")
|
|
198
|
+
return content, cache_path
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _resolve_local_source_path(workspace_path: Path, source: str) -> Path:
|
|
202
|
+
path = Path(source)
|
|
203
|
+
if path.is_absolute():
|
|
204
|
+
return path
|
|
205
|
+
return workspace_path / path
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _entry_from_dict(item: dict[str, object]) -> SpecEntry:
|
|
209
|
+
return SpecEntry(
|
|
210
|
+
id=str(item["id"]),
|
|
211
|
+
kind=str(item["kind"]),
|
|
212
|
+
source=str(item["source"]),
|
|
213
|
+
ref=str(item["ref"]),
|
|
214
|
+
source_name=str(item["source_name"]) if item.get("source_name") is not None else None,
|
|
215
|
+
update_policy=str(item.get("update_policy") or "preview"),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def change_dicts(result: AttachResult) -> list[dict[str, object]]:
|
|
220
|
+
return [asdict(change) for change in result.changes]
|