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,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,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .adapter import base
4
+
5
+ __all__ = ["base"]
@@ -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]
@@ -0,0 +1,3 @@
1
+ from modelable.validation.semantic import validate
2
+
3
+ __all__ = ["validate"]