floe-python 0.8.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.
- floe/__init__.py +327 -0
- floe/__main__.py +5 -0
- floe/_cli.py +302 -0
- floe/_cli_output.py +187 -0
- floe/add_entity.py +516 -0
- floe/checks/cast.py +160 -0
- floe/checks/mismatch.py +137 -0
- floe/checks/normalize.py +118 -0
- floe/checks/rows.py +116 -0
- floe/checks/unique.py +247 -0
- floe/config/__init__.py +0 -0
- floe/config/entity.py +479 -0
- floe/config/include.py +122 -0
- floe/config/parse.py +140 -0
- floe/config/profile.py +381 -0
- floe/config/project.py +404 -0
- floe/config/storage.py +179 -0
- floe/config/types.py +195 -0
- floe/config/validate.py +803 -0
- floe/config/vars.py +127 -0
- floe/config/yaml.py +263 -0
- floe/errors.py +13 -0
- floe/glob.py +300 -0
- floe/iceberg/catalog.py +345 -0
- floe/iceberg/compact.py +139 -0
- floe/iceberg/merge.py +297 -0
- floe/iceberg/sink.py +208 -0
- floe/iceberg/unique.py +61 -0
- floe/iceberg/write.py +270 -0
- floe/lineage/events.py +163 -0
- floe/lineage/observer.py +261 -0
- floe/maintain.py +134 -0
- floe/manifest.py +503 -0
- floe/manifest_run.py +299 -0
- floe/pii.py +134 -0
- floe/py.typed +0 -0
- floe/read/__init__.py +0 -0
- floe/read/avro.py +40 -0
- floe/read/csv.py +138 -0
- floe/read/json.py +236 -0
- floe/read/orc.py +42 -0
- floe/read/parquet.py +43 -0
- floe/read/xlsx.py +75 -0
- floe/read/xml.py +227 -0
- floe/report/entity.py +245 -0
- floe/report/summary.py +114 -0
- floe/run/__init__.py +175 -0
- floe/run/entity.py +963 -0
- floe/run/events.py +42 -0
- floe/state.py +311 -0
- floe/storage/__init__.py +0 -0
- floe/storage/archive.py +31 -0
- floe/storage/inputs.py +302 -0
- floe/storage/local.py +175 -0
- floe/storage/partitioning.py +116 -0
- floe/storage/paths.py +69 -0
- floe/storage/resolve.py +55 -0
- floe/storage/s3.py +182 -0
- floe/storage/types.py +33 -0
- floe/write/__init__.py +0 -0
- floe/write/outputs.py +121 -0
- floe_python-0.8.0.dist-info/METADATA +15 -0
- floe_python-0.8.0.dist-info/RECORD +66 -0
- floe_python-0.8.0.dist-info/WHEEL +4 -0
- floe_python-0.8.0.dist-info/entry_points.txt +2 -0
- floe_python-0.8.0.dist-info/licenses/LICENSE +21 -0
floe/manifest.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""Orchestrator manifest, ported from `manifest/builder.rs` and `manifest/model.rs`.
|
|
2
|
+
|
|
3
|
+
The manifest JSON is the contract (Dagster and Airflow consume it): key order is the order of the
|
|
4
|
+
Rust structs, and every value Rust builds with `serde_json::to_value` has its keys sorted.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import asdict
|
|
13
|
+
from itertools import takewhile
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
from typing import Any, NamedTuple, cast
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
from floe import __version__
|
|
20
|
+
from floe.config.profile import ProfileConfig
|
|
21
|
+
from floe.config.project import ReportConfig, RootConfig
|
|
22
|
+
from floe.config.storage import StoragesConfig
|
|
23
|
+
from floe.config.types import ArchiveTarget, EntityConfig, SinkTarget, SourceConfig
|
|
24
|
+
from floe.config.validate import FLOE_VERSION
|
|
25
|
+
from floe.errors import FloeConfigError, FloeError
|
|
26
|
+
from floe.pii import PiiColumn
|
|
27
|
+
from floe.run import select_entities
|
|
28
|
+
from floe.storage.paths import normalize_local_path
|
|
29
|
+
|
|
30
|
+
Json = dict[str, Any]
|
|
31
|
+
_REMOTE = ("s3://", "gs://", "gcs://", "abfs://", "abfss://", "az://")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _sorted(value: object) -> Any:
|
|
35
|
+
"""`serde_json::to_value`: objects come out key-sorted."""
|
|
36
|
+
if isinstance(value, BaseModel):
|
|
37
|
+
value = value.model_dump(mode="json")
|
|
38
|
+
if isinstance(value, dict):
|
|
39
|
+
return {k: _sorted(v) for k, v in sorted(cast(Json, value).items())}
|
|
40
|
+
if isinstance(value, list):
|
|
41
|
+
return [_sorted(v) for v in cast(list[object], value)]
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _put(target: Json, **optional: object) -> Json:
|
|
46
|
+
"""`skip_serializing_if = "Option::is_none"`."""
|
|
47
|
+
return target | {k: v for k, v in optional.items() if v is not None}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sha256(data: bytes) -> str:
|
|
51
|
+
return f"sha256:{hashlib.sha256(data).hexdigest()}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _manifest_id(config_uri: str, checksum: str | None) -> str:
|
|
55
|
+
h = 0xCBF29CE484222325
|
|
56
|
+
for byte in config_uri.encode() + b"\0" + (checksum or "").encode():
|
|
57
|
+
h = ((h ^ byte) * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
|
|
58
|
+
return f"mfv1-{h:016x}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _revision(manifest: Json) -> str:
|
|
62
|
+
"""SHA-256 of the canonical content, without the volatile `generated_at_ts_ms` and itself."""
|
|
63
|
+
body = {
|
|
64
|
+
k: v
|
|
65
|
+
for k, v in manifest.items()
|
|
66
|
+
if k not in ("generated_at_ts_ms", "manifest_revision")
|
|
67
|
+
}
|
|
68
|
+
return _sha256(
|
|
69
|
+
json.dumps(_sorted(body), separators=(",", ":"), ensure_ascii=False).encode()
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Options(NamedTuple):
|
|
74
|
+
config_arg: str # `-c` as typed
|
|
75
|
+
deterministic: bool
|
|
76
|
+
manifest_name: str | None
|
|
77
|
+
default_domain: str | None
|
|
78
|
+
path_mode: str
|
|
79
|
+
runtime: str | None # None: detect
|
|
80
|
+
manifest_uri: str | None = (
|
|
81
|
+
None # replaces `{manifest_uri}` in `execution.base_args`
|
|
82
|
+
)
|
|
83
|
+
profile_arg: str | None = None # `-p` as typed
|
|
84
|
+
profile: ProfileConfig | None = None # its `execution` block shapes the runners
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _Context(NamedTuple):
|
|
88
|
+
config_dir: Path
|
|
89
|
+
storages: StoragesConfig | None
|
|
90
|
+
resolved_uri_paths: bool
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _s3_uri(bucket: str, prefix: str | None, raw: str) -> str | None:
|
|
94
|
+
"""`resolve_s3_uri`: the bucket-qualified URI, or None where Rust errors."""
|
|
95
|
+
uri_bucket, _, uri_key = raw.removeprefix("s3://").partition("/")
|
|
96
|
+
if raw.startswith("s3://") and uri_bucket:
|
|
97
|
+
if uri_bucket != bucket:
|
|
98
|
+
return None
|
|
99
|
+
return f"s3://{bucket}/{uri_key}" if uri_key else f"s3://{bucket}"
|
|
100
|
+
key = "/".join(p for p in ((prefix or "").strip("/"), raw.lstrip("/")) if p)
|
|
101
|
+
return f"s3://{bucket}/{key}" if key else f"s3://{bucket}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _Target:
|
|
105
|
+
"""`resolve_or_raw`: the resolved URI, else the raw path as typed."""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
ctx: _Context,
|
|
110
|
+
target: SourceConfig | SinkTarget | ArchiveTarget | ReportConfig,
|
|
111
|
+
) -> None:
|
|
112
|
+
storages, self.raw = ctx.storages, target.path
|
|
113
|
+
name = target.storage or (storages.default if storages else "local")
|
|
114
|
+
definition = storages and next(
|
|
115
|
+
(d for d in storages.definitions if d.name == name), None
|
|
116
|
+
)
|
|
117
|
+
kind = definition.type if definition else "local" if not storages else None
|
|
118
|
+
uri = None
|
|
119
|
+
if kind == "local" and not self.raw.startswith(_REMOTE):
|
|
120
|
+
uri = f"local://{normalize_local_path(ctx.config_dir / self.raw)}"
|
|
121
|
+
elif kind == "s3" and definition and definition.bucket is not None:
|
|
122
|
+
uri = _s3_uri(definition.bucket, definition.prefix, self.raw)
|
|
123
|
+
self.resolved = uri is not None
|
|
124
|
+
self.storage = name if uri else target.storage or "local"
|
|
125
|
+
self.uri = uri or self.raw
|
|
126
|
+
# `--manifest-path-mode resolved-uri`: a resolved path is recorded as its URI, minus `local://`
|
|
127
|
+
self.path = (
|
|
128
|
+
self.uri.removeprefix("local://")
|
|
129
|
+
if ctx.resolved_uri_paths and uri
|
|
130
|
+
else self.raw
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def json(self, fmt: str | None = None) -> Json:
|
|
134
|
+
fields = {"storage": self.storage, "uri": self.uri, "path": self.path}
|
|
135
|
+
return ({"format": fmt} if fmt else {}) | fields | {"resolved": self.resolved}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _pii_key(entity: EntityConfig, column: PiiColumn) -> str | None:
|
|
139
|
+
"""Only an exact `${VAR}` reference reaches a manifest (D20: no secrets). A literal hash key is
|
|
140
|
+
refused, not dropped: dropping it makes a manifest run hash with unsalted SHA-256 (#1006)."""
|
|
141
|
+
key = column.key
|
|
142
|
+
if key is None or re.fullmatch(r"\$\{[^{}]+\}", key):
|
|
143
|
+
return key
|
|
144
|
+
if column.strategy != "hash":
|
|
145
|
+
return None # `key` is ignored by every other strategy
|
|
146
|
+
raise FloeConfigError(
|
|
147
|
+
f"entity.name={entity.name} pii.columns[name={column.name}].key: a manifest never "
|
|
148
|
+
"carries secrets, so a literal hash key cannot be written to it; "
|
|
149
|
+
"set key to a ${ENV_VAR} reference"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _sink(ctx: _Context, target: SinkTarget) -> Json:
|
|
154
|
+
return _put(
|
|
155
|
+
_Target(ctx, target).json(target.format),
|
|
156
|
+
options=_sorted(target.options),
|
|
157
|
+
partition_by=target.partition_by,
|
|
158
|
+
partition_spec=_sorted(target.partition_spec), # #911
|
|
159
|
+
merge=_sorted(target.merge),
|
|
160
|
+
iceberg=_sorted(target.iceberg),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _entity(entity: EntityConfig, ctx: _Context, default_domain: str | None) -> Json:
|
|
165
|
+
source = _Target(ctx, entity.source)
|
|
166
|
+
sink, meta, schema, domain = (
|
|
167
|
+
entity.sink,
|
|
168
|
+
entity.metadata,
|
|
169
|
+
entity.schema_,
|
|
170
|
+
entity.domain if entity.domain is not None else default_domain,
|
|
171
|
+
)
|
|
172
|
+
accepted = _Target(ctx, sink.accepted)
|
|
173
|
+
rejected = sink.rejected and _Target(ctx, sink.rejected)
|
|
174
|
+
tags = {
|
|
175
|
+
k: v
|
|
176
|
+
for k, v in (
|
|
177
|
+
("data_product", meta and meta.data_product),
|
|
178
|
+
("domain", meta and meta.domain),
|
|
179
|
+
("owner", meta and meta.owner),
|
|
180
|
+
)
|
|
181
|
+
if v is not None
|
|
182
|
+
}
|
|
183
|
+
pii = entity.pii and _sorted(
|
|
184
|
+
{
|
|
185
|
+
"columns": [
|
|
186
|
+
asdict(c) | {"key": _pii_key(entity, c)} for c in entity.pii.columns
|
|
187
|
+
]
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
return _put(
|
|
191
|
+
{
|
|
192
|
+
"name": entity.id,
|
|
193
|
+
"domain": domain,
|
|
194
|
+
"group_name": domain if domain is not None else "default",
|
|
195
|
+
"asset_key": [domain if domain is not None else "default", entity.name],
|
|
196
|
+
"source_format": entity.source.format,
|
|
197
|
+
"accepted_sink_uri": accepted.uri,
|
|
198
|
+
"rejected_sink_uri": rejected and rejected.uri,
|
|
199
|
+
"tags": tags or None,
|
|
200
|
+
"source": _put(
|
|
201
|
+
source.json(entity.source.format),
|
|
202
|
+
ref=entity.source.source_ref,
|
|
203
|
+
resource=entity.source.resource,
|
|
204
|
+
)
|
|
205
|
+
| _put(
|
|
206
|
+
{
|
|
207
|
+
"cast_mode": entity.source.cast_mode,
|
|
208
|
+
"options": _sorted(entity.source.options),
|
|
209
|
+
},
|
|
210
|
+
partitioning=_sorted(entity.source.partitioning), # D28
|
|
211
|
+
),
|
|
212
|
+
"sinks": {
|
|
213
|
+
"accepted": _sink(ctx, sink.accepted),
|
|
214
|
+
"rejected": sink.rejected and _sink(ctx, sink.rejected),
|
|
215
|
+
"archive": sink.archive and _Target(ctx, sink.archive).json(),
|
|
216
|
+
},
|
|
217
|
+
"runner": None,
|
|
218
|
+
"policy_severity": entity.policy.severity,
|
|
219
|
+
"write_mode": sink.write_mode,
|
|
220
|
+
"incremental_mode": entity.incremental_mode,
|
|
221
|
+
"schema": _put(
|
|
222
|
+
{
|
|
223
|
+
"columns": [
|
|
224
|
+
_put(
|
|
225
|
+
{"name": c.name, "column_type": c.column_type},
|
|
226
|
+
source=c.source,
|
|
227
|
+
nullable=c.nullable,
|
|
228
|
+
unique=c.unique,
|
|
229
|
+
width=c.width,
|
|
230
|
+
trim=c.trim,
|
|
231
|
+
)
|
|
232
|
+
for c in schema.columns
|
|
233
|
+
],
|
|
234
|
+
"primary_key": schema.primary_key or [],
|
|
235
|
+
"unique_keys": schema.unique_keys or [],
|
|
236
|
+
},
|
|
237
|
+
normalize_columns=_sorted(schema.normalize_columns),
|
|
238
|
+
mismatch=_sorted(schema.mismatch),
|
|
239
|
+
schema_evolution=_sorted(schema.schema_evolution),
|
|
240
|
+
),
|
|
241
|
+
},
|
|
242
|
+
pii=pii,
|
|
243
|
+
state_path=entity.state and entity.state.path,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _execution(manifest_uri: str | None, profile: ProfileConfig | None) -> Json:
|
|
248
|
+
orchestration = profile and profile.orchestration
|
|
249
|
+
execution: Json = {
|
|
250
|
+
"entrypoint": "floe",
|
|
251
|
+
"base_args": ["run", "--manifest", manifest_uri or "{manifest_uri}"]
|
|
252
|
+
+ ["--log-format", "json", "--quiet"],
|
|
253
|
+
"per_entity_args": ["--entities", "{entity_name}"],
|
|
254
|
+
"log_format": "json",
|
|
255
|
+
"result_contract": {
|
|
256
|
+
"run_finished_event": True,
|
|
257
|
+
"summary_uri_field": "summary_uri",
|
|
258
|
+
"exit_codes": {
|
|
259
|
+
"0": "success_or_rejected",
|
|
260
|
+
"1": "technical_failure",
|
|
261
|
+
"2": "aborted",
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
"defaults": {"env": {}, "workdir": None},
|
|
265
|
+
}
|
|
266
|
+
if orchestration is not None: # Rust skips it when the profile sets none
|
|
267
|
+
execution["orchestration"] = orchestration
|
|
268
|
+
return execution
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
_RUNNER_FIELDS = (
|
|
272
|
+
*("command", "args", "timeout_seconds", "ttl_seconds_after_finished"),
|
|
273
|
+
*("poll_interval_seconds", "secrets", "image", "namespace", "service_account"),
|
|
274
|
+
*("resources", "env", "workspace_url", "existing_cluster_id", "config_uri"),
|
|
275
|
+
*("python_file_uri", "job_name", "auth", "env_parameters"),
|
|
276
|
+
)
|
|
277
|
+
_PYTHON_RUNNER_FIELDS = ("job_scope", "domain_resources") # carried only when set
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# Rust `runners_contract`: the fields each profile runner type carries into the manifest
|
|
281
|
+
_PROFILE_RUNNER_FIELDS = {
|
|
282
|
+
"kubernetes_job": _RUNNER_FIELDS[:11],
|
|
283
|
+
"databricks_job": (*_RUNNER_FIELDS[:3], _RUNNER_FIELDS[4], *_RUNNER_FIELDS[11:]),
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _runners(profile: ProfileConfig | None) -> Json:
|
|
288
|
+
"""The profile's `kubernetes_job` or `databricks_job` runner as `default`, else `local`."""
|
|
289
|
+
kind = profile and profile.runner.get("type")
|
|
290
|
+
if not isinstance(kind, str) or kind not in _PROFILE_RUNNER_FIELDS:
|
|
291
|
+
local = {"type": "local_process"} | dict.fromkeys(_RUNNER_FIELDS)
|
|
292
|
+
return {"default": "local", "definitions": {"local": local}}
|
|
293
|
+
assert profile is not None
|
|
294
|
+
keep = _PROFILE_RUNNER_FIELDS[kind]
|
|
295
|
+
runner: Json = {"type": kind}
|
|
296
|
+
for k in _RUNNER_FIELDS:
|
|
297
|
+
v = profile.runner.get(k) if k in keep else None
|
|
298
|
+
if k in ("env", "env_parameters") and isinstance(v, dict): # a BTreeMap in Rust
|
|
299
|
+
v = dict(sorted(cast(dict[str, str], v).items()))
|
|
300
|
+
runner[k] = v
|
|
301
|
+
if kind == "kubernetes_job":
|
|
302
|
+
for k in _PYTHON_RUNNER_FIELDS:
|
|
303
|
+
if (v := profile.runner.get(k)) is not None:
|
|
304
|
+
runner[k] = v
|
|
305
|
+
if kind == "databricks_job":
|
|
306
|
+
runner["job_name"] = runner["job_name"] or "floe-{domain}-{env}"
|
|
307
|
+
return {"default": "default", "definitions": {"default": runner}}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
_OPTIONAL = (
|
|
311
|
+
*("work_root", "config_checksum", "config_files"),
|
|
312
|
+
*("profile_uri", "profile_checksum"),
|
|
313
|
+
)
|
|
314
|
+
_WORK_ROOT = "/work"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _config_files(files: list[Path]) -> list[Json] | None:
|
|
318
|
+
"""Every file of a multi-file config by path relative to the root's directory, else None."""
|
|
319
|
+
if len(files) < 2:
|
|
320
|
+
return None
|
|
321
|
+
root = files[0].parent.parts
|
|
322
|
+
entries: list[Json] = []
|
|
323
|
+
for file in files:
|
|
324
|
+
parts = file.parts
|
|
325
|
+
common = sum(1 for _ in takewhile(lambda p: p[0] == p[1], zip(parts, root)))
|
|
326
|
+
path = "/".join([".."] * (len(root) - common) + list(parts[common:]))
|
|
327
|
+
entries.append(
|
|
328
|
+
{"path": path, "sha256": hashlib.sha256(file.read_bytes()).hexdigest()}
|
|
329
|
+
)
|
|
330
|
+
return sorted(entries, key=lambda e: e["path"])
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _blocks(root: RootConfig) -> Json:
|
|
334
|
+
"""The effective `storages`, `catalogs` and `lineage`, serialized as Rust does (secrets in clear)."""
|
|
335
|
+
blocks: Json = {}
|
|
336
|
+
if root.storages:
|
|
337
|
+
blocks["storages"] = _sorted(
|
|
338
|
+
{
|
|
339
|
+
"default": root.storages.default,
|
|
340
|
+
"definitions": [
|
|
341
|
+
d.model_dump(exclude={"type"}) | {"fs_type": d.type}
|
|
342
|
+
for d in root.storages.definitions
|
|
343
|
+
],
|
|
344
|
+
}
|
|
345
|
+
)
|
|
346
|
+
if root.catalogs:
|
|
347
|
+
blocks["catalogs"] = _sorted(
|
|
348
|
+
{
|
|
349
|
+
"default": root.catalogs.default,
|
|
350
|
+
"definitions": [
|
|
351
|
+
{
|
|
352
|
+
"name": c.name,
|
|
353
|
+
"type_config": {
|
|
354
|
+
"type": "rest",
|
|
355
|
+
"uri": c.uri,
|
|
356
|
+
"credential": c.credential
|
|
357
|
+
and c.credential.get_secret_value(),
|
|
358
|
+
"warehouse": c.warehouse,
|
|
359
|
+
"oauth2_server_uri": c.oauth2_server_uri,
|
|
360
|
+
"scope": c.scope,
|
|
361
|
+
},
|
|
362
|
+
"warehouse_storage": c.warehouse_storage,
|
|
363
|
+
"warehouse_prefix": c.warehouse_prefix,
|
|
364
|
+
}
|
|
365
|
+
for c in root.catalogs.definitions
|
|
366
|
+
],
|
|
367
|
+
}
|
|
368
|
+
)
|
|
369
|
+
if root.lineage:
|
|
370
|
+
key = root.lineage.api_key
|
|
371
|
+
blocks["lineage"] = _sorted(
|
|
372
|
+
root.lineage.model_dump(exclude={"api_key"})
|
|
373
|
+
| {"api_key": key and key.get_secret_value()}
|
|
374
|
+
)
|
|
375
|
+
return blocks
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _sources(entities: list[Json]) -> list[Json] | None:
|
|
379
|
+
"""D20: the named sources' index, derived from the entities that use them."""
|
|
380
|
+
index: dict[str, Json] = {}
|
|
381
|
+
for entity in entities:
|
|
382
|
+
source = entity["source"]
|
|
383
|
+
if (ref := source.get("ref")) is None:
|
|
384
|
+
continue
|
|
385
|
+
# ponytail: an entity overlay can change a named source's format; the first entity's wins
|
|
386
|
+
entry = index.setdefault(
|
|
387
|
+
ref, {"name": ref, "format": source["format"], "uri": source["uri"]}
|
|
388
|
+
)
|
|
389
|
+
pairs = zip(entry["uri"].split("/"), source["uri"].split("/"))
|
|
390
|
+
common = takewhile(lambda p: p[0] == p[1], pairs)
|
|
391
|
+
entry["uri"] = "/".join(a for a, _ in common)
|
|
392
|
+
entry.setdefault("entities", []).append(entity["name"])
|
|
393
|
+
for (
|
|
394
|
+
entry
|
|
395
|
+
) in index.values(): # consumers on different storages or buckets share no location
|
|
396
|
+
if entry["uri"].rstrip("/").endswith(":") or not entry["uri"]:
|
|
397
|
+
entry["uri"] = None
|
|
398
|
+
return sorted(index.values(), key=lambda s: s["name"]) or None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def detect_runtime() -> str:
|
|
402
|
+
"""`FLOE_RUNTIME`, else a container (`/.dockerenv` or cwd `/work`) is `image`, else `cli`."""
|
|
403
|
+
forced = os.environ.get("FLOE_RUNTIME", "").strip().lower()
|
|
404
|
+
if forced in ("image", "cli"):
|
|
405
|
+
return forced
|
|
406
|
+
in_container = Path("/.dockerenv").exists() or Path.cwd() == Path(_WORK_ROOT)
|
|
407
|
+
return "image" if in_container else "cli"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _config_uri(typed: str, canonical: Path, work_root: str | None) -> str:
|
|
411
|
+
"""`cli`: the canonical host path. `image`: the path as typed, rebased under the work-root."""
|
|
412
|
+
if work_root is None:
|
|
413
|
+
return f"local://{canonical}"
|
|
414
|
+
parts = ["" if p.startswith("/") else p for p in PurePosixPath(typed).parts]
|
|
415
|
+
rel = "/".join(parts) or "."
|
|
416
|
+
return (
|
|
417
|
+
f"local://{rel}"
|
|
418
|
+
if rel.startswith("/")
|
|
419
|
+
else f"local://{work_root.rstrip('/')}/{rel}"
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def build_manifest(
|
|
424
|
+
root: RootConfig, config_path: Path, options: Options, selectors: list[str]
|
|
425
|
+
) -> Json:
|
|
426
|
+
if options.path_mode not in ("default", "resolved-uri"):
|
|
427
|
+
raise FloeError(
|
|
428
|
+
f"unknown --manifest-path-mode value '{options.path_mode}'; expected 'default' or 'resolved-uri'"
|
|
429
|
+
)
|
|
430
|
+
runtime = options.runtime or detect_runtime()
|
|
431
|
+
work_root = None
|
|
432
|
+
if runtime == "image":
|
|
433
|
+
work_root = os.environ.get("FLOE_WORK_ROOT", "")
|
|
434
|
+
work_root = (work_root if work_root.strip() else _WORK_ROOT).replace("\\", "/")
|
|
435
|
+
config_uri = _config_uri(options.config_arg, config_path, work_root)
|
|
436
|
+
ctx = _Context(
|
|
437
|
+
# D20: in `image` runtime local paths resolve beside `config_uri`, never on the host
|
|
438
|
+
Path(config_uri.removeprefix("local://")).parent
|
|
439
|
+
if work_root is not None
|
|
440
|
+
else normalize_local_path(config_path.parent),
|
|
441
|
+
root.storages,
|
|
442
|
+
options.path_mode == "resolved-uri",
|
|
443
|
+
)
|
|
444
|
+
files = _config_files(root.config_files)
|
|
445
|
+
checksum = (
|
|
446
|
+
_sha256("".join(f"{f['path']}\0{f['sha256']}\n" for f in files).encode())
|
|
447
|
+
if files
|
|
448
|
+
else _sha256(config_path.read_bytes())
|
|
449
|
+
if len(root.config_files) == 1
|
|
450
|
+
else None
|
|
451
|
+
)
|
|
452
|
+
report = _Target(ctx, root.report)
|
|
453
|
+
profile_uri = profile_checksum = None
|
|
454
|
+
if options.profile_arg is not None: # recorded like `config_uri` and its checksum
|
|
455
|
+
profile = Path(os.path.realpath(options.profile_arg))
|
|
456
|
+
profile_uri = _config_uri(options.profile_arg, profile, work_root)
|
|
457
|
+
profile_checksum = _sha256(profile.read_bytes())
|
|
458
|
+
manifest: Json = _put(
|
|
459
|
+
{
|
|
460
|
+
"generated_at_ts_ms": 0,
|
|
461
|
+
"floe_version": __version__,
|
|
462
|
+
"spec_version": f"{FLOE_VERSION[0]}.{FLOE_VERSION[1]}", # D22
|
|
463
|
+
},
|
|
464
|
+
manifest_name=options.manifest_name,
|
|
465
|
+
)
|
|
466
|
+
manifest |= {
|
|
467
|
+
"manifest_id": _manifest_id(config_uri, checksum),
|
|
468
|
+
"manifest_revision": None,
|
|
469
|
+
"runtime_env": runtime,
|
|
470
|
+
"work_root": work_root,
|
|
471
|
+
"config_uri": config_uri,
|
|
472
|
+
"config_checksum": checksum,
|
|
473
|
+
"config_files": files,
|
|
474
|
+
"profile_uri": profile_uri,
|
|
475
|
+
"profile_checksum": profile_checksum,
|
|
476
|
+
"report_base_uri": report.uri,
|
|
477
|
+
"domains": [
|
|
478
|
+
_put(
|
|
479
|
+
{"name": d.name},
|
|
480
|
+
incoming_dir=d.resolved_incoming_dir or d.incoming_dir,
|
|
481
|
+
)
|
|
482
|
+
for d in root.domains
|
|
483
|
+
],
|
|
484
|
+
"execution": _execution(options.manifest_uri, options.profile),
|
|
485
|
+
"runners": _runners(options.profile),
|
|
486
|
+
"entities": [
|
|
487
|
+
_entity(e, ctx, options.default_domain)
|
|
488
|
+
for e in sorted(select_entities(root, selectors), key=lambda e: e.id)
|
|
489
|
+
],
|
|
490
|
+
}
|
|
491
|
+
manifest |= _blocks(root)
|
|
492
|
+
manifest = _put(
|
|
493
|
+
manifest,
|
|
494
|
+
metadata=root.metadata and root.metadata.model_dump(exclude_none=True),
|
|
495
|
+
sources=_sources(manifest["entities"]),
|
|
496
|
+
)
|
|
497
|
+
manifest = {
|
|
498
|
+
k: v for k, v in manifest.items() if k not in _OPTIONAL or v is not None
|
|
499
|
+
}
|
|
500
|
+
if not options.deterministic:
|
|
501
|
+
manifest["generated_at_ts_ms"] = time.time_ns() // 1_000_000
|
|
502
|
+
manifest["manifest_revision"] = _revision(manifest)
|
|
503
|
+
return manifest
|