openlakeforge-domain-model 0.2.0a1__tar.gz
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.
- openlakeforge_domain_model-0.2.0a1/PKG-INFO +6 -0
- openlakeforge_domain_model-0.2.0a1/openlakeforge_domain/__init__.py +67 -0
- openlakeforge_domain_model-0.2.0a1/openlakeforge_domain/descriptors.py +336 -0
- openlakeforge_domain_model-0.2.0a1/openlakeforge_domain/inventory.py +627 -0
- openlakeforge_domain_model-0.2.0a1/openlakeforge_domain_model.egg-info/PKG-INFO +6 -0
- openlakeforge_domain_model-0.2.0a1/openlakeforge_domain_model.egg-info/SOURCES.txt +9 -0
- openlakeforge_domain_model-0.2.0a1/openlakeforge_domain_model.egg-info/dependency_links.txt +1 -0
- openlakeforge_domain_model-0.2.0a1/openlakeforge_domain_model.egg-info/requires.txt +1 -0
- openlakeforge_domain_model-0.2.0a1/openlakeforge_domain_model.egg-info/top_level.txt +1 -0
- openlakeforge_domain_model-0.2.0a1/pyproject.toml +13 -0
- openlakeforge_domain_model-0.2.0a1/setup.cfg +4 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Canonical provider-neutral lakehouse descriptors and inventory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .descriptors import (
|
|
6
|
+
LAKEHOUSE_API_VERSION,
|
|
7
|
+
LAKEHOUSE_KIND,
|
|
8
|
+
SOURCE_API_VERSION,
|
|
9
|
+
SOURCE_KIND,
|
|
10
|
+
LakehouseDescriptorError,
|
|
11
|
+
load_lakehouse_descriptor,
|
|
12
|
+
load_source_descriptor,
|
|
13
|
+
validate_lakehouse_descriptor,
|
|
14
|
+
validate_source_descriptor,
|
|
15
|
+
)
|
|
16
|
+
from .inventory import (
|
|
17
|
+
ArtifactPrefixes,
|
|
18
|
+
CatalogNamespace,
|
|
19
|
+
Dashboard,
|
|
20
|
+
Domain,
|
|
21
|
+
LakehouseInventory,
|
|
22
|
+
PhysicalInventory,
|
|
23
|
+
PhysicalDomainNames,
|
|
24
|
+
PhysicalProductNames,
|
|
25
|
+
PhysicalSourceNames,
|
|
26
|
+
Product,
|
|
27
|
+
GoldTable,
|
|
28
|
+
SilverTable,
|
|
29
|
+
SourceResource,
|
|
30
|
+
Source,
|
|
31
|
+
Table,
|
|
32
|
+
inventory_for,
|
|
33
|
+
load_lakehouse_inventory,
|
|
34
|
+
load_lakehouse_inventory_from_descriptors,
|
|
35
|
+
load_transitional_lakehouse_inventory,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"LAKEHOUSE_API_VERSION",
|
|
40
|
+
"LAKEHOUSE_KIND",
|
|
41
|
+
"SOURCE_API_VERSION",
|
|
42
|
+
"SOURCE_KIND",
|
|
43
|
+
"ArtifactPrefixes",
|
|
44
|
+
"CatalogNamespace",
|
|
45
|
+
"Dashboard",
|
|
46
|
+
"Domain",
|
|
47
|
+
"LakehouseDescriptorError",
|
|
48
|
+
"LakehouseInventory",
|
|
49
|
+
"PhysicalInventory",
|
|
50
|
+
"PhysicalDomainNames",
|
|
51
|
+
"PhysicalProductNames",
|
|
52
|
+
"PhysicalSourceNames",
|
|
53
|
+
"Product",
|
|
54
|
+
"GoldTable",
|
|
55
|
+
"SilverTable",
|
|
56
|
+
"SourceResource",
|
|
57
|
+
"Source",
|
|
58
|
+
"Table",
|
|
59
|
+
"inventory_for",
|
|
60
|
+
"load_lakehouse_descriptor",
|
|
61
|
+
"load_lakehouse_inventory",
|
|
62
|
+
"load_lakehouse_inventory_from_descriptors",
|
|
63
|
+
"load_transitional_lakehouse_inventory",
|
|
64
|
+
"load_source_descriptor",
|
|
65
|
+
"validate_lakehouse_descriptor",
|
|
66
|
+
"validate_source_descriptor",
|
|
67
|
+
]
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Versioned descriptor validation for the Lakehouse and Source kinds.
|
|
2
|
+
|
|
3
|
+
The canonical v1alpha3 model is the ``Lakehouse`` descriptor at
|
|
4
|
+
``lakehouse_code/lakehouse.yaml`` together with one ``Source`` descriptor per
|
|
5
|
+
bronze source at ``lakehouse_code/bronze/<source>/source.yaml``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
LAKEHOUSE_API_VERSION = "openlakeforge.io/v1alpha3"
|
|
18
|
+
SOURCE_API_VERSION = "openlakeforge.io/v1alpha3"
|
|
19
|
+
LAKEHOUSE_KIND = "Lakehouse"
|
|
20
|
+
SOURCE_KIND = "Source"
|
|
21
|
+
_PRODUCT_IDENTIFIER_PATTERN = r"[a-z][a-z0-9_]*"
|
|
22
|
+
_IDENTIFIER_PATTERN = re.compile(rf"^{_PRODUCT_IDENTIFIER_PATTERN}$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LakehouseDescriptorError(ValueError):
|
|
26
|
+
"""Raised when a v1alpha3 lakehouse or source descriptor is invalid."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _identifier(value: object, *, field: str, source: str) -> str:
|
|
30
|
+
if not isinstance(value, str) or not _IDENTIFIER_PATTERN.fullmatch(value):
|
|
31
|
+
raise LakehouseDescriptorError(f"{source}: {field} must match '^[a-z][a-z0-9_]*$'")
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _non_empty_string(value: object, *, field: str, source: str) -> str:
|
|
36
|
+
if not isinstance(value, str) or not value:
|
|
37
|
+
raise LakehouseDescriptorError(f"{source}: {field} must be a non-empty string")
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _optional_string(value: object, *, field: str, source: str) -> None:
|
|
42
|
+
if value is not None and not isinstance(value, str):
|
|
43
|
+
raise LakehouseDescriptorError(f"{source}: {field} must be a string")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate_source_descriptor(document: Mapping[str, Any], *, source: str = "source.yaml") -> None:
|
|
47
|
+
"""Validate a v1alpha3 Source descriptor envelope and resource list."""
|
|
48
|
+
if document.get("apiVersion") != SOURCE_API_VERSION:
|
|
49
|
+
raise LakehouseDescriptorError(
|
|
50
|
+
f"{source}: unsupported apiVersion {document.get('apiVersion')!r}; expected {SOURCE_API_VERSION!r}"
|
|
51
|
+
)
|
|
52
|
+
if document.get("kind") != SOURCE_KIND:
|
|
53
|
+
raise LakehouseDescriptorError(f"{source}: kind must be {SOURCE_KIND!r}")
|
|
54
|
+
for field in ("name", "displayName", "description", "status", "resources"):
|
|
55
|
+
if field not in document:
|
|
56
|
+
raise LakehouseDescriptorError(f"{source}: missing required field {field!r}")
|
|
57
|
+
_identifier(document["name"], field="name", source=source)
|
|
58
|
+
_non_empty_string(document["displayName"], field="displayName", source=source)
|
|
59
|
+
_non_empty_string(document["status"], field="status", source=source)
|
|
60
|
+
_optional_string(document["description"], field="description", source=source)
|
|
61
|
+
resources = document["resources"]
|
|
62
|
+
if not isinstance(resources, list) or not resources:
|
|
63
|
+
raise LakehouseDescriptorError(f"{source}: resources must be a non-empty array")
|
|
64
|
+
seen: set[str] = set()
|
|
65
|
+
for index, resource in enumerate(resources):
|
|
66
|
+
if not isinstance(resource, Mapping):
|
|
67
|
+
raise LakehouseDescriptorError(f"{source}: resources[{index}] must be an object")
|
|
68
|
+
if "name" not in resource:
|
|
69
|
+
raise LakehouseDescriptorError(f"{source}: resources[{index}] is missing required field 'name'")
|
|
70
|
+
name = _identifier(resource["name"], field=f"resources[{index}].name", source=source)
|
|
71
|
+
if name in seen:
|
|
72
|
+
raise LakehouseDescriptorError(f"{source}: duplicate resource name {name!r}")
|
|
73
|
+
seen.add(name)
|
|
74
|
+
if "description" in resource and not isinstance(resource["description"], str):
|
|
75
|
+
raise LakehouseDescriptorError(f"{source}: resources[{index}].description must be a string")
|
|
76
|
+
unexpected = set(resource) - {"name", "description"}
|
|
77
|
+
if unexpected:
|
|
78
|
+
raise LakehouseDescriptorError(
|
|
79
|
+
f"{source}: resources[{index}] must not contain {sorted(unexpected)!r} (provider-neutral fields only)"
|
|
80
|
+
)
|
|
81
|
+
unexpected = set(document) - {"apiVersion", "kind", "name", "displayName", "description", "status", "resources"}
|
|
82
|
+
if unexpected:
|
|
83
|
+
raise LakehouseDescriptorError(
|
|
84
|
+
f"{source}: must not contain {sorted(unexpected)!r} (provider-neutral fields only)"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _validate_table_group(product: Mapping[str, Any], *, group: str, source: str) -> None:
|
|
89
|
+
spec = product.get(group)
|
|
90
|
+
if not isinstance(spec, Mapping):
|
|
91
|
+
raise LakehouseDescriptorError(f"{source}: product {product.get('id')!r}: {group} must be an object")
|
|
92
|
+
tables = spec.get("tables")
|
|
93
|
+
if not isinstance(tables, list) or not tables:
|
|
94
|
+
raise LakehouseDescriptorError(f"{source}: product {product.get('id')!r}: {group}.tables must be a non-empty array")
|
|
95
|
+
seen: set[str] = set()
|
|
96
|
+
for index, table in enumerate(tables):
|
|
97
|
+
if not isinstance(table, Mapping) or not _IDENTIFIER_PATTERN.fullmatch(table.get("name") or ""):
|
|
98
|
+
raise LakehouseDescriptorError(
|
|
99
|
+
f"{source}: product {product.get('id')!r}: {group}.tables[{index}].name must match '^[a-z][a-z0-9_]*$'"
|
|
100
|
+
)
|
|
101
|
+
if table["name"] in seen:
|
|
102
|
+
raise LakehouseDescriptorError(
|
|
103
|
+
f"{source}: product {product.get('id')!r}: {group}.tables must not contain duplicate names"
|
|
104
|
+
)
|
|
105
|
+
seen.add(table["name"])
|
|
106
|
+
if "description" in table and not isinstance(table["description"], str):
|
|
107
|
+
raise LakehouseDescriptorError(f"{source}: product {product.get('id')!r}: {group}.tables[{index}].description must be a string")
|
|
108
|
+
unexpected = set(table) - {"name", "description"}
|
|
109
|
+
if unexpected:
|
|
110
|
+
raise LakehouseDescriptorError(
|
|
111
|
+
f"{source}: product {product.get('id')!r}: {group}.tables[{index}] must not contain "
|
|
112
|
+
f"{sorted(unexpected)!r} (provider-neutral fields only)"
|
|
113
|
+
)
|
|
114
|
+
if "schema" in spec:
|
|
115
|
+
raise LakehouseDescriptorError(
|
|
116
|
+
f"{source}: product {product.get('id')!r}: {group}.schema must be derived from provider contracts"
|
|
117
|
+
)
|
|
118
|
+
unexpected = set(spec) - {"tables"}
|
|
119
|
+
if unexpected:
|
|
120
|
+
raise LakehouseDescriptorError(
|
|
121
|
+
f"{source}: product {product.get('id')!r}: {group} must not contain {sorted(unexpected)!r} "
|
|
122
|
+
"(provider-neutral fields only)"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _validate_product(product: Mapping[str, Any], *, domain_name: str, source: str) -> None:
|
|
127
|
+
label = product.get("id") or f"product {product!r}"
|
|
128
|
+
for field in ("id", "displayName", "description", "status"):
|
|
129
|
+
if field not in product:
|
|
130
|
+
raise LakehouseDescriptorError(f"{source}: product {label!r}: missing required field {field!r}")
|
|
131
|
+
_identifier(product["id"], field="id", source=f"{source}: product {label!r}")
|
|
132
|
+
_non_empty_string(product["displayName"], field="displayName", source=f"{source}: product {label!r}")
|
|
133
|
+
_non_empty_string(product["status"], field="status", source=f"{source}: product {label!r}")
|
|
134
|
+
_optional_string(product["description"], field="description", source=f"{source}: product {label!r}")
|
|
135
|
+
if "domain" in product and product["domain"] != domain_name:
|
|
136
|
+
raise LakehouseDescriptorError(
|
|
137
|
+
f"{source}: product {label!r}: domain {product['domain']!r} must match enclosing domain {domain_name!r}"
|
|
138
|
+
)
|
|
139
|
+
silver_inputs = product.get("silver_inputs")
|
|
140
|
+
if not isinstance(silver_inputs, list) or not silver_inputs:
|
|
141
|
+
raise LakehouseDescriptorError(f"{source}: product {label!r}: silver_inputs must be a non-empty array")
|
|
142
|
+
if any(not isinstance(value, str) or not _IDENTIFIER_PATTERN.fullmatch(value) for value in silver_inputs):
|
|
143
|
+
raise LakehouseDescriptorError(
|
|
144
|
+
f"{source}: product {label!r}: silver_inputs must contain identifiers matching '^[a-z][a-z0-9_]*$'"
|
|
145
|
+
)
|
|
146
|
+
if len(silver_inputs) != len(set(silver_inputs)):
|
|
147
|
+
raise LakehouseDescriptorError(f"{source}: product {label!r}: silver_inputs must not contain duplicates")
|
|
148
|
+
_validate_table_group(product, group="gold_tables", source=source)
|
|
149
|
+
unexpected = set(product) - {
|
|
150
|
+
"id", "displayName", "description", "status", "domain", "silver_inputs", "gold_tables",
|
|
151
|
+
}
|
|
152
|
+
if unexpected:
|
|
153
|
+
raise LakehouseDescriptorError(
|
|
154
|
+
f"{source}: product {label!r} must not contain {sorted(unexpected)!r} (provider-neutral fields only)"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _validate_domain(domain: Mapping[str, Any], *, source: str) -> None:
|
|
159
|
+
required = ("name", "displayName", "description", "status", "silver_tables", "products")
|
|
160
|
+
for field in required:
|
|
161
|
+
if field not in domain:
|
|
162
|
+
raise LakehouseDescriptorError(f"{source}: domain: missing required field {field!r}")
|
|
163
|
+
_identifier(domain["name"], field="domain.name", source=source)
|
|
164
|
+
_non_empty_string(domain["displayName"], field="domain.displayName", source=source)
|
|
165
|
+
_non_empty_string(domain["status"], field="domain.status", source=source)
|
|
166
|
+
_optional_string(domain["description"], field="domain.description", source=source)
|
|
167
|
+
_validate_silver_table_group(domain, source=source)
|
|
168
|
+
products = domain["products"]
|
|
169
|
+
if not isinstance(products, list):
|
|
170
|
+
raise LakehouseDescriptorError(f"{source}: domain {domain['name']!r}: products must be an array")
|
|
171
|
+
seen_products: set[str] = set()
|
|
172
|
+
silver_names = {table["name"] for table in domain["silver_tables"]["tables"]}
|
|
173
|
+
for product in products:
|
|
174
|
+
if not isinstance(product, Mapping):
|
|
175
|
+
raise LakehouseDescriptorError(f"{source}: domain {domain['name']!r}: products must contain objects")
|
|
176
|
+
_validate_product(product, domain_name=domain["name"], source=source)
|
|
177
|
+
if product["id"] in seen_products:
|
|
178
|
+
raise LakehouseDescriptorError(
|
|
179
|
+
f"{source}: domain {domain['name']!r}: duplicate product id {product['id']!r}"
|
|
180
|
+
)
|
|
181
|
+
seen_products.add(product["id"])
|
|
182
|
+
unknown_inputs = sorted(set(product["silver_inputs"]) - silver_names)
|
|
183
|
+
if unknown_inputs:
|
|
184
|
+
raise LakehouseDescriptorError(
|
|
185
|
+
f"{source}: product {product['id']!r}: silver_inputs reference unknown domain Silver tables "
|
|
186
|
+
f"{unknown_inputs!r}"
|
|
187
|
+
)
|
|
188
|
+
unexpected = set(domain) - {"name", "displayName", "description", "status", "silver_tables", "products"}
|
|
189
|
+
if unexpected:
|
|
190
|
+
raise LakehouseDescriptorError(
|
|
191
|
+
f"{source}: domain {domain['name']!r} must not contain {sorted(unexpected)!r} (provider-neutral fields only)"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _validate_silver_table_group(domain: Mapping[str, Any], *, source: str) -> None:
|
|
196
|
+
spec = domain.get("silver_tables")
|
|
197
|
+
if not isinstance(spec, Mapping) or not isinstance(spec.get("tables"), list) or not spec["tables"]:
|
|
198
|
+
raise LakehouseDescriptorError(f"{source}: domain {domain.get('name')!r}: silver_tables.tables must be a non-empty array")
|
|
199
|
+
if set(spec) != {"tables"}:
|
|
200
|
+
unexpected = sorted(set(spec) - {"tables"})
|
|
201
|
+
raise LakehouseDescriptorError(
|
|
202
|
+
f"{source}: domain {domain.get('name')!r}: silver_tables must not contain {unexpected!r} "
|
|
203
|
+
"(provider-neutral fields only)"
|
|
204
|
+
)
|
|
205
|
+
seen: set[str] = set()
|
|
206
|
+
for index, table in enumerate(spec["tables"]):
|
|
207
|
+
if not isinstance(table, Mapping) or not _IDENTIFIER_PATTERN.fullmatch(table.get("name") or ""):
|
|
208
|
+
raise LakehouseDescriptorError(f"{source}: domain {domain.get('name')!r}: silver_tables.tables[{index}].name must be an identifier")
|
|
209
|
+
if table["name"] in seen:
|
|
210
|
+
raise LakehouseDescriptorError(f"{source}: domain {domain.get('name')!r}: silver_tables.tables must not contain duplicate names")
|
|
211
|
+
seen.add(table["name"])
|
|
212
|
+
if not _IDENTIFIER_PATTERN.fullmatch(table.get("source") or "") or not _IDENTIFIER_PATTERN.fullmatch(table.get("resource") or ""):
|
|
213
|
+
raise LakehouseDescriptorError(f"{source}: domain {domain.get('name')!r}: silver_tables.tables[{index}] must declare source and resource identifiers")
|
|
214
|
+
if "description" in table and not isinstance(table["description"], str):
|
|
215
|
+
raise LakehouseDescriptorError(f"{source}: domain {domain.get('name')!r}: silver_tables.tables[{index}].description must be a string")
|
|
216
|
+
unexpected = set(table) - {"name", "source", "resource", "description"}
|
|
217
|
+
if unexpected:
|
|
218
|
+
raise LakehouseDescriptorError(f"{source}: domain {domain.get('name')!r}: silver_tables.tables[{index}] must not contain {sorted(unexpected)!r}")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _validate_dashboard(dashboard: Mapping[str, Any], *, source: str) -> None:
|
|
222
|
+
for field in ("name", "products"):
|
|
223
|
+
if field not in dashboard:
|
|
224
|
+
raise LakehouseDescriptorError(f"{source}: dashboard: missing required field {field!r}")
|
|
225
|
+
_identifier(dashboard["name"], field="dashboard.name", source=source)
|
|
226
|
+
products = dashboard["products"]
|
|
227
|
+
if not isinstance(products, list) or not products:
|
|
228
|
+
raise LakehouseDescriptorError(f"{source}: dashboard {dashboard['name']!r}: products must be a non-empty array")
|
|
229
|
+
if any(not isinstance(product, str) or not _IDENTIFIER_PATTERN.fullmatch(product) for product in products):
|
|
230
|
+
raise LakehouseDescriptorError(f"{source}: dashboard {dashboard['name']!r}: products must contain identifiers")
|
|
231
|
+
if len(products) != len(set(products)):
|
|
232
|
+
raise LakehouseDescriptorError(f"{source}: dashboard {dashboard['name']!r}: products must not contain duplicates")
|
|
233
|
+
unexpected = set(dashboard) - {"name", "products"}
|
|
234
|
+
if unexpected:
|
|
235
|
+
raise LakehouseDescriptorError(
|
|
236
|
+
f"{source}: dashboard {dashboard['name']!r} must not contain {sorted(unexpected)!r} "
|
|
237
|
+
"(provider-neutral fields only)"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def validate_lakehouse_descriptor(
|
|
242
|
+
document: Mapping[str, Any], *, source: str = "lakehouse.yaml", allow_incomplete: bool = False
|
|
243
|
+
) -> None:
|
|
244
|
+
"""Validate the v1alpha3 Lakehouse envelope, domains, and dashboards."""
|
|
245
|
+
if document.get("apiVersion") != LAKEHOUSE_API_VERSION:
|
|
246
|
+
raise LakehouseDescriptorError(
|
|
247
|
+
f"{source}: unsupported apiVersion {document.get('apiVersion')!r}; expected {LAKEHOUSE_API_VERSION!r}"
|
|
248
|
+
)
|
|
249
|
+
if document.get("kind") != LAKEHOUSE_KIND:
|
|
250
|
+
raise LakehouseDescriptorError(f"{source}: kind must be {LAKEHOUSE_KIND!r}")
|
|
251
|
+
for field in ("name", "displayName", "description", "status", "sources", "domains", "dashboards"):
|
|
252
|
+
if field not in document:
|
|
253
|
+
raise LakehouseDescriptorError(f"{source}: missing required field {field!r}")
|
|
254
|
+
_identifier(document["name"], field="name", source=source)
|
|
255
|
+
_non_empty_string(document["displayName"], field="displayName", source=source)
|
|
256
|
+
_non_empty_string(document["status"], field="status", source=source)
|
|
257
|
+
_optional_string(document["description"], field="description", source=source)
|
|
258
|
+
sources = document["sources"]
|
|
259
|
+
if not isinstance(sources, list) or (not sources and not allow_incomplete):
|
|
260
|
+
raise LakehouseDescriptorError(f"{source}: sources must be a non-empty array")
|
|
261
|
+
for index, source_name in enumerate(sources):
|
|
262
|
+
_identifier(source_name, field=f"sources[{index}]", source=source)
|
|
263
|
+
if len(sources) != len(set(sources)):
|
|
264
|
+
raise LakehouseDescriptorError(f"{source}: sources must not contain duplicates")
|
|
265
|
+
domains = document["domains"]
|
|
266
|
+
if not isinstance(domains, list) or (not domains and not allow_incomplete):
|
|
267
|
+
raise LakehouseDescriptorError(f"{source}: domains must be a non-empty array")
|
|
268
|
+
seen_domains: set[str] = set()
|
|
269
|
+
seen_products: set[str] = set()
|
|
270
|
+
for domain in domains:
|
|
271
|
+
if not isinstance(domain, Mapping):
|
|
272
|
+
raise LakehouseDescriptorError(f"{source}: domains must contain objects")
|
|
273
|
+
_validate_domain(domain, source=source)
|
|
274
|
+
if domain["name"] in seen_domains:
|
|
275
|
+
raise LakehouseDescriptorError(f"{source}: duplicate domain name {domain['name']!r}")
|
|
276
|
+
seen_domains.add(domain["name"])
|
|
277
|
+
for product in domain["products"]:
|
|
278
|
+
if product["id"] in seen_products:
|
|
279
|
+
raise LakehouseDescriptorError(
|
|
280
|
+
f"{source}: product id {product['id']!r} must be globally unique across domains"
|
|
281
|
+
)
|
|
282
|
+
seen_products.add(product["id"])
|
|
283
|
+
if not seen_products and not allow_incomplete:
|
|
284
|
+
raise LakehouseDescriptorError(
|
|
285
|
+
f"{source}: lakehouse must declare at least one product across its domains "
|
|
286
|
+
"(an individual domain may have zero products while it is being seeded, "
|
|
287
|
+
"but the lakehouse as a whole cannot -- deployment tooling requires a "
|
|
288
|
+
"default product)"
|
|
289
|
+
)
|
|
290
|
+
dashboards = document["dashboards"]
|
|
291
|
+
if not isinstance(dashboards, list):
|
|
292
|
+
raise LakehouseDescriptorError(f"{source}: dashboards must be an array")
|
|
293
|
+
seen_dashboards: set[str] = set()
|
|
294
|
+
for dashboard in dashboards:
|
|
295
|
+
if not isinstance(dashboard, Mapping):
|
|
296
|
+
raise LakehouseDescriptorError(f"{source}: dashboards must contain objects")
|
|
297
|
+
_validate_dashboard(dashboard, source=source)
|
|
298
|
+
if dashboard["name"] in seen_dashboards:
|
|
299
|
+
raise LakehouseDescriptorError(f"{source}: duplicate dashboard name {dashboard['name']!r}")
|
|
300
|
+
seen_dashboards.add(dashboard["name"])
|
|
301
|
+
unknown_products = sorted(set(dashboard["products"]) - seen_products)
|
|
302
|
+
if unknown_products:
|
|
303
|
+
raise LakehouseDescriptorError(
|
|
304
|
+
f"{source}: dashboard {dashboard['name']!r}: products reference unknown product ids "
|
|
305
|
+
f"{unknown_products!r}"
|
|
306
|
+
)
|
|
307
|
+
unexpected = set(document) - {
|
|
308
|
+
"apiVersion", "kind", "name", "displayName", "description", "status", "sources", "domains", "dashboards",
|
|
309
|
+
}
|
|
310
|
+
if unexpected:
|
|
311
|
+
raise LakehouseDescriptorError(
|
|
312
|
+
f"{source}: must not contain {sorted(unexpected)!r} (provider-neutral fields only)"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def load_lakehouse_descriptor(path: str | Path, *, allow_incomplete: bool = False) -> dict[str, Any]:
|
|
317
|
+
"""Load and validate the v1alpha3 Lakehouse descriptor at ``path``."""
|
|
318
|
+
source = str(path)
|
|
319
|
+
with Path(path).open(encoding="utf-8") as handle:
|
|
320
|
+
document = yaml.safe_load(handle)
|
|
321
|
+
if not isinstance(document, dict):
|
|
322
|
+
raise LakehouseDescriptorError(f"{source}: descriptor must contain a YAML object")
|
|
323
|
+
validate_lakehouse_descriptor(document, source=source, allow_incomplete=allow_incomplete)
|
|
324
|
+
return document
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def load_source_descriptor(path: str | Path) -> dict[str, Any]:
|
|
328
|
+
"""Load and validate the v1alpha3 Source descriptor at ``path``."""
|
|
329
|
+
source = str(path)
|
|
330
|
+
with Path(path).open(encoding="utf-8") as handle:
|
|
331
|
+
document = yaml.safe_load(handle)
|
|
332
|
+
if not isinstance(document, dict):
|
|
333
|
+
raise LakehouseDescriptorError(f"{source}: descriptor must contain a YAML object")
|
|
334
|
+
validate_source_descriptor(document, source=source)
|
|
335
|
+
return document
|
|
336
|
+
|
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
"""Typed, provider-neutral inventory derived from lakehouse descriptors.
|
|
2
|
+
|
|
3
|
+
The canonical v1alpha3 inventory is composed from the ``Lakehouse`` descriptor
|
|
4
|
+
at ``lakehouse_code/lakehouse.yaml`` and one ``Source`` descriptor per bronze
|
|
5
|
+
source at ``lakehouse_code/bronze/<source>/source.yaml``.
|
|
6
|
+
|
|
7
|
+
Canonical identities:
|
|
8
|
+
|
|
9
|
+
* Bronze ``<source>.<resource>``
|
|
10
|
+
* Silver ``<domain>.<table>``
|
|
11
|
+
* Gold ``<product>.<table>``
|
|
12
|
+
|
|
13
|
+
with the dependency chain Source/Bronze -> Domain/Silver -> Product/Gold ->
|
|
14
|
+
Dashboard. The legacy v1alpha1/v1alpha2 ``Domain`` inventory loaders below are
|
|
15
|
+
preserved for migration diagnostics; the canonical loaders are the
|
|
16
|
+
``load_lakehouse_inventory*`` functions.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from collections.abc import Mapping, Sequence
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from functools import cache
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .descriptors import (
|
|
29
|
+
LakehouseDescriptorError,
|
|
30
|
+
load_lakehouse_descriptor,
|
|
31
|
+
load_source_descriptor,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_IDENTIFIER_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Table:
|
|
39
|
+
"""A provider-neutral logical table or source resource."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class SourceResource:
|
|
46
|
+
"""A source-owned Bronze resource."""
|
|
47
|
+
|
|
48
|
+
source: str
|
|
49
|
+
name: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class SilverTable:
|
|
54
|
+
"""A domain-owned Silver table and its source resource."""
|
|
55
|
+
|
|
56
|
+
domain: str
|
|
57
|
+
name: str
|
|
58
|
+
source: str
|
|
59
|
+
resource: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class GoldTable:
|
|
64
|
+
"""A product-owned Gold table."""
|
|
65
|
+
|
|
66
|
+
product: str
|
|
67
|
+
name: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class ArtifactPrefixes:
|
|
72
|
+
"""Object-store locations derived from an owner's logical identity."""
|
|
73
|
+
|
|
74
|
+
manifest_key: str
|
|
75
|
+
floe_report_prefix: str
|
|
76
|
+
dbt_artifact_prefix: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class Source:
|
|
81
|
+
"""A bronze source and the resources it owns."""
|
|
82
|
+
|
|
83
|
+
descriptor_path: Path
|
|
84
|
+
name: str
|
|
85
|
+
display_name: str
|
|
86
|
+
resources: tuple[Table, ...]
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def bronze_namespace(self) -> str:
|
|
90
|
+
return f"{self.name}_bronze"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class Product:
|
|
95
|
+
"""A v1alpha3 product and its provider-neutral derived identities.
|
|
96
|
+
|
|
97
|
+
Product identity is the product ``id`` (also the product ``name``); it is
|
|
98
|
+
globally unique across the lakehouse inventory. The Silver layer is
|
|
99
|
+
domain-aligned (``{domain}_silver``), the Gold layer is product-aligned
|
|
100
|
+
(``{product}_gold``).
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
domain_name: str
|
|
104
|
+
id: str
|
|
105
|
+
display_name: str
|
|
106
|
+
silver_inputs: tuple[str, ...]
|
|
107
|
+
gold_tables: tuple[Table, ...]
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def name(self) -> str:
|
|
111
|
+
return self.id
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def job_name(self) -> str:
|
|
115
|
+
return f"{self.id}_pipeline"
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def silver_namespace(self) -> str:
|
|
119
|
+
return f"{self.domain_name}_silver"
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def gold_namespace(self) -> str:
|
|
123
|
+
return f"{self.id}_gold"
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def definitions_module(self) -> str:
|
|
127
|
+
return f"lakehouse_code.pipelines.dagster.{self.id}"
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def dbt_artifact_prefix(self) -> str:
|
|
131
|
+
return f"run-artifacts/dbt/{self.domain_name}/{self.id}/"
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def dbt_project_dir(self) -> str:
|
|
135
|
+
return f"lakehouse_code/gold/{self.id}/dbt"
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def openmetadata_data_product_fqns(self) -> tuple[str, str]:
|
|
139
|
+
return self.id, f"{self.domain_name}.{self.id}"
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def gold_mart_names(self) -> tuple[str, ...]:
|
|
143
|
+
return tuple(f"{self.gold_namespace}.{table.name}" for table in self.gold_tables)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class Domain:
|
|
148
|
+
"""A v1alpha3 domain and its products."""
|
|
149
|
+
|
|
150
|
+
descriptor_path: Path
|
|
151
|
+
name: str
|
|
152
|
+
display_name: str
|
|
153
|
+
silver_tables: tuple[SilverTable, ...]
|
|
154
|
+
products: tuple[Product, ...]
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def artifact_prefixes(self) -> ArtifactPrefixes:
|
|
158
|
+
return ArtifactPrefixes(
|
|
159
|
+
manifest_key=f"floe/manifests/{self.name}/{self.name}.manifest.json",
|
|
160
|
+
floe_report_prefix=f"floe/reports/{self.name}/",
|
|
161
|
+
dbt_artifact_prefix="",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def silver_namespace(self) -> str:
|
|
166
|
+
return f"{self.name}_silver"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True)
|
|
170
|
+
class Dashboard:
|
|
171
|
+
"""A consumption-aligned dashboard bound to one or more products."""
|
|
172
|
+
|
|
173
|
+
name: str
|
|
174
|
+
products: tuple[str, ...]
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def report_source_dir(self) -> str:
|
|
178
|
+
return f"lakehouse_code/dashboards/superset/{self.name}"
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def superset_export_bundle_name(self) -> str:
|
|
182
|
+
return f"{self.name}_superset_assets_export.zip"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class CatalogNamespace:
|
|
187
|
+
"""A physical catalog namespace and its object-store location."""
|
|
188
|
+
|
|
189
|
+
name: str
|
|
190
|
+
location: str
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class PhysicalProductNames:
|
|
195
|
+
"""Provider-specific names resolved for one logical product."""
|
|
196
|
+
|
|
197
|
+
product: str
|
|
198
|
+
gold_namespace: str
|
|
199
|
+
gold_schema_fqn: str
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class PhysicalDomainNames:
|
|
204
|
+
"""Provider-specific names resolved for one logical Silver domain."""
|
|
205
|
+
|
|
206
|
+
domain: str
|
|
207
|
+
silver_namespace: str
|
|
208
|
+
silver_schema_fqn: str
|
|
209
|
+
manifest_uri: str
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass(frozen=True)
|
|
213
|
+
class PhysicalSourceNames:
|
|
214
|
+
"""Provider-specific names resolved for one logical bronze source."""
|
|
215
|
+
|
|
216
|
+
source: str
|
|
217
|
+
bronze_namespace: str
|
|
218
|
+
bronze_schema_fqn: str
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass(frozen=True)
|
|
222
|
+
class PhysicalInventory:
|
|
223
|
+
"""Physical catalog and artifact identities for a provider contract."""
|
|
224
|
+
|
|
225
|
+
catalog_namespaces: tuple[CatalogNamespace, ...]
|
|
226
|
+
products: tuple[PhysicalProductNames, ...]
|
|
227
|
+
domains: tuple[PhysicalDomainNames, ...] = ()
|
|
228
|
+
sources: tuple[PhysicalSourceNames, ...] = ()
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def silver_namespaces(self) -> dict[str, str]:
|
|
232
|
+
return {domain.domain: domain.silver_namespace for domain in self.domains}
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def gold_namespaces(self) -> dict[str, str]:
|
|
236
|
+
return {product.product: product.gold_namespace for product in self.products}
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def silver_schema_fqns(self) -> dict[str, str]:
|
|
240
|
+
return {domain.domain: domain.silver_schema_fqn for domain in self.domains}
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def gold_schema_fqns(self) -> dict[str, str]:
|
|
244
|
+
return {product.product: product.gold_schema_fqn for product in self.products}
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def bronze_namespaces(self) -> dict[str, str]:
|
|
248
|
+
return {source.source: source.bronze_namespace for source in self.sources}
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def bronze_schema_fqns(self) -> dict[str, str]:
|
|
252
|
+
return {source.source: source.bronze_schema_fqn for source in self.sources}
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def domain_floe_manifest_uris(self) -> dict[str, str]:
|
|
256
|
+
return {domain.domain: domain.manifest_uri for domain in self.domains}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@dataclass(frozen=True)
|
|
260
|
+
class LakehouseInventory:
|
|
261
|
+
"""All sources, domains, products, and dashboards declared in one lakehouse."""
|
|
262
|
+
|
|
263
|
+
lakehouse_root: Path
|
|
264
|
+
name: str
|
|
265
|
+
sources: tuple[Source, ...]
|
|
266
|
+
domains: tuple[Domain, ...]
|
|
267
|
+
dashboards: tuple[Dashboard, ...]
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def products(self) -> tuple[Product, ...]:
|
|
271
|
+
return tuple(product for domain in self.domains for product in domain.products)
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def job_names(self) -> tuple[str, ...]:
|
|
275
|
+
return tuple(product.job_name for product in self.products)
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def catalog_namespaces(self) -> tuple[str, ...]:
|
|
279
|
+
return tuple(
|
|
280
|
+
namespace
|
|
281
|
+
for namespace in (*self.bronze_namespace_names, *self.silver_namespace_names, *self.gold_namespace_names)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def default_product(self) -> Product:
|
|
286
|
+
if not self.products:
|
|
287
|
+
raise LakehouseDescriptorError(f"{self.lakehouse_root}: no data products were discovered")
|
|
288
|
+
return self.products[0]
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def artifact_prefixes(self) -> tuple[ArtifactPrefixes, ...]:
|
|
292
|
+
return tuple(domain.artifact_prefixes for domain in self.domains)
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def domain_names(self) -> tuple[str, ...]:
|
|
296
|
+
return tuple(domain.name for domain in self.domains)
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def source_names(self) -> tuple[str, ...]:
|
|
300
|
+
return tuple(source.name for source in self.sources)
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def silver_table_count(self) -> int:
|
|
304
|
+
"""Count of distinct product-reachable physical Silver relations.
|
|
305
|
+
|
|
306
|
+
A domain may declare Silver tables ahead of any product consuming
|
|
307
|
+
them (see the product-less-domain scaffolding path), so this counts
|
|
308
|
+
only tables referenced by at least one product's ``silver_inputs``,
|
|
309
|
+
not every declared table. Two products in the same domain can
|
|
310
|
+
declare the same table (they share the domain's Silver namespace),
|
|
311
|
+
so this counts unique ``(domain, table)`` pairs rather than summing
|
|
312
|
+
per-product declarations, which would double-count a shared table.
|
|
313
|
+
"""
|
|
314
|
+
return sum(
|
|
315
|
+
len({name for product in domain.products for name in product.silver_inputs})
|
|
316
|
+
for domain in self.domains
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def gold_table_count(self) -> int:
|
|
321
|
+
return sum(len(product.gold_tables) for product in self.products)
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def gold_mart_names(self) -> tuple[str, ...]:
|
|
325
|
+
return tuple(mart for product in self.products for mart in product.gold_mart_names)
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def manifest_keys(self) -> tuple[str, ...]:
|
|
329
|
+
return tuple(domain.artifact_prefixes.manifest_key for domain in self.domains)
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def openmetadata_data_products(self) -> dict[str, tuple[str, str]]:
|
|
333
|
+
return {product.name: product.openmetadata_data_product_fqns for product in self.products}
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def bronze_namespace_names(self) -> frozenset[str]:
|
|
337
|
+
return frozenset(source.bronze_namespace for source in self.sources)
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def silver_namespace_names(self) -> frozenset[str]:
|
|
341
|
+
return frozenset(domain.silver_namespace for domain in self.domains)
|
|
342
|
+
|
|
343
|
+
def domain_for_product(self, product: Product) -> Domain:
|
|
344
|
+
return next(domain for domain in self.domains if domain.name == product.domain_name)
|
|
345
|
+
|
|
346
|
+
def resolved_silver_tables(self, product: Product) -> tuple[SilverTable, ...]:
|
|
347
|
+
domain = self.domain_for_product(product)
|
|
348
|
+
by_name = {table.name: table for table in domain.silver_tables}
|
|
349
|
+
return tuple(by_name[name] for name in product.silver_inputs)
|
|
350
|
+
|
|
351
|
+
def bronze_resources_for_product(self, product: Product) -> tuple[SourceResource, ...]:
|
|
352
|
+
return tuple(SourceResource(table.source, table.resource) for table in self.resolved_silver_tables(product))
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def gold_namespace_names(self) -> frozenset[str]:
|
|
356
|
+
return frozenset(product.gold_namespace for product in self.products)
|
|
357
|
+
|
|
358
|
+
def resolve_physical_names(
|
|
359
|
+
self,
|
|
360
|
+
*,
|
|
361
|
+
catalog_database_fqn: str,
|
|
362
|
+
bronze_bucket: str,
|
|
363
|
+
silver_bucket: str,
|
|
364
|
+
gold_bucket: str,
|
|
365
|
+
manifest_base_uri: str,
|
|
366
|
+
) -> PhysicalInventory:
|
|
367
|
+
"""Resolve physical names from provider-contract values."""
|
|
368
|
+
namespaces: list[CatalogNamespace] = []
|
|
369
|
+
products: list[PhysicalProductNames] = []
|
|
370
|
+
domains: list[PhysicalDomainNames] = []
|
|
371
|
+
sources: list[PhysicalSourceNames] = []
|
|
372
|
+
for domain in self.domains:
|
|
373
|
+
namespaces.append(
|
|
374
|
+
CatalogNamespace(domain.silver_namespace, f"s3://{silver_bucket}/{domain.silver_namespace}/")
|
|
375
|
+
)
|
|
376
|
+
domains.append(
|
|
377
|
+
PhysicalDomainNames(
|
|
378
|
+
domain=domain.name,
|
|
379
|
+
silver_namespace=domain.silver_namespace,
|
|
380
|
+
silver_schema_fqn=f"{catalog_database_fqn}.{domain.silver_namespace}",
|
|
381
|
+
manifest_uri=f"{manifest_base_uri.rstrip('/')}/{domain.name}/{domain.name}.manifest.json",
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
for product in self.products:
|
|
385
|
+
namespaces.append(
|
|
386
|
+
CatalogNamespace(product.gold_namespace, f"s3://{gold_bucket}/{product.gold_namespace}/")
|
|
387
|
+
)
|
|
388
|
+
products.append(
|
|
389
|
+
PhysicalProductNames(
|
|
390
|
+
product=product.name,
|
|
391
|
+
gold_namespace=product.gold_namespace,
|
|
392
|
+
gold_schema_fqn=f"{catalog_database_fqn}.{product.gold_namespace}",
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
for source in self.sources:
|
|
396
|
+
namespaces.append(
|
|
397
|
+
CatalogNamespace(source.bronze_namespace, f"s3://{bronze_bucket}/{source.bronze_namespace}/")
|
|
398
|
+
)
|
|
399
|
+
sources.append(
|
|
400
|
+
PhysicalSourceNames(
|
|
401
|
+
source=source.name,
|
|
402
|
+
bronze_namespace=source.bronze_namespace,
|
|
403
|
+
bronze_schema_fqn=f"{catalog_database_fqn}.{source.bronze_namespace}",
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
return PhysicalInventory(
|
|
407
|
+
catalog_namespaces=tuple(namespaces), products=tuple(products), domains=tuple(domains), sources=tuple(sources)
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _lakehouse_root(path: Path) -> Path:
|
|
412
|
+
candidate = path.resolve()
|
|
413
|
+
return candidate / "lakehouse_code" if (candidate / "lakehouse_code").is_dir() else candidate
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _source_table_names(source: Mapping[str, Any]) -> set[str]:
|
|
417
|
+
resources = source.get("resources")
|
|
418
|
+
if not isinstance(resources, list):
|
|
419
|
+
return set()
|
|
420
|
+
return {resource["name"] for resource in resources if isinstance(resource, Mapping) and "name" in resource}
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _product(
|
|
424
|
+
path: Path, domain_name: str, product: Mapping[str, Any], index: int, silver_tables: tuple[SilverTable, ...]
|
|
425
|
+
) -> Product:
|
|
426
|
+
label = product.get("id") or f"products[{index}]"
|
|
427
|
+
product_id = product.get("id")
|
|
428
|
+
if not isinstance(product_id, str) or not _IDENTIFIER_PATTERN.fullmatch(product_id):
|
|
429
|
+
raise LakehouseDescriptorError(f"{path}: product {label!r}: id must match '^[a-z][a-z0-9_]*$'")
|
|
430
|
+
display_name = product.get("displayName")
|
|
431
|
+
if not isinstance(display_name, str) or not display_name:
|
|
432
|
+
raise LakehouseDescriptorError(f"{path}: product {label!r}: displayName is required and must be a non-empty string")
|
|
433
|
+
inputs = product.get("silver_inputs")
|
|
434
|
+
if not isinstance(inputs, list) or not inputs:
|
|
435
|
+
raise LakehouseDescriptorError(f"{path}: product {label!r}: silver_inputs must be a non-empty array")
|
|
436
|
+
return Product(
|
|
437
|
+
domain_name=domain_name,
|
|
438
|
+
id=product_id,
|
|
439
|
+
display_name=display_name,
|
|
440
|
+
silver_inputs=tuple(inputs),
|
|
441
|
+
gold_tables=_table_group(path, product, "gold_tables", label),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _silver_tables(path: Path, domain_name: str, domain: Mapping[str, Any]) -> tuple[SilverTable, ...]:
|
|
446
|
+
tables = domain["silver_tables"]["tables"]
|
|
447
|
+
return tuple(
|
|
448
|
+
SilverTable(domain=domain_name, name=table["name"], source=table["source"], resource=table["resource"])
|
|
449
|
+
for table in tables
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _table_group(path: Path, product: Mapping[str, Any], field: str, label: str) -> tuple[Table, ...]:
|
|
454
|
+
spec = product.get(field)
|
|
455
|
+
if not isinstance(spec, Mapping):
|
|
456
|
+
raise LakehouseDescriptorError(f"{path}: product {label!r}: {field} is required and must be an object")
|
|
457
|
+
tables = spec.get("tables")
|
|
458
|
+
if not isinstance(tables, list) or not tables:
|
|
459
|
+
raise LakehouseDescriptorError(f"{path}: product {label!r}: {field}.tables must be a non-empty array")
|
|
460
|
+
names = [table.get("name") for table in tables if isinstance(table, Mapping)]
|
|
461
|
+
if len(names) != len(tables) or any(not isinstance(name, str) or not name for name in names):
|
|
462
|
+
raise LakehouseDescriptorError(f"{path}: product {label!r}: {field}.tables must contain non-empty string names")
|
|
463
|
+
if len(names) != len(set(names)):
|
|
464
|
+
raise LakehouseDescriptorError(f"{path}: product {label!r}: {field}.tables must not contain duplicate names")
|
|
465
|
+
return tuple(Table(name=name) for name in names)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _validate_inventory_identities(
|
|
469
|
+
lakehouse_root: Path,
|
|
470
|
+
sources: tuple[Source, ...],
|
|
471
|
+
domains: tuple[Domain, ...],
|
|
472
|
+
dashboards: tuple[Dashboard, ...],
|
|
473
|
+
) -> None:
|
|
474
|
+
seen_sources: set[str] = set()
|
|
475
|
+
for source in sources:
|
|
476
|
+
if source.name in seen_sources:
|
|
477
|
+
raise LakehouseDescriptorError(f"{source.descriptor_path}: duplicate source name {source.name!r}")
|
|
478
|
+
seen_sources.add(source.name)
|
|
479
|
+
seen_domains: set[str] = set()
|
|
480
|
+
seen_product_ids: set[str] = set()
|
|
481
|
+
for domain in domains:
|
|
482
|
+
if domain.name in seen_domains:
|
|
483
|
+
raise LakehouseDescriptorError(f"{domain.descriptor_path}: duplicate domain name {domain.name!r}")
|
|
484
|
+
seen_domains.add(domain.name)
|
|
485
|
+
silver_by_name = {table.name: table for table in domain.silver_tables}
|
|
486
|
+
for table in domain.silver_tables:
|
|
487
|
+
source = next((candidate for candidate in sources if candidate.name == table.source), None)
|
|
488
|
+
if source is None:
|
|
489
|
+
raise LakehouseDescriptorError(
|
|
490
|
+
f"{domain.descriptor_path}: domain {domain.name!r}: Silver table {table.name!r} references unknown source {table.source!r}"
|
|
491
|
+
)
|
|
492
|
+
if table.resource not in {resource.name for resource in source.resources}:
|
|
493
|
+
raise LakehouseDescriptorError(
|
|
494
|
+
f"{domain.descriptor_path}: domain {domain.name!r}: Silver table {table.name!r} references unknown "
|
|
495
|
+
f"resource {table.resource!r} of source {table.source!r}"
|
|
496
|
+
)
|
|
497
|
+
for product in domain.products:
|
|
498
|
+
if product.id in seen_product_ids:
|
|
499
|
+
raise LakehouseDescriptorError(
|
|
500
|
+
f"{domain.descriptor_path}: product {product.id!r}: id must be globally unique across the lakehouse"
|
|
501
|
+
)
|
|
502
|
+
seen_product_ids.add(product.id)
|
|
503
|
+
unknown = [name for name in product.silver_inputs if name not in silver_by_name]
|
|
504
|
+
if unknown:
|
|
505
|
+
raise LakehouseDescriptorError(
|
|
506
|
+
f"{domain.descriptor_path}: product {product.id!r}: silver_inputs reference unknown domain Silver tables {unknown!r}"
|
|
507
|
+
)
|
|
508
|
+
seen_dashboards: set[str] = set()
|
|
509
|
+
for dashboard in dashboards:
|
|
510
|
+
if dashboard.name in seen_dashboards:
|
|
511
|
+
raise LakehouseDescriptorError(f"{lakehouse_root}: duplicate dashboard name {dashboard.name!r}")
|
|
512
|
+
seen_dashboards.add(dashboard.name)
|
|
513
|
+
unknown_products = sorted(set(dashboard.products) - seen_product_ids)
|
|
514
|
+
if unknown_products:
|
|
515
|
+
raise LakehouseDescriptorError(
|
|
516
|
+
f"{lakehouse_root}: dashboard {dashboard.name!r}: products {unknown_products!r} must reference declared products"
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def load_lakehouse_inventory_from_descriptors(
|
|
521
|
+
lakehouse_path: str | Path,
|
|
522
|
+
source_paths: Sequence[str | Path],
|
|
523
|
+
*,
|
|
524
|
+
source_label: str | Path | None = None,
|
|
525
|
+
allow_incomplete: bool = False,
|
|
526
|
+
) -> LakehouseInventory:
|
|
527
|
+
"""Load and validate one Lakehouse descriptor plus its Source descriptors."""
|
|
528
|
+
lakehouse_document = load_lakehouse_descriptor(lakehouse_path, allow_incomplete=allow_incomplete)
|
|
529
|
+
sources: list[Source] = []
|
|
530
|
+
for source_path in source_paths:
|
|
531
|
+
document = load_source_descriptor(source_path)
|
|
532
|
+
name = document["name"]
|
|
533
|
+
if name != Path(source_path).parent.name:
|
|
534
|
+
raise LakehouseDescriptorError(
|
|
535
|
+
f"{source_path}: name {name!r} must match source directory {Path(source_path).parent.name!r}"
|
|
536
|
+
)
|
|
537
|
+
resources = tuple(
|
|
538
|
+
Table(name=resource["name"])
|
|
539
|
+
for resource in document["resources"]
|
|
540
|
+
if isinstance(resource, Mapping) and "name" in resource
|
|
541
|
+
)
|
|
542
|
+
sources.append(
|
|
543
|
+
Source(
|
|
544
|
+
descriptor_path=Path(source_path),
|
|
545
|
+
name=name,
|
|
546
|
+
display_name=document["displayName"],
|
|
547
|
+
resources=resources,
|
|
548
|
+
)
|
|
549
|
+
)
|
|
550
|
+
declared_sources = set(lakehouse_document["sources"])
|
|
551
|
+
discovered_sources = {source.name for source in sources}
|
|
552
|
+
if declared_sources != discovered_sources:
|
|
553
|
+
raise LakehouseDescriptorError(
|
|
554
|
+
f"{lakehouse_path}: lakehouse sources {sorted(declared_sources)!r} must match discovered "
|
|
555
|
+
f"source descriptors {sorted(discovered_sources)!r} under lakehouse_code/bronze/*/source.yaml"
|
|
556
|
+
)
|
|
557
|
+
domains: list[Domain] = []
|
|
558
|
+
for index, domain in enumerate(lakehouse_document["domains"]):
|
|
559
|
+
if not isinstance(domain, Mapping):
|
|
560
|
+
raise LakehouseDescriptorError(f"{lakehouse_path}: domains[{index}] must be an object")
|
|
561
|
+
domain_name = domain["name"]
|
|
562
|
+
silver_tables = _silver_tables(lakehouse_path, domain_name, domain)
|
|
563
|
+
products = tuple(
|
|
564
|
+
_product(lakehouse_path, domain_name, product, product_index, silver_tables)
|
|
565
|
+
for product_index, product in enumerate(domain["products"])
|
|
566
|
+
if isinstance(product, Mapping)
|
|
567
|
+
)
|
|
568
|
+
domains.append(
|
|
569
|
+
Domain(
|
|
570
|
+
descriptor_path=Path(lakehouse_path),
|
|
571
|
+
name=domain_name,
|
|
572
|
+
display_name=domain["displayName"],
|
|
573
|
+
silver_tables=silver_tables,
|
|
574
|
+
products=products,
|
|
575
|
+
)
|
|
576
|
+
)
|
|
577
|
+
dashboards = tuple(
|
|
578
|
+
Dashboard(
|
|
579
|
+
name=dashboard["name"],
|
|
580
|
+
products=tuple(dashboard["products"]),
|
|
581
|
+
)
|
|
582
|
+
for dashboard in lakehouse_document["dashboards"]
|
|
583
|
+
if isinstance(dashboard, Mapping)
|
|
584
|
+
)
|
|
585
|
+
inventory = LakehouseInventory(
|
|
586
|
+
lakehouse_root=Path(source_label) if source_label is not None else Path(lakehouse_path).parent,
|
|
587
|
+
name=lakehouse_document["name"],
|
|
588
|
+
sources=tuple(sources),
|
|
589
|
+
domains=tuple(domains),
|
|
590
|
+
dashboards=dashboards,
|
|
591
|
+
)
|
|
592
|
+
_validate_inventory_identities(inventory.lakehouse_root, inventory.sources, inventory.domains, inventory.dashboards)
|
|
593
|
+
return inventory
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def load_lakehouse_inventory(path: str | Path, *, allow_incomplete: bool = False) -> LakehouseInventory:
|
|
597
|
+
"""Load and validate ``lakehouse_code/lakehouse.yaml`` plus every source."""
|
|
598
|
+
lakehouse_root = _lakehouse_root(Path(path))
|
|
599
|
+
lakehouse_descriptor = lakehouse_root / "lakehouse.yaml"
|
|
600
|
+
if not lakehouse_descriptor.is_file():
|
|
601
|
+
raise LakehouseDescriptorError(f"{lakehouse_root}: no lakehouse descriptor found at lakehouse.yaml")
|
|
602
|
+
source_descriptors = sorted(lakehouse_root.glob("bronze/*/source.yaml"))
|
|
603
|
+
if not source_descriptors and not allow_incomplete:
|
|
604
|
+
raise LakehouseDescriptorError(f"{lakehouse_root}: no source descriptors found at bronze/*/source.yaml")
|
|
605
|
+
return load_lakehouse_inventory_from_descriptors(
|
|
606
|
+
lakehouse_descriptor, source_descriptors, source_label=lakehouse_root, allow_incomplete=allow_incomplete
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def load_transitional_lakehouse_inventory(path: str | Path) -> LakehouseInventory:
|
|
611
|
+
"""Load an `olf init --empty` project while it has no runnable product.
|
|
612
|
+
|
|
613
|
+
This is intentionally limited to scaffold planning. Deployment and
|
|
614
|
+
ordinary descriptor validation retain the strict v1alpha3 contract.
|
|
615
|
+
"""
|
|
616
|
+
return load_lakehouse_inventory(path, allow_incomplete=True)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
@cache
|
|
620
|
+
def _cached_lakehouse_inventory(resolved_path: str) -> LakehouseInventory:
|
|
621
|
+
return load_lakehouse_inventory(resolved_path)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def inventory_for(repo_root: str | Path) -> LakehouseInventory:
|
|
625
|
+
"""Load and cache the canonical lakehouse inventory for a repository root."""
|
|
626
|
+
return _cached_lakehouse_inventory(str(Path(repo_root).resolve()))
|
|
627
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
openlakeforge_domain/__init__.py
|
|
3
|
+
openlakeforge_domain/descriptors.py
|
|
4
|
+
openlakeforge_domain/inventory.py
|
|
5
|
+
openlakeforge_domain_model.egg-info/PKG-INFO
|
|
6
|
+
openlakeforge_domain_model.egg-info/SOURCES.txt
|
|
7
|
+
openlakeforge_domain_model.egg-info/dependency_links.txt
|
|
8
|
+
openlakeforge_domain_model.egg-info/requires.txt
|
|
9
|
+
openlakeforge_domain_model.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
PyYAML<7,>=6.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
openlakeforge_domain
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "openlakeforge-domain-model"
|
|
7
|
+
version = "0.2.0a1"
|
|
8
|
+
description = "Canonical provider-neutral domain model for OpenLakeForge"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = ["PyYAML>=6.0,<7"]
|
|
11
|
+
|
|
12
|
+
[tool.setuptools.packages.find]
|
|
13
|
+
include = ["openlakeforge_domain*"]
|