helios-model 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ """Helios model discovery: harvest a data source's tables, profile them, propose a
2
+ semantic model and publish it (burndown Phase 5).
3
+
4
+ The facades are ``Catalog``, ``Profiler``, ``Proposer`` and ``Publisher``
5
+ (``helios_model.discovery``), with the typed ``Proposals`` they pass along.
6
+
7
+ helios-model depends on helios-cortex alone; engines and catalogs come from plugins
8
+ (helios_cortex.plugins), so it runs on any warehouse an installed plugin supports.
9
+ """
10
+
11
+ _EXPORTS = {
12
+ "Catalog": "discovery", "Profiler": "discovery", "Proposer": "discovery", "Publisher": "discovery",
13
+ "Harvest": "discovery", "Profile": "discovery", "Publication": "discovery",
14
+ "DiscoveryError": "discovery", "ModelNotRegistered": "discovery",
15
+ "Proposals": "proposals", "Proposal": "proposals", "ProposedDataset": "proposals",
16
+ "ProposedField": "proposals", "ProposedRelationship": "proposals", "ProposedMetric": "proposals",
17
+ "ProposedTerm": "proposals",
18
+ }
19
+
20
+ __all__ = sorted(_EXPORTS)
21
+
22
+
23
+ def __getattr__(name: str):
24
+ # Exports are imported on first use, so importing helios_model loads no engine,
25
+ # store or LLM code; __version__ is read from the installed metadata.
26
+ if name in _EXPORTS:
27
+ from importlib import import_module
28
+
29
+ return getattr(import_module(f"helios_model.{_EXPORTS[name]}"), name)
30
+ if name == "__version__":
31
+ from importlib.metadata import PackageNotFoundError, version
32
+
33
+ try:
34
+ return version("helios-model")
35
+ except PackageNotFoundError: # running from a source path without an install
36
+ return "0.0.0+unknown"
37
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
helios_model/build.py ADDED
@@ -0,0 +1,198 @@
1
+ """
2
+ Build an Apache Ossie semantic model document from a reviewed helios proposal.
3
+ Validating and writing the document is the format's (helios_cortex.ossie.format).
4
+
5
+ Mapping (Ossie core spec 0.2.0.dev0, schema vendored as ossie-schema.json):
6
+
7
+ dataset -> datasets[].name = physical table, source = database.table
8
+ business name goes into ai_context.synonyms (the spec has no dataset label)
9
+ field -> fields[].name = column, expression ANSI_SQL = column, label = business name
10
+ role time -> dimension.is_time = true
11
+ role dimension / attribute / foreign_key / identifier -> dimension.is_time = false
12
+ role measure -> no `dimension` block (Ossie has no measure flag)
13
+ relationship -> relationships[] (accepted only), unique name per from-column
14
+ metric -> metrics[], expression re-qualified as dataset.column
15
+ provenance -> custom_extensions [{vendor_name: HELIOS, data: <json>}] on every element:
16
+ role, confidence, source, glossary links, refers_to, run id.
17
+
18
+ Nothing in here talks to Atlas or the warehouse; proposed glossary terms are published separately.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+
25
+ from helios_cortex.ossie.format import DIALECT, SPEC_VERSION, VENDOR
26
+
27
+ _SQL_WORDS = {"group_concat", "date_trunc", "date_part", "date_add", "date_sub", "current_date", "current_timestamp",
28
+ "is_null", "not_null", "if_null", "if_not"}
29
+
30
+ _TYPE_MAP = [
31
+ (("bigint", "int", "smallint", "tinyint"), "Integer"),
32
+ (("decimal", "numeric"), "Decimal"),
33
+ (("double", "float", "real"), "Float"),
34
+ (("boolean", "bool"), "Boolean"),
35
+ (("timestamp",), "DateTime"),
36
+ (("date",), "Date"),
37
+ (("string", "varchar", "char", "text"), "String"),
38
+ ]
39
+
40
+
41
+ def datatype(engine_type: str | None) -> str:
42
+ t = (engine_type or "").lower()
43
+ for prefixes, dt in _TYPE_MAP:
44
+ if t.startswith(prefixes):
45
+ return dt
46
+ return "Opaque"
47
+
48
+
49
+ def slug(s: str) -> str:
50
+ s = re.sub(r"[^0-9a-zA-Z]+", "_", s.strip()).strip("_").lower()
51
+ return re.sub(r"_+", "_", s) or "unnamed"
52
+
53
+
54
+ def _ext(data: dict) -> list[dict]:
55
+ clean = {k: v for k, v in data.items() if v not in (None, [], "", {})}
56
+ return [{"vendor_name": VENDOR, "data": json.dumps(clean, separators=(",", ":"))}]
57
+
58
+
59
+ def _table_name(table: str) -> str:
60
+ return table.split(".")[-1]
61
+
62
+
63
+ def _qualify(expression: str, table: str, columns: set[str]) -> str:
64
+ """Prefix bare column references with the dataset name, e.g. SUM(ss_net_paid) -> SUM(store_sales.ss_net_paid)."""
65
+ ds = _table_name(table)
66
+ pattern = re.compile(r"(?<![\w.])(" + "|".join(re.escape(c) for c in sorted(columns, key=len, reverse=True)) + r")(?![\w(])")
67
+ return pattern.sub(lambda m: f"{ds}.{m.group(1)}", expression) if columns else expression
68
+
69
+
70
+ def _pk_and_unique(d: dict) -> tuple[list[str], list[list[str]]]:
71
+ """Surrogate key becomes the primary key; other single-column keys become unique keys."""
72
+ pks = list(d.get("primary_key") or [])
73
+ if not pks:
74
+ return [], []
75
+ sk = [c for c in pks if c.endswith("_sk")]
76
+ primary = sk[:1] or pks[:1]
77
+ unique = [[c] for c in pks if c not in primary]
78
+ return primary, unique
79
+
80
+
81
+ def build_dataset(d: dict, run_id: str | None) -> dict:
82
+ name = _table_name(d["table"])
83
+ primary, unique = _pk_and_unique(d)
84
+ fields = []
85
+ for f in d["fields"]:
86
+ role = f.get("role", "attribute")
87
+ field = {
88
+ "name": f["column"],
89
+ "expression": {"dialects": [{"dialect": DIALECT, "expression": f["column"]}]},
90
+ "label": f.get("name") or f["column"],
91
+ "description": f.get("description") or "",
92
+ "datatype": datatype(f.get("type")),
93
+ }
94
+ if role == "time":
95
+ field["dimension"] = {"is_time": True}
96
+ elif role != "measure":
97
+ field["dimension"] = {"is_time": False}
98
+ terms = list(f.get("glossary_terms") or [])
99
+ if terms:
100
+ field["ai_context"] = {"synonyms": terms}
101
+ field["custom_extensions"] = _ext({
102
+ "role": role, "engine_type": f.get("type"), "confidence": f.get("confidence"),
103
+ "refers_to": f.get("refers_to"), "glossary_terms": terms,
104
+ })
105
+ fields.append(field)
106
+
107
+ ds = {
108
+ "name": name,
109
+ "source": d["table"],
110
+ "description": d.get("description") or "",
111
+ "fields": fields,
112
+ "custom_extensions": _ext({"kind": d.get("kind"), "confidence": d.get("confidence"), "run_id": run_id}),
113
+ }
114
+ if primary:
115
+ ds["primary_key"] = primary
116
+ if unique:
117
+ ds["unique_keys"] = unique
118
+ if d.get("name") and d["name"].lower() != name.replace("_", " "):
119
+ ds["ai_context"] = {"synonyms": [d["name"]]}
120
+ return ds
121
+
122
+
123
+ def build_relationships(rels: list[dict]) -> list[dict]:
124
+ out, seen = [], set()
125
+ for r in rels:
126
+ if not r.get("accepted", True):
127
+ continue
128
+ frm, to = _table_name(r["from"]), _table_name(r["to"])
129
+ name = f"{frm}__{r['from_column']}__{to}"
130
+ if name in seen:
131
+ continue
132
+ seen.add(name)
133
+ out.append({
134
+ "name": name, "from": frm, "to": to,
135
+ "from_columns": [r["from_column"]], "to_columns": [r["to_column"]],
136
+ "custom_extensions": _ext({"source": r.get("source"), "confidence": r.get("confidence"),
137
+ "reason": r.get("reason"), "match_ratio": r.get("match_ratio")}),
138
+ })
139
+ return out
140
+
141
+
142
+ def build_metrics(metrics: list[dict], datasets: list[dict]) -> tuple[list[dict], list[str]]:
143
+ cols_by_table = {d["table"]: {f["column"] for f in d["fields"]} for d in datasets}
144
+ all_cols = {c for cs in cols_by_table.values() for c in cs}
145
+ out, seen, problems = [], set(), []
146
+ for m in metrics:
147
+ cols = cols_by_table.get(m["dataset"], set())
148
+ referenced = set(re.findall(r"\b[a-z][a-z0-9_]*\b", m["expression"].lower()))
149
+ foreign = (referenced & all_cols) - cols
150
+ if foreign:
151
+ problems.append(f"{m['name']}: references columns outside {m['dataset']}: {sorted(foreign)}")
152
+ continue
153
+ unknown = {t for t in referenced if "_" in t and t not in all_cols and t not in _SQL_WORDS}
154
+ if unknown:
155
+ problems.append(f"{m['name']}: unknown columns {sorted(unknown)}")
156
+ continue
157
+ name = slug(m["name"])
158
+ if name in seen:
159
+ problems.append(f"{m['name']}: duplicate metric name {name}")
160
+ continue
161
+ seen.add(name)
162
+ out.append({
163
+ "name": name,
164
+ "expression": {"dialects": [{"dialect": DIALECT, "expression": _qualify(m["expression"], m["dataset"], cols)}]},
165
+ "description": m.get("description") or "",
166
+ "datatype": "Float" if "/" in m["expression"] else "Decimal",
167
+ "ai_context": {"synonyms": [m["name"]]},
168
+ "custom_extensions": _ext({"dataset": m["dataset"], "source": m.get("source"),
169
+ "confidence": m.get("confidence")}),
170
+ })
171
+ return out, problems
172
+
173
+
174
+ def build(proposal: dict, model_name: str, description: str = "",
175
+ run_id: str | None = None, model_id: str | None = None) -> tuple[dict, list[str]]:
176
+ """Return (ossie_document, problems). problems are non-fatal things dropped along the way."""
177
+ datasets = [build_dataset(d, run_id) for d in proposal["datasets"]]
178
+ relationships = build_relationships(proposal.get("relationships", []))
179
+ metrics, problems = build_metrics(proposal.get("metrics", []), proposal["datasets"])
180
+ doc = {
181
+ "version": SPEC_VERSION,
182
+ "name": model_name,
183
+ "description": description,
184
+ "ai_context": {"instructions": "Semantic model generated by helios from warehouse evidence and reviewed by a human. "
185
+ "Field labels are business names; custom_extensions carry helios provenance."},
186
+ "datasets": datasets,
187
+ "relationships": relationships,
188
+ "metrics": metrics,
189
+ "custom_extensions": _ext({"model_id": model_id, "run_id": run_id,
190
+ "llm": proposal.get("llm"), "review": proposal.get("review")}),
191
+ }
192
+ return doc, problems
193
+
194
+
195
+ def preflight(proposal: dict) -> dict:
196
+ """Problems build() would report for the proposal as it stands, keyed by metric name, for the review page."""
197
+ _, problems = build_metrics(proposal.get("metrics", []), proposal["datasets"])
198
+ return {p.split(":", 1)[0]: p.split(":", 1)[1].strip() for p in problems}
@@ -0,0 +1,296 @@
1
+ """The facades a host calls to build a model: harvest, profile, propose, publish.
2
+
3
+ catalog = Catalog(settings)
4
+ harvest = catalog.harvest(source, caller, databases=["sales"], model_id="sales")
5
+ profile = Profiler(settings).profile(harvest, caller)
6
+ proposals = Proposer(settings).propose(profile)
7
+ proposals.accept_all(min_confidence=0.8) # or a person reviews them
8
+ publication = Publisher(settings).publish(proposals, caller)
9
+
10
+ Each stage keeps its document in the run store (``<run_id>/<stage>.json``) and returns
11
+ a result carrying the run id; the next stage takes that result or the run id, so the
12
+ stages can run in different processes. They are thin: the work is done by
13
+ ``TableHarvester``, ``TableProfiler``, ``ModelProposer``, ``review`` and
14
+ ``publication``, unchanged.
15
+
16
+ The ``caller`` (``helios_cortex.Caller``) is who the host says is asking. Harvest and
17
+ profile forward it to the engine, which runs the catalog reads and the profiling
18
+ queries as the caller under the connection's identity mode, so a model is built from
19
+ what that caller may see; ``None`` is the connection's service identity. Publish
20
+ records the caller's username as the publisher. Helios checks no permissions itself.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from collections.abc import Mapping, Sequence
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime, timezone
28
+ from types import MappingProxyType
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ from helios_cortex.caller import Caller
32
+ from helios_cortex.catalog import catalog_for
33
+ from helios_cortex.domain import DataSource
34
+ from helios_cortex.engines import Engine, engine_for
35
+ from helios_cortex.registry import SemanticRevision
36
+ from helios_cortex.runs import RunStore
37
+ from helios_cortex.settings import Settings
38
+
39
+ from helios_model import publication as publishing
40
+ from helios_model.harvest import TableHarvester
41
+ from helios_model.profile import TableProfiler
42
+ from helios_model.proposals import Proposals
43
+ from helios_model.propose import ModelProposer
44
+
45
+ if TYPE_CHECKING:
46
+ from helios_cortex.artifacts import ArtifactStore
47
+ from helios_cortex.llm import LLMClient
48
+ from helios_cortex.registry import ModelRegistry
49
+
50
+ LOGGER = logging.getLogger(__name__)
51
+
52
+
53
+ class DiscoveryError(RuntimeError):
54
+ """A stage cannot run: its input run is missing, or nothing is configured to run it."""
55
+
56
+
57
+ class ModelNotRegistered(LookupError):
58
+ """Publishing a model the registry does not hold. Register it first (``save_model``)."""
59
+
60
+
61
+ def new_run_id() -> str:
62
+ """A run id: the UTC time to the second, so run ids sort by time."""
63
+ return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
64
+
65
+
66
+ def _username(caller: Caller | None) -> str | None:
67
+ return caller.username if caller is not None else None
68
+
69
+
70
+ def _frozen(document: Mapping[str, Any]) -> Mapping[str, Any]:
71
+ return MappingProxyType(dict(document))
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Harvest:
76
+ """A harvest kept in run ``run_id``: tables, columns, row counts, glossary, query log."""
77
+
78
+ run_id: str
79
+ model_id: str
80
+ data_source_id: str
81
+ document: Mapping[str, Any] = field(repr=False)
82
+
83
+ @property
84
+ def tables(self) -> tuple[str, ...]:
85
+ """``database.table`` for every harvested table."""
86
+ return tuple(f"{t['database']}.{t['table']}" for t in self.document.get("tables", []))
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class Profile:
91
+ """A profile kept in run ``run_id``: statistics, keys and verified relationships."""
92
+
93
+ run_id: str
94
+ model_id: str
95
+ document: Mapping[str, Any] = field(repr=False)
96
+
97
+ @property
98
+ def relationships(self) -> tuple[Mapping[str, Any], ...]:
99
+ """Relationships found from both names and data."""
100
+ return tuple(self.document.get("relationships", []))
101
+
102
+ @property
103
+ def suggested_relationships(self) -> tuple[Mapping[str, Any], ...]:
104
+ """Relationships the data supports but the names do not; the proposer decides them."""
105
+ return tuple(self.document.get("suggested_relationships", []))
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class Publication:
110
+ """A published model: the registry's revision row, the Ossie document and the
111
+ artifact store's manifest (path, hash, size, counts, review)."""
112
+
113
+ revision: SemanticRevision
114
+ document: Mapping[str, Any] = field(repr=False)
115
+ manifest: Mapping[str, Any] = field(repr=False)
116
+
117
+ @property
118
+ def model_id(self) -> str:
119
+ return self.revision.model_id
120
+
121
+ @property
122
+ def revision_id(self) -> str:
123
+ return self.revision.id
124
+
125
+
126
+ class _Stage:
127
+ def __init__(self, settings: Settings, *, runs: RunStore | None = None):
128
+ self.settings = settings
129
+ self.runs = runs if runs is not None else RunStore.from_settings(settings)
130
+
131
+ def _engine(self, connection_ref: str | None) -> Engine:
132
+ connection = self.settings.connection(connection_ref)
133
+ if connection is None:
134
+ raise DiscoveryError(f"no connection {connection_ref!r} (and no default) in the settings")
135
+ return engine_for(connection)
136
+
137
+ def _load(self, run_id: str, stage: str, before: str) -> dict[str, Any]:
138
+ document = self.runs.load(run_id, stage)
139
+ if document is None:
140
+ raise DiscoveryError(f"run {run_id!r} has no {stage}; {before} first")
141
+ return document
142
+
143
+
144
+ class Catalog(_Stage):
145
+ """Harvests a data source into a new run. ``Catalog(settings)`` keeps runs where the
146
+ settings say (``RunStore.from_settings``); pass ``runs=`` to choose."""
147
+
148
+ def harvest(self, source: DataSource, caller: Caller | None, *, databases: Sequence[str], model_id: str,
149
+ glossaries: Sequence[str] | None = None, query_dir: str | None = None,
150
+ catalog: str | None = None, run_id: str | None = None) -> Harvest:
151
+ """Harvest ``databases`` of ``source`` as ``caller`` for model ``model_id``.
152
+
153
+ The engine is the connection ``source.connection_ref`` names; the glossary comes
154
+ from the catalog ``catalog`` names (the default catalog when None, none when the
155
+ settings have no catalogs). ``databases`` is required: Helios never guesses what
156
+ to harvest. ``run_id`` defaults to a new one; an existing run is never replaced."""
157
+ databases = [d for d in databases if d]
158
+ if not databases:
159
+ raise ValueError("name at least one database to harvest")
160
+ run_id = run_id or new_run_id()
161
+ if self.runs.load(run_id, "harvest") is not None:
162
+ raise DiscoveryError(f"run {run_id!r} already has a harvest; start a new run")
163
+ engine = self._engine(source.connection_ref)
164
+ harvester = TableHarvester(engine, catalog_for(self.settings.catalog(catalog)))
165
+ document = harvester.run(list(databases), list(glossaries) if glossaries else None, query_dir, caller=caller)
166
+ document.update({"model_id": model_id, "data_source_id": source.id,
167
+ "connection_ref": source.connection_ref, "harvested_by": _username(caller)})
168
+ self.runs.write(run_id, "harvest", document)
169
+ return Harvest(run_id, model_id, source.id, _frozen(document))
170
+
171
+
172
+ class Profiler(_Stage):
173
+ """Profiles a harvest on the connection it was harvested from."""
174
+
175
+ def __init__(self, settings: Settings, *, runs: RunStore | None = None,
176
+ overlap_threshold: float = 0.95, max_pairs: int = 400):
177
+ super().__init__(settings, runs=runs)
178
+ self.overlap_threshold = overlap_threshold
179
+ self.max_pairs = max_pairs
180
+
181
+ def profile(self, harvest: Harvest | str, caller: Caller | None = None) -> Profile:
182
+ """Profile the harvest (a ``Harvest`` or its run id) as ``caller``, or as the
183
+ service identity without one, and keep the result in the same run."""
184
+ run_id = harvest.run_id if isinstance(harvest, Harvest) else harvest
185
+ document = self._load(run_id, "harvest", "harvest")
186
+ if "connection_ref" not in document:
187
+ raise DiscoveryError(f"run {run_id!r} was not harvested by Catalog.harvest; it names no connection")
188
+ profiler = TableProfiler(self._engine(document["connection_ref"]), self.overlap_threshold, self.max_pairs)
189
+ result = profiler.run(document, caller=caller)
190
+ result.update({"model_id": document.get("model_id"), "profiled_by": _username(caller)})
191
+ self.runs.write(run_id, "profile", result)
192
+ return Profile(run_id, result["model_id"], _frozen(result))
193
+
194
+
195
+ class Proposer(_Stage):
196
+ """Drafts a model from a profiled run with an LLM: ``llm=`` or the settings' ``[llm]``."""
197
+
198
+ def __init__(self, settings: Settings, *, runs: RunStore | None = None,
199
+ artifacts: ArtifactStore | None = None, llm: LLMClient | None = None):
200
+ super().__init__(settings, runs=runs)
201
+ self._artifacts = artifacts
202
+ self._llm = llm
203
+
204
+ @property
205
+ def artifacts(self) -> ArtifactStore:
206
+ if self._artifacts is None:
207
+ from helios_cortex.artifacts import ArtifactStore
208
+
209
+ self._artifacts = ArtifactStore.from_settings(self.settings)
210
+ return self._artifacts
211
+
212
+ def _client(self) -> LLMClient:
213
+ if self._llm is None:
214
+ unconfigured = DiscoveryError("no LLM configured: set [llm] provider (and its key or base URL)")
215
+ if self.settings.llm.provider is None:
216
+ raise unconfigured # before importing the client, which needs helios-cortex[llm]
217
+ from helios_cortex.llm import llm_from_settings
218
+
219
+ self._llm = llm_from_settings(self.settings.llm)
220
+ if self._llm is None:
221
+ raise unconfigured
222
+ return self._llm
223
+
224
+ def propose(self, profile: Profile | str) -> Proposals:
225
+ """Propose a model from the run's harvest and profile, with no decisions yet. The
226
+ proposal is kept in the run and as the model's latest proposal in the artifact store."""
227
+ run_id = profile.run_id if isinstance(profile, Profile) else profile
228
+ harvest = self._load(run_id, "harvest", "harvest")
229
+ profiled = self._load(run_id, "profile", "profile")
230
+ model_id = harvest.get("model_id")
231
+ if not model_id:
232
+ raise DiscoveryError(f"run {run_id!r} names no model")
233
+ proposal = ModelProposer(self._client()).run(harvest, profiled)
234
+ proposal["model_id"] = model_id
235
+ self.runs.write(run_id, "propose", proposal)
236
+ self.artifacts.write_proposal(model_id, run_id, proposal)
237
+ return Proposals(proposal, run_id=run_id, model_id=model_id)
238
+
239
+ def load(self, run_id: str) -> Proposals:
240
+ """A run's proposals with the decisions made on them so far."""
241
+ proposal = self._load(run_id, "propose", "propose")
242
+ model_id = proposal.get("model_id") or (self.runs.load(run_id, "harvest") or {}).get("model_id")
243
+ if not model_id:
244
+ raise DiscoveryError(f"run {run_id!r} names no model")
245
+ return Proposals(proposal, run_id=run_id, model_id=model_id, review=self.runs.load(run_id, "review"))
246
+
247
+ def save_review(self, proposals: Proposals, caller: Caller | None, *, at: datetime | None = None) -> None:
248
+ """Keep the decisions on ``proposals`` in their run, as reviewed by ``caller`` at
249
+ ``at`` (now by default). Who and when are stamped on ``proposals`` too, and
250
+ published with them."""
251
+ proposals.mark_reviewed(_username(caller), at)
252
+ self.runs.write(proposals.run_id, "review", proposals.review)
253
+
254
+
255
+ class Publisher:
256
+ """Publishes reviewed proposals: the Ossie artifact (validated, then made the model's
257
+ active revision) and its row in the registry."""
258
+
259
+ def __init__(self, settings: Settings, *, registry: ModelRegistry | None = None,
260
+ artifacts: ArtifactStore | None = None, runs: RunStore | None = None):
261
+ self.settings = settings
262
+ if registry is None:
263
+ from helios_cortex.registry import SqlRegistry
264
+
265
+ registry = SqlRegistry.from_settings(settings)
266
+ if artifacts is None:
267
+ from helios_cortex.artifacts import ArtifactStore
268
+
269
+ artifacts = ArtifactStore.from_settings(settings)
270
+ self.registry = registry
271
+ self.artifacts = artifacts
272
+ self.runs = runs if runs is not None else RunStore.from_settings(settings)
273
+
274
+ def publish(self, proposals: Proposals, caller: Caller | None) -> Publication:
275
+ """Publish the accepted and edited elements of ``proposals``; pending and rejected
276
+ ones are left out. The decisions are kept in the run, so the run records what was
277
+ published. Raises ModelNotRegistered, or ``publication.PublicationValidationError``
278
+ when the reviewed model is not valid Ossie, before writing anything."""
279
+ model_id = proposals.model_id
280
+ if self.registry.model(model_id) is None:
281
+ raise ModelNotRegistered(f"model {model_id!r} is not in the registry; register it before publishing")
282
+ harvest = self.runs.load(proposals.run_id, "harvest") or {}
283
+ proposal, review = proposals.proposal, proposals.review
284
+ published_by = _username(caller)
285
+ result = publishing.publish_reviewed_proposal( # validates before it writes
286
+ self.artifacts, model_id=model_id, model_name=publishing.model_name_for_proposal(proposal, harvest),
287
+ run_id=proposals.run_id, proposal=proposal, review=review, published_by=published_by)
288
+ self.runs.write(proposals.run_id, "review", review)
289
+ manifest = result.manifest
290
+ revision = self.registry.save_semantic_revision(SemanticRevision(
291
+ id=manifest["revision_id"], model_id=model_id, sha256=manifest["sha256"],
292
+ artifact_path=manifest["path"], ossie_version=manifest.get("ossie_version"),
293
+ discovery_run_id=proposals.run_id, published_at=datetime.fromisoformat(manifest["published_at"]),
294
+ published_by=published_by, size_bytes=manifest["size_bytes"]))
295
+ LOGGER.info("registered revision %s of model %s", revision.id, model_id)
296
+ return Publication(revision, _frozen(result.document), _frozen(manifest))