fabcontext 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fabcontext/__init__.py +187 -0
- fabcontext/__main__.py +64 -0
- fabcontext/_fabric/__init__.py +20 -0
- fabcontext/_fabric/auth.py +167 -0
- fabcontext/_fabric/delta.py +136 -0
- fabcontext/_fabric/onelake.py +221 -0
- fabcontext/_fabric/patterns.py +62 -0
- fabcontext/_fabric/rest.py +175 -0
- fabcontext/_fabric/workspace.py +126 -0
- fabcontext/api.py +272 -0
- fabcontext/common.py +361 -0
- fabcontext/fetch.py +344 -0
- fabcontext/files.py +133 -0
- fabcontext/graph.py +420 -0
- fabcontext/graph_template.html +642 -0
- fabcontext/parse.py +645 -0
- fabcontext/parse_code.py +269 -0
- fabcontext/parse_model.py +271 -0
- fabcontext/parse_report.py +244 -0
- fabcontext/profiling.py +277 -0
- fabcontext/publish.py +83 -0
- fabcontext/schema.sql +110 -0
- fabcontext/viz.py +102 -0
- fabcontext/wiki.py +695 -0
- fabcontext-0.1.0.dist-info/METADATA +63 -0
- fabcontext-0.1.0.dist-info/RECORD +29 -0
- fabcontext-0.1.0.dist-info/WHEEL +5 -0
- fabcontext-0.1.0.dist-info/licenses/LICENSE +21 -0
- fabcontext-0.1.0.dist-info/top_level.txt +1 -0
fabcontext/__init__.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""One call: name a workspace, get back a lakehouse holding the ranked context of it.
|
|
2
|
+
|
|
3
|
+
%pip install fabcontext
|
|
4
|
+
from fabcontext import harvest
|
|
5
|
+
url = harvest("My Workspace")
|
|
6
|
+
|
|
7
|
+
The first call creates the lakehouse; every later call updates it. Nothing else to configure
|
|
8
|
+
and nothing kept on the machine that ran it - the lakehouse is the only state, and the URL it
|
|
9
|
+
returns is the whole contract with whatever answers questions from it later.
|
|
10
|
+
|
|
11
|
+
It is built for the Fabric Python runtime and asks for nothing that runtime does not already
|
|
12
|
+
have, so the install replaces no native library and needs no kernel restart.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import tempfile
|
|
18
|
+
import time
|
|
19
|
+
from typing import Dict, List, Optional, Sequence, Union
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = ["harvest", "build_and_publish", "open_context", "__version__"]
|
|
24
|
+
|
|
25
|
+
DEFAULT_LAKEHOUSE = "context_layer"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _steps(log):
|
|
29
|
+
"""A per-step timing table. A step that dies leaves the ones before it published, and the
|
|
30
|
+
table at the end says which failed and why - a half-built context that reports honestly
|
|
31
|
+
beats one that vanishes with a traceback."""
|
|
32
|
+
rows = []
|
|
33
|
+
|
|
34
|
+
def step(name, fn, *args, **kwargs):
|
|
35
|
+
start = time.time()
|
|
36
|
+
try:
|
|
37
|
+
out = fn(*args, **kwargs)
|
|
38
|
+
except Exception as exc: # noqa: BLE001 - recorded, then re-raised
|
|
39
|
+
rows.append((name, round(time.time() - start, 1), "FAILED"))
|
|
40
|
+
log("[FAILED] " + name + " " + exc.__class__.__name__ + ": " + str(exc)[:400])
|
|
41
|
+
raise
|
|
42
|
+
rows.append((name, round(time.time() - start, 1), "ok"))
|
|
43
|
+
log("[ok] " + name + " " + str(round(time.time() - start, 1)) + "s "
|
|
44
|
+
+ str(out)[:200])
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
return step, rows
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _work_dirs(work: str):
|
|
51
|
+
raw = os.path.join(work, "raw")
|
|
52
|
+
build = os.path.join(work, "build")
|
|
53
|
+
wiki_dir = os.path.join(work, "wiki")
|
|
54
|
+
for folder in (raw, build, wiki_dir):
|
|
55
|
+
os.makedirs(folder, exist_ok=True)
|
|
56
|
+
return raw, build, wiki_dir, os.path.join(work, "graph.html")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _target(to: Optional[str], first_workspace: str):
|
|
60
|
+
"""(workspace, lakehouse) for `to`, or the default: a lakehouse called `context_layer` in
|
|
61
|
+
the first workspace named, so the context lives beside what it describes."""
|
|
62
|
+
if not to:
|
|
63
|
+
return first_workspace, DEFAULT_LAKEHOUSE
|
|
64
|
+
workspace, _sep, lakehouse = to.partition("/")
|
|
65
|
+
if not workspace or not lakehouse:
|
|
66
|
+
raise ValueError("`to` is <workspace>/<lakehouse>; got " + repr(to))
|
|
67
|
+
return workspace, lakehouse
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_and_publish(work: str, store, *, profile: bool = True, values: bool = True,
|
|
71
|
+
wiki: bool = True, aliases: Optional[Dict[str, List[str]]] = None,
|
|
72
|
+
push: bool = True, log=print) -> Dict:
|
|
73
|
+
"""Everything downstream of the fetch: parse `work/raw`, rank it, publish, render, push.
|
|
74
|
+
|
|
75
|
+
Split out from `harvest` because it needs no tenant - a folder of harvested JSON and a
|
|
76
|
+
local store are enough to run the whole second half, which is how the offline suite
|
|
77
|
+
exercises the real publish rather than a mock.
|
|
78
|
+
"""
|
|
79
|
+
from . import files, graph, parse, profiling, publish
|
|
80
|
+
|
|
81
|
+
step, rows = _steps(log)
|
|
82
|
+
raw, build, wiki_dir, graph_html = _work_dirs(work)
|
|
83
|
+
|
|
84
|
+
def _build():
|
|
85
|
+
parse.build(raw, build)
|
|
86
|
+
return graph.build(build, aliases)
|
|
87
|
+
|
|
88
|
+
con, counts = step("build", _build)
|
|
89
|
+
try:
|
|
90
|
+
if profile:
|
|
91
|
+
step("profile", profiling.run, con, raw, values=values)
|
|
92
|
+
con.close()
|
|
93
|
+
con, counts = step("build (with profiles)", _build)
|
|
94
|
+
published = step("publish", publish.publish, con, store)
|
|
95
|
+
if wiki:
|
|
96
|
+
from . import viz
|
|
97
|
+
from . import wiki as wiki_mod
|
|
98
|
+
step("wiki", wiki_mod.render, con, wiki_dir)
|
|
99
|
+
step("graph.html", viz.render, con, graph_html)
|
|
100
|
+
finally:
|
|
101
|
+
con.close()
|
|
102
|
+
if push:
|
|
103
|
+
step("push files", files.push, store, work, files.ITEMS, False, lambda _m: None)
|
|
104
|
+
return {"counts": counts, "tables": published, "steps": rows}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def harvest(workspaces: Union[str, Sequence[str]], to: Optional[str] = None, *,
|
|
108
|
+
days: int = 28, query_log: bool = False, refresh: bool = False,
|
|
109
|
+
profile: bool = True, values: bool = True, wiki: bool = True,
|
|
110
|
+
folder: Optional[str] = "context", stale_after_days: float = 1.0,
|
|
111
|
+
aliases: Optional[Dict[str, List[str]]] = None, work: Optional[str] = None,
|
|
112
|
+
log=print) -> str:
|
|
113
|
+
"""Harvest `workspaces` into a lakehouse and return its URL.
|
|
114
|
+
|
|
115
|
+
`workspaces` is a name, a GUID, or a list of either. `to` names the lakehouse to publish
|
|
116
|
+
into as `<workspace>/<lakehouse>`; by default it is `context_layer` in the first workspace
|
|
117
|
+
given. `aliases` merges spellings the word lists cannot, as {term_id: [spelling, ...]}.
|
|
118
|
+
|
|
119
|
+
**Create or update.** The lakehouse is made if it is not there and reused if it is, and an
|
|
120
|
+
existing one has its previous `Files/raw` pulled down first - which is what makes a second
|
|
121
|
+
run incremental rather than a full re-read of the tenant.
|
|
122
|
+
|
|
123
|
+
`query_log` additionally reads each workspace's monitoring Eventhouse for the DAX that
|
|
124
|
+
actually ran, the one signal that says a measure was evaluated rather than merely written
|
|
125
|
+
into a report. Off by default: monitoring bills against the capacity, and a workspace
|
|
126
|
+
without it is skipped.
|
|
127
|
+
|
|
128
|
+
Two levels of permission, and it says which it got rather than failing: **contributor** on
|
|
129
|
+
the target workspace to create the lakehouse, and **Fabric admin** for the Scanner API and
|
|
130
|
+
the audit log. Without admin the graph still builds, but loses endorsement, cross-workspace
|
|
131
|
+
lineage and usage.
|
|
132
|
+
"""
|
|
133
|
+
from . import common, fetch, files, publish
|
|
134
|
+
|
|
135
|
+
names = [workspaces] if isinstance(workspaces, str) else list(workspaces)
|
|
136
|
+
if not names:
|
|
137
|
+
raise ValueError("name at least one workspace to harvest")
|
|
138
|
+
workspace, lakehouse = _target(to, names[0])
|
|
139
|
+
common.set_aliases(aliases)
|
|
140
|
+
|
|
141
|
+
step, _rows = _steps(log)
|
|
142
|
+
store, created = step("open lakehouse", publish.open_lakehouse,
|
|
143
|
+
workspace, lakehouse, folder)
|
|
144
|
+
|
|
145
|
+
own_work = work is None
|
|
146
|
+
work = work or tempfile.mkdtemp(prefix="fabcontext_")
|
|
147
|
+
raw, _build, _wiki, _html = _work_dirs(work)
|
|
148
|
+
try:
|
|
149
|
+
if not created:
|
|
150
|
+
# The previous harvest. Its absence is not an error - a lakehouse someone made by
|
|
151
|
+
# hand, or a run that died before its first push, simply starts from scratch.
|
|
152
|
+
step("stage previous raw/", files.pull, store, work, ["raw"], lambda _m: None)
|
|
153
|
+
step("harvest", fetch.harvest, raw, names, days, refresh, True, True,
|
|
154
|
+
query_log, stale_after_days)
|
|
155
|
+
out = build_and_publish(work, store, profile=profile, values=values, wiki=wiki,
|
|
156
|
+
aliases=aliases, log=log)
|
|
157
|
+
finally:
|
|
158
|
+
if own_work:
|
|
159
|
+
import shutil
|
|
160
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
161
|
+
|
|
162
|
+
log("")
|
|
163
|
+
log("published " + str(sum(out["tables"].values())) + " rows across "
|
|
164
|
+
+ str(len(out["tables"])) + " tables")
|
|
165
|
+
log(store.tables_root)
|
|
166
|
+
return store.tables_root
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def open_context(url: str, storage_options: Optional[Dict[str, str]] = None):
|
|
170
|
+
"""A read-only DuckDB connection over a published context.
|
|
171
|
+
|
|
172
|
+
`url` is what `harvest` returned. This is the harvest side reading back what it wrote -
|
|
173
|
+
for looking at a context without rebuilding it.
|
|
174
|
+
"""
|
|
175
|
+
from ._fabric import auth, onelake
|
|
176
|
+
from . import graph
|
|
177
|
+
|
|
178
|
+
class _Store:
|
|
179
|
+
tables_root = url
|
|
180
|
+
storage_options = None
|
|
181
|
+
|
|
182
|
+
store = _Store()
|
|
183
|
+
if storage_options is not None:
|
|
184
|
+
store.storage_options = storage_options
|
|
185
|
+
elif url.startswith("abfss://"):
|
|
186
|
+
store.storage_options = onelake.storage_options(auth.onelake_token())
|
|
187
|
+
return graph.read_published(store)
|
fabcontext/__main__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""`python -m fabcontext "<workspace>"` - the one call, from a command line.
|
|
2
|
+
|
|
3
|
+
The package does one thing, so this does one thing. It exists for a terminal session and for
|
|
4
|
+
CI; inside a notebook, call `harvest()` directly.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from . import __doc__ as _doc
|
|
12
|
+
from . import __version__, harvest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv=None) -> int:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="python -m fabcontext", description=_doc,
|
|
18
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
19
|
+
parser.add_argument("workspace", nargs="+",
|
|
20
|
+
help="workspace name or GUID; repeat for more")
|
|
21
|
+
parser.add_argument("--to", default=None,
|
|
22
|
+
help="the lakehouse to publish into, <workspace>/<lakehouse>. "
|
|
23
|
+
"Default: context_layer in the first workspace named")
|
|
24
|
+
parser.add_argument("--days", type=int, default=28,
|
|
25
|
+
help="days of activity events to read (the audit log keeps 30)")
|
|
26
|
+
parser.add_argument("--query-log", action="store_true",
|
|
27
|
+
help="also read each workspace's monitoring Eventhouse for the DAX "
|
|
28
|
+
"that actually ran. Needs monitoring enabled there, which bills "
|
|
29
|
+
"against the capacity; a workspace without it is skipped")
|
|
30
|
+
parser.add_argument("--refresh", action="store_true",
|
|
31
|
+
help="refetch everything instead of only what changed")
|
|
32
|
+
parser.add_argument("--stale-after-days", type=float, default=1.0,
|
|
33
|
+
help="refetch the scanner and the store table lists once older than "
|
|
34
|
+
"this; they carry no freshness signal of their own")
|
|
35
|
+
parser.add_argument("--no-profile", action="store_true",
|
|
36
|
+
help="skip reading lakehouse columns, stats and values")
|
|
37
|
+
parser.add_argument("--no-values", action="store_true",
|
|
38
|
+
help="profile from the Delta log only, without the distinct-value scan")
|
|
39
|
+
parser.add_argument("--no-wiki", action="store_true",
|
|
40
|
+
help="skip the markdown wiki and graph.html")
|
|
41
|
+
parser.add_argument("--folder", default="context",
|
|
42
|
+
help="workspace folder to put the lakehouse in; empty for the root")
|
|
43
|
+
parser.add_argument("--work", default=None,
|
|
44
|
+
help="keep the working files here instead of a temporary folder")
|
|
45
|
+
parser.add_argument("--version", action="version", version="fabcontext " + __version__)
|
|
46
|
+
args = parser.parse_args(argv)
|
|
47
|
+
|
|
48
|
+
# DuckDB prints tables with box-drawing characters and Fabric names are not all ASCII;
|
|
49
|
+
# a stock Windows console is cp1252 and would raise on both.
|
|
50
|
+
for stream in (sys.stdout, sys.stderr):
|
|
51
|
+
try:
|
|
52
|
+
stream.reconfigure(errors="replace")
|
|
53
|
+
except (AttributeError, ValueError):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
harvest(args.workspace, to=args.to, days=args.days, query_log=args.query_log,
|
|
57
|
+
refresh=args.refresh, profile=not args.no_profile, values=not args.no_values,
|
|
58
|
+
wiki=not args.no_wiki, folder=args.folder or None,
|
|
59
|
+
stale_after_days=args.stale_after_days, work=args.work)
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Everything that talks to Fabric, and nothing that understands the tenant.
|
|
2
|
+
|
|
3
|
+
The split is deliberate: above this package the code is about semantic models, terms and
|
|
4
|
+
ranking; inside it, about tokens, retries, Delta files and REST pagination. The harvest used
|
|
5
|
+
to get all of this from a third-party library that pins duckdb and deltalake to versions the
|
|
6
|
+
Fabric Python runtime does not ship - which forced a pip upgrade of two native libraries and
|
|
7
|
+
a kernel restart before anything could import them.
|
|
8
|
+
|
|
9
|
+
So it is vendored here instead, sized to exactly what the harvest does, against the versions
|
|
10
|
+
Fabric already has. Nothing in this package imports that library, and nothing should.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from . import auth, delta, onelake, patterns, rest
|
|
15
|
+
from .onelake import LocalStore, OneLakeStore
|
|
16
|
+
from .rest import FabricError
|
|
17
|
+
from .workspace import Workspace
|
|
18
|
+
|
|
19
|
+
__all__ = ["auth", "delta", "onelake", "patterns", "rest",
|
|
20
|
+
"LocalStore", "OneLakeStore", "FabricError", "Workspace"]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Bearer tokens, one function per audience.
|
|
2
|
+
|
|
3
|
+
Five audiences are in play and they are genuinely different: OneLake storage, the Fabric
|
|
4
|
+
control plane, the Power BI REST API, an Eventhouse cluster, and the SQL endpoint. A token
|
|
5
|
+
for one 401s on another, so nothing here is interchangeable.
|
|
6
|
+
|
|
7
|
+
Acquisition order, cheapest first:
|
|
8
|
+
|
|
9
|
+
1. **inside a Fabric notebook** - `notebookutils.credentials.getToken`, which is the only
|
|
10
|
+
path that matters in production and needs no sign-in at all;
|
|
11
|
+
2. an already-minted token in the environment;
|
|
12
|
+
3. **azure-identity** - Azure CLI, then an interactive browser but only on a TTY, so a
|
|
13
|
+
headless run can never hang waiting for a redirect that will not come.
|
|
14
|
+
|
|
15
|
+
Tokens are cached per (tenant, scope) and re-acquired near expiry, read out of the JWT
|
|
16
|
+
itself. A failed re-acquire keeps the token it has rather than raising over one that is
|
|
17
|
+
merely inside the refresh margin.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from typing import Callable, Dict, Optional
|
|
28
|
+
|
|
29
|
+
STORAGE_SCOPE = "https://storage.azure.com/.default"
|
|
30
|
+
FABRIC_SCOPE = "https://api.fabric.microsoft.com/.default"
|
|
31
|
+
POWERBI_SCOPE = "https://analysis.windows.net/powerbi/api/.default"
|
|
32
|
+
SQL_SCOPE = "https://database.windows.net/.default"
|
|
33
|
+
|
|
34
|
+
# Env vars honoured per scope, so a CI run can inject a token instead of signing in.
|
|
35
|
+
_ENV = {STORAGE_SCOPE: "AZURE_STORAGE_TOKEN", FABRIC_SCOPE: "FABRIC_TOKEN",
|
|
36
|
+
POWERBI_SCOPE: "POWERBI_TOKEN", SQL_SCOPE: "SQL_TOKEN"}
|
|
37
|
+
|
|
38
|
+
_CACHE: Dict[tuple, str] = {}
|
|
39
|
+
_LOCK = threading.RLock()
|
|
40
|
+
_EXPIRY: Dict[str, Optional[float]] = {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _notebook_token(audience: str) -> Optional[str]:
|
|
44
|
+
"""A token from the Fabric notebook runtime, or None when not running in one."""
|
|
45
|
+
try:
|
|
46
|
+
import notebookutils # noqa: F401 - Fabric runtime only
|
|
47
|
+
except ImportError:
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
return notebookutils.credentials.getToken(audience) or None
|
|
51
|
+
except Exception: # noqa: BLE001 - audience may be unknown
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def azure_identity_token(scope: str, interactive: bool = True) -> Optional[str]:
|
|
56
|
+
"""A token for `scope` through azure-identity, or None when it cannot be had.
|
|
57
|
+
|
|
58
|
+
The browser credential is appended only on a TTY: on a headless runner it would try to
|
|
59
|
+
open a browser and block on a local redirect listener, which reads as a hang rather than
|
|
60
|
+
a failure.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
from azure.identity import AzureCliCredential
|
|
64
|
+
except ImportError:
|
|
65
|
+
return None
|
|
66
|
+
chain = [AzureCliCredential]
|
|
67
|
+
if interactive and sys.stdin is not None and sys.stdin.isatty():
|
|
68
|
+
try:
|
|
69
|
+
from azure.identity import InteractiveBrowserCredential
|
|
70
|
+
chain.append(InteractiveBrowserCredential)
|
|
71
|
+
except ImportError:
|
|
72
|
+
pass
|
|
73
|
+
debug = bool(os.environ.get("FABCONTEXT_AUTH_DEBUG"))
|
|
74
|
+
for credential in chain:
|
|
75
|
+
try:
|
|
76
|
+
return credential().get_token(scope).token
|
|
77
|
+
except Exception as exc: # noqa: BLE001 - try the next credential
|
|
78
|
+
if debug:
|
|
79
|
+
print("[auth] " + credential.__name__ + " failed for " + scope + ": "
|
|
80
|
+
+ repr(exc), flush=True)
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _expiry(token: str) -> Optional[float]:
|
|
85
|
+
"""The `exp` of a JWT, or None when it is not a decodable one. No signature check - the
|
|
86
|
+
only question is when to refresh."""
|
|
87
|
+
if token in _EXPIRY:
|
|
88
|
+
return _EXPIRY[token]
|
|
89
|
+
try:
|
|
90
|
+
seg = token.split(".")[1]
|
|
91
|
+
seg += "=" * (-len(seg) % 4) # restore base64url padding
|
|
92
|
+
exp = float(json.loads(base64.urlsafe_b64decode(seg.encode())).get("exp"))
|
|
93
|
+
except Exception: # noqa: BLE001 - not a JWT
|
|
94
|
+
exp = None
|
|
95
|
+
with _LOCK:
|
|
96
|
+
if len(_EXPIRY) >= 16: # a session holds a handful of tokens
|
|
97
|
+
_EXPIRY.clear()
|
|
98
|
+
_EXPIRY[token] = exp
|
|
99
|
+
return exp
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_expiring(token: Optional[str], margin_seconds: int = 600) -> bool:
|
|
103
|
+
"""Whether `token` is a JWT within `margin_seconds` of expiry. A token whose expiry
|
|
104
|
+
cannot be read is left alone rather than churned."""
|
|
105
|
+
if not token:
|
|
106
|
+
return False
|
|
107
|
+
exp = _expiry(token)
|
|
108
|
+
return exp is not None and time.time() >= exp - margin_seconds
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _cached(scope: str, acquire: Callable[[], Optional[str]]) -> Optional[str]:
|
|
112
|
+
key = (os.environ.get("AZURE_TENANT_ID") or "", scope)
|
|
113
|
+
with _LOCK:
|
|
114
|
+
held = _CACHE.get(key)
|
|
115
|
+
if held and not is_expiring(held):
|
|
116
|
+
return held
|
|
117
|
+
token = acquire()
|
|
118
|
+
if token:
|
|
119
|
+
_CACHE[key] = token
|
|
120
|
+
# A blip re-acquiring must not discard a token that is merely inside the margin: it
|
|
121
|
+
# is still valid, and raising while holding a working token helps nobody.
|
|
122
|
+
return token or held
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _token(scope: str, audience: str, hint: str) -> str:
|
|
126
|
+
token = _cached(scope, lambda: (_notebook_token(audience)
|
|
127
|
+
or os.environ.get(_ENV.get(scope, ""))
|
|
128
|
+
or azure_identity_token(scope)))
|
|
129
|
+
if token:
|
|
130
|
+
return token
|
|
131
|
+
raise RuntimeError(
|
|
132
|
+
"could not acquire a token for " + hint + ". Inside a Fabric notebook this is "
|
|
133
|
+
"automatic; elsewhere run `az login --scope " + scope + "`"
|
|
134
|
+
+ (", or set " + _ENV[scope] if scope in _ENV else ""))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def onelake_token() -> str:
|
|
138
|
+
"""OneLake storage. Goes into delta-rs `storage_options` and the DFS credential."""
|
|
139
|
+
return _token(STORAGE_SCOPE, "storage", "OneLake storage")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def fabric_token() -> str:
|
|
143
|
+
"""The Fabric control plane. In a notebook the `pbi` audience covers it."""
|
|
144
|
+
return _token(FABRIC_SCOPE, "pbi", "the Fabric API")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def powerbi_token() -> str:
|
|
148
|
+
"""The Power BI REST API - the scanner and the audit log."""
|
|
149
|
+
return _token(POWERBI_SCOPE, "pbi", "the Power BI API")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def sql_token() -> str:
|
|
153
|
+
"""A SQL analytics endpoint, over TDS."""
|
|
154
|
+
return _token(SQL_SCOPE, "pbi", "the SQL endpoint")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def kusto_token(cluster_uri: str) -> str:
|
|
158
|
+
"""An Eventhouse query endpoint. The audience is the cluster itself, so this one cannot
|
|
159
|
+
be cached against a fixed scope like the others."""
|
|
160
|
+
token = _notebook_token(cluster_uri)
|
|
161
|
+
if token:
|
|
162
|
+
return token
|
|
163
|
+
scope = cluster_uri.rstrip("/") + "/.default"
|
|
164
|
+
token = _cached(scope, lambda: azure_identity_token(scope))
|
|
165
|
+
if token:
|
|
166
|
+
return token
|
|
167
|
+
raise RuntimeError("no token for " + cluster_uri + "; run `az login --scope " + scope + "`")
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Delta Lake in and out, through delta-rs alone.
|
|
2
|
+
|
|
3
|
+
Three things worth knowing about why it is shaped this way:
|
|
4
|
+
|
|
5
|
+
- **Writing streams.** `write_deltalake` accepts anything exporting an Arrow C stream, and a
|
|
6
|
+
DuckDB relation is one, so a table goes from the in-memory graph to Delta without ever
|
|
7
|
+
being materialised as an Arrow table in between.
|
|
8
|
+
- **Reading uses DataFusion, not `delta_scan`.** DuckDB's `delta` and `azure` extensions are
|
|
9
|
+
not bundled with the Fabric runtime, so `delta_scan` would fetch them over the network in
|
|
10
|
+
every notebook session. `QueryBuilder` ships inside the deltalake wheel and hands back a
|
|
11
|
+
`RecordBatchReader`, which DuckDB consumes through the same Arrow capsule. No extension,
|
|
12
|
+
no download, no pyarrow.
|
|
13
|
+
- **Opening logs is latency, not work.** Replaying a Delta log over OneLake is a handful of
|
|
14
|
+
round trips, so a batch of tables is opened concurrently and the results stay positional.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
20
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
21
|
+
|
|
22
|
+
# Opening one Delta log is pure network wait; more threads than this buys nothing and starts
|
|
23
|
+
# to look like a burst to OneLake.
|
|
24
|
+
MAX_OPEN_WORKERS = 16
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def table_url(root: str, schema: str, table: str) -> str:
|
|
28
|
+
"""Where one table lives under a Tables root. A local root stays a local path so the
|
|
29
|
+
offline test exercises the real write, not a mock."""
|
|
30
|
+
if "://" in root:
|
|
31
|
+
return root.rstrip("/") + "/" + schema + "/" + table
|
|
32
|
+
return os.path.join(root, schema, table)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def write_table(url: str, source: Any, storage_options: Optional[Dict[str, str]] = None) -> None:
|
|
36
|
+
"""Replace whatever is at `url` with `source`, schema and all.
|
|
37
|
+
|
|
38
|
+
`source` is normally a DuckDB relation. `schema_mode="overwrite"` matters: the published
|
|
39
|
+
tables gain columns as the graph gains attributes, and without it a new column would fail
|
|
40
|
+
against the previous version's schema.
|
|
41
|
+
"""
|
|
42
|
+
from deltalake import write_deltalake
|
|
43
|
+
|
|
44
|
+
write_deltalake(url, source, mode="overwrite", schema_mode="overwrite",
|
|
45
|
+
storage_options=storage_options or None)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def open_table(url: str, storage_options: Optional[Dict[str, str]] = None):
|
|
49
|
+
"""The `DeltaTable` at `url`, or None when there is no Delta log there. A missing table is
|
|
50
|
+
an ordinary outcome when probing for one, not an error."""
|
|
51
|
+
from deltalake import DeltaTable
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
return DeltaTable(url, storage_options=storage_options or None)
|
|
55
|
+
except Exception: # noqa: BLE001 - absent or unreadable
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def open_tables(targets: Sequence[Tuple[str, Optional[Dict[str, str]]]]) -> List[Optional[Any]]:
|
|
60
|
+
"""One `DeltaTable` (or None) per target, opened concurrently.
|
|
61
|
+
|
|
62
|
+
The result is **positional**: callers index it against the input to retry a different URL
|
|
63
|
+
shape for the ones that came back None.
|
|
64
|
+
"""
|
|
65
|
+
if not targets:
|
|
66
|
+
return []
|
|
67
|
+
workers = min(MAX_OPEN_WORKERS, len(targets))
|
|
68
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
69
|
+
return list(pool.map(lambda t: open_table(t[0], t[1]), targets))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def query(dt, sql: str, name: str = "t"):
|
|
73
|
+
"""Run `sql` against one Delta table and return a `RecordBatchReader`.
|
|
74
|
+
|
|
75
|
+
The dialect is DataFusion's, not DuckDB's - `approx_distinct`, not
|
|
76
|
+
`approx_count_distinct`.
|
|
77
|
+
"""
|
|
78
|
+
from deltalake import QueryBuilder
|
|
79
|
+
|
|
80
|
+
return QueryBuilder().register(name, dt).execute(sql)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def query_rows(dt, sql: str, name: str = "t") -> List[tuple]:
|
|
84
|
+
"""`query`, materialised as a list of tuples. For the small results - a distinct-value
|
|
85
|
+
listing, a row of cardinality estimates - where streaming buys nothing."""
|
|
86
|
+
import duckdb
|
|
87
|
+
|
|
88
|
+
con = duckdb.connect()
|
|
89
|
+
try:
|
|
90
|
+
con.register("_q", query(dt, sql, name))
|
|
91
|
+
return con.execute("SELECT * FROM _q").fetchall()
|
|
92
|
+
finally:
|
|
93
|
+
con.close()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_into(con, name: str, url: str,
|
|
97
|
+
storage_options: Optional[Dict[str, str]] = None) -> bool:
|
|
98
|
+
"""Copy the Delta table at `url` into `con` as `name`. False when it is not there, so an
|
|
99
|
+
optional table simply does not arrive rather than failing the read."""
|
|
100
|
+
dt = open_table(url, storage_options)
|
|
101
|
+
if dt is None:
|
|
102
|
+
return False
|
|
103
|
+
# Registered by name rather than left to DuckDB's replacement scan, which would resolve
|
|
104
|
+
# the reader out of this frame's locals - true, but not something to rely on.
|
|
105
|
+
handle = "_reader_" + name
|
|
106
|
+
con.register(handle, query(dt, "SELECT * FROM t"))
|
|
107
|
+
try:
|
|
108
|
+
con.execute("CREATE OR REPLACE TABLE " + name + " AS SELECT * FROM " + handle)
|
|
109
|
+
finally:
|
|
110
|
+
con.unregister(handle)
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def arrow_fields(dt) -> List[Any]:
|
|
115
|
+
"""A table's columns as Arrow fields, whichever spelling this deltalake version has. The
|
|
116
|
+
probe is deliberate: this is the one place the versions disagree."""
|
|
117
|
+
schema = dt.schema()
|
|
118
|
+
for attr in ("to_arrow", "to_pyarrow"):
|
|
119
|
+
if hasattr(schema, attr):
|
|
120
|
+
return list(getattr(schema, attr)())
|
|
121
|
+
return [f for f in schema.fields] # delta-rs fields carry .name and .type
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def add_actions(dt):
|
|
125
|
+
"""The Delta log's add actions as something DuckDB can register.
|
|
126
|
+
|
|
127
|
+
`get_add_actions` hands back an arro3 `RecordBatch`, which exports an Arrow C *array* but
|
|
128
|
+
not a *stream*, and DuckDB only accepts the latter. Wrapping it in a one-batch table is
|
|
129
|
+
the whole fix.
|
|
130
|
+
"""
|
|
131
|
+
adds = dt.get_add_actions(flatten=True)
|
|
132
|
+
if hasattr(adds, "__arrow_c_stream__"):
|
|
133
|
+
return adds
|
|
134
|
+
from arro3.core import Table
|
|
135
|
+
|
|
136
|
+
return Table.from_batches([adds])
|