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.
@@ -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)