dbt-metricflow-sql 0.1.1__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.
- dbt_metricflow_sql/__init__.py +11 -0
- dbt_metricflow_sql/client.py +173 -0
- dbt_metricflow_sql/dependencies.py +56 -0
- dbt_metricflow_sql/fragment.schema.json +1165 -0
- dbt_metricflow_sql/plugin.py +452 -0
- dbt_metricflow_sql/semantics.py +440 -0
- dbt_metricflow_sql/server.py +128 -0
- dbt_metricflow_sql-0.1.1.dist-info/METADATA +20 -0
- dbt_metricflow_sql-0.1.1.dist-info/RECORD +11 -0
- dbt_metricflow_sql-0.1.1.dist-info/WHEEL +4 -0
- dbt_metricflow_sql-0.1.1.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""A dbt plugin: dbt discovers it by its `dbt_` module prefix and `plugins` list."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from dbt_metricflow_sql.plugin import MetricFlowPlugin
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
plugins = [MetricFlowPlugin]
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Metricflow HTTP client"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from email.message import Message
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_TIMEOUT_SECONDS = 10
|
|
18
|
+
# Bundles can be large, and the server reads one as it syncs it.
|
|
19
|
+
_PUBLISH_TIMEOUT_SECONDS = 300
|
|
20
|
+
# The header of a publish's response naming the catalog to query the bundle as, e.g. `env_220`.
|
|
21
|
+
CATALOG_HEADER = "X-Dbt-Catalog"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MetricFlowError(Exception):
|
|
25
|
+
"""A request to a MetricFlow server failed: it's unreachable, or it rejected the request."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
29
|
+
"""
|
|
30
|
+
Follows no redirect, so a 3xx raises `HTTPError`: urllib would otherwise send the redirect's
|
|
31
|
+
host the request's headers, e.g. `Authorization`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# For the server's requests and the upload alike.
|
|
39
|
+
_OPENER = urllib.request.build_opener(_NoRedirectHandler)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Compiled:
|
|
44
|
+
"""Warehouse SQL compiled from semantic SQL."""
|
|
45
|
+
|
|
46
|
+
sql: str
|
|
47
|
+
# The physical tables `sql` reads, by their parts, e.g. `("analytics", "main", "orders")`.
|
|
48
|
+
tables: tuple[tuple[str, ...], ...]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def compile_sql(url: str, sql: str, dialect: str) -> Compiled:
|
|
52
|
+
"""Metricflow SQL, in the warehouse's `dialect`, to warehouse SQL."""
|
|
53
|
+
response = _request(
|
|
54
|
+
url,
|
|
55
|
+
"POST",
|
|
56
|
+
"/v1/compile",
|
|
57
|
+
{"sql": sql, "input_dialect": dialect, "output_dialect": dialect},
|
|
58
|
+
).body
|
|
59
|
+
return Compiled(
|
|
60
|
+
sql=str(response["sql"]),
|
|
61
|
+
tables=tuple(tuple(str(part) for part in table) for table in response.get("tables", [])),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Published:
|
|
67
|
+
"""A bundle published to a MetricFlow server."""
|
|
68
|
+
|
|
69
|
+
name: str
|
|
70
|
+
version: int
|
|
71
|
+
# False if the server already had this bundle, as its current version.
|
|
72
|
+
changed: bool
|
|
73
|
+
# The catalog to query it as, if the server names one, e.g. `env_220`.
|
|
74
|
+
catalog: str | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def publish_bundle(
|
|
78
|
+
url: str, name: str, archive: bytes, headers: Mapping[str, str] | None = None
|
|
79
|
+
) -> Published:
|
|
80
|
+
"""
|
|
81
|
+
Publishes the zip `archive` of a bundle to the server at `url`, as the next version of
|
|
82
|
+
the bundle `name`: uploads it through the URL the server gives, then asks the server to
|
|
83
|
+
publish it. `headers`, e.g. credentials, go with each request to the server, and never
|
|
84
|
+
with the upload.
|
|
85
|
+
"""
|
|
86
|
+
timeout = _PUBLISH_TIMEOUT_SECONDS
|
|
87
|
+
upload = _request(
|
|
88
|
+
url, "POST", "/api/v1/bundles/uploads", {"size": len(archive)}, timeout, headers
|
|
89
|
+
).body
|
|
90
|
+
upload_url = str(upload["url"])
|
|
91
|
+
# Only the headers the server gives: the URL is the store's, e.g. S3's, which mustn't see
|
|
92
|
+
# the server's credentials.
|
|
93
|
+
put = urllib.request.Request(
|
|
94
|
+
upload_url, data=archive, headers=dict(upload["headers"]), method=str(upload["method"])
|
|
95
|
+
)
|
|
96
|
+
# Named by its host: the URL itself carries a signature.
|
|
97
|
+
parts = urllib.parse.urlsplit(upload_url)
|
|
98
|
+
_send(put, f"{parts.scheme}://{parts.netloc}", timeout)
|
|
99
|
+
path = f"/api/v1/bundles/{urllib.parse.quote(name, safe='')}"
|
|
100
|
+
response = _request(url, "PUT", path, {"ref": upload["ref"]}, timeout, headers)
|
|
101
|
+
result = response.body
|
|
102
|
+
return Published(
|
|
103
|
+
name=str(result["name"]),
|
|
104
|
+
version=int(result["version"]),
|
|
105
|
+
changed=bool(result["changed"]),
|
|
106
|
+
catalog=response.headers.get(CATALOG_HEADER) or None,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class _Response:
|
|
112
|
+
"""A JSON response from the server."""
|
|
113
|
+
|
|
114
|
+
body: dict[str, Any]
|
|
115
|
+
headers: Message
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _request(
|
|
119
|
+
url: str,
|
|
120
|
+
method: str,
|
|
121
|
+
path: str,
|
|
122
|
+
body: Mapping[str, Any],
|
|
123
|
+
timeout: float = _TIMEOUT_SECONDS,
|
|
124
|
+
headers: Mapping[str, str] | None = None,
|
|
125
|
+
) -> _Response:
|
|
126
|
+
parts = urllib.parse.urlsplit(url)
|
|
127
|
+
if parts.scheme not in ("http", "https") or not parts.netloc:
|
|
128
|
+
raise MetricFlowError(f"invalid URL `{url}`: it must be like http://localhost:8080")
|
|
129
|
+
request = urllib.request.Request(
|
|
130
|
+
f"{url.rstrip('/')}{path}",
|
|
131
|
+
data=json.dumps(body).encode(),
|
|
132
|
+
headers={**(headers or {}), "Content-Type": "application/json"},
|
|
133
|
+
method=method,
|
|
134
|
+
)
|
|
135
|
+
content, response_headers = _send(request, url, timeout)
|
|
136
|
+
return _Response(body=json.loads(content), headers=response_headers)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _send(request: urllib.request.Request, url: str, timeout: float) -> tuple[bytes, Message]:
|
|
140
|
+
"""
|
|
141
|
+
Sends `request`, returning the response's body and headers. A redirect is an error, naming its
|
|
142
|
+
host.
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
with _OPENER.open(request, timeout=timeout) as response:
|
|
146
|
+
content: bytes = response.read()
|
|
147
|
+
return content, response.headers
|
|
148
|
+
except urllib.error.HTTPError as e:
|
|
149
|
+
location = e.headers.get("Location")
|
|
150
|
+
if 300 <= e.code < 400 and location:
|
|
151
|
+
# Named by its host, as the upload's URL is: a `Location` can carry a signature. A
|
|
152
|
+
# relative one, e.g. `/other`, is on the request's host.
|
|
153
|
+
parts = urllib.parse.urlsplit(urllib.parse.urljoin(request.full_url, location))
|
|
154
|
+
raise MetricFlowError(
|
|
155
|
+
f"redirected ({e.code}) to {parts.scheme}://{parts.netloc}, not followed"
|
|
156
|
+
) from e
|
|
157
|
+
raise MetricFlowError(_error_message(e.read())) from e
|
|
158
|
+
# `URLError` (refused, DNS) and timeouts, which a read can raise unwrapped, are both `OSError`s.
|
|
159
|
+
except OSError as e:
|
|
160
|
+
raise MetricFlowError(f"unreachable at {url}: {e}") from e
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _error_message(content: bytes) -> str:
|
|
164
|
+
"""The message of an error response, e.g. `{"message": "..."}`, else its raw body."""
|
|
165
|
+
text = content.decode(errors="replace")
|
|
166
|
+
try:
|
|
167
|
+
body = json.loads(text)
|
|
168
|
+
except ValueError:
|
|
169
|
+
return text
|
|
170
|
+
message: Any | None = None
|
|
171
|
+
if type(body) is dict:
|
|
172
|
+
message = body.get("message", body.get("error"))
|
|
173
|
+
return text if message is None else str(message)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Maps the physical tables a compiled MetricFlow model reads to the dbt nodes that build them.
|
|
2
|
+
|
|
3
|
+
dbt can't see what a model's semantic SQL reads, e.g. that `FROM sales` reads the `orders`
|
|
4
|
+
model through the `sales` constellation; MetricFlow reports the tables, e.g.
|
|
5
|
+
`("analytics", "main", "orders")`, and their nodes become the model's dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from typing import cast
|
|
14
|
+
|
|
15
|
+
from dbt.artifacts.resources.types import NodeType
|
|
16
|
+
from dbt.contracts.graph.manifest import Manifest
|
|
17
|
+
from dbt.contracts.graph.nodes import ModelNode, SeedNode, SnapshotNode
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# The node types that build tables a query can read.
|
|
22
|
+
_RELATION_TYPES = (NodeType.Model, NodeType.Seed, NodeType.Snapshot)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RelationIndex:
|
|
26
|
+
"""The dbt nodes and sources of a manifest, by the relation each builds or names."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, manifest: Manifest) -> None:
|
|
29
|
+
# Unique ids by every suffix of the relation's parts, casefolded: warehouses differ in
|
|
30
|
+
# how they case unquoted names, and a query may leave off the database or schema.
|
|
31
|
+
self._ids: dict[tuple[str, ...], set[str]] = defaultdict(set)
|
|
32
|
+
for node in manifest.nodes.values():
|
|
33
|
+
if node.resource_type not in _RELATION_TYPES or node.config.materialized == "ephemeral":
|
|
34
|
+
continue
|
|
35
|
+
relation = cast("ModelNode | SeedNode | SnapshotNode", node)
|
|
36
|
+
self._add(relation.unique_id, (relation.database, relation.schema, relation.alias))
|
|
37
|
+
for source in manifest.sources.values():
|
|
38
|
+
self._add(source.unique_id, (source.database, source.schema, source.identifier))
|
|
39
|
+
|
|
40
|
+
def _add(self, unique_id: str, parts: tuple[str | None, ...]) -> None:
|
|
41
|
+
names = tuple(part.casefold() for part in parts if part)
|
|
42
|
+
for start in range(len(names)):
|
|
43
|
+
self._ids[names[start:]].add(unique_id)
|
|
44
|
+
|
|
45
|
+
def nodes(self, tables: Iterable[tuple[str, ...]]) -> list[str]:
|
|
46
|
+
"""
|
|
47
|
+
The unique ids of the nodes that build `tables`, in order. A table that no node builds,
|
|
48
|
+
e.g. one outside the project, or that names several, e.g. `orders` in two schemas, has
|
|
49
|
+
none.
|
|
50
|
+
"""
|
|
51
|
+
unique_ids: dict[str, None] = {}
|
|
52
|
+
for table in tables:
|
|
53
|
+
matches = self._ids.get(tuple(part.casefold() for part in table), set())
|
|
54
|
+
if len(matches) == 1:
|
|
55
|
+
unique_ids[next(iter(matches))] = None
|
|
56
|
+
return list(unique_ids)
|