fabcontext 0.1.0__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.
- fabcontext-0.1.0/LICENSE +21 -0
- fabcontext-0.1.0/PKG-INFO +63 -0
- fabcontext-0.1.0/README.md +36 -0
- fabcontext-0.1.0/fabcontext/__init__.py +187 -0
- fabcontext-0.1.0/fabcontext/__main__.py +64 -0
- fabcontext-0.1.0/fabcontext/_fabric/__init__.py +20 -0
- fabcontext-0.1.0/fabcontext/_fabric/auth.py +167 -0
- fabcontext-0.1.0/fabcontext/_fabric/delta.py +136 -0
- fabcontext-0.1.0/fabcontext/_fabric/onelake.py +221 -0
- fabcontext-0.1.0/fabcontext/_fabric/patterns.py +62 -0
- fabcontext-0.1.0/fabcontext/_fabric/rest.py +175 -0
- fabcontext-0.1.0/fabcontext/_fabric/workspace.py +126 -0
- fabcontext-0.1.0/fabcontext/api.py +272 -0
- fabcontext-0.1.0/fabcontext/common.py +361 -0
- fabcontext-0.1.0/fabcontext/fetch.py +344 -0
- fabcontext-0.1.0/fabcontext/files.py +133 -0
- fabcontext-0.1.0/fabcontext/graph.py +420 -0
- fabcontext-0.1.0/fabcontext/graph_template.html +642 -0
- fabcontext-0.1.0/fabcontext/parse.py +645 -0
- fabcontext-0.1.0/fabcontext/parse_code.py +269 -0
- fabcontext-0.1.0/fabcontext/parse_model.py +271 -0
- fabcontext-0.1.0/fabcontext/parse_report.py +244 -0
- fabcontext-0.1.0/fabcontext/profiling.py +277 -0
- fabcontext-0.1.0/fabcontext/publish.py +83 -0
- fabcontext-0.1.0/fabcontext/schema.sql +110 -0
- fabcontext-0.1.0/fabcontext/viz.py +102 -0
- fabcontext-0.1.0/fabcontext/wiki.py +695 -0
- fabcontext-0.1.0/fabcontext.egg-info/PKG-INFO +63 -0
- fabcontext-0.1.0/fabcontext.egg-info/SOURCES.txt +35 -0
- fabcontext-0.1.0/fabcontext.egg-info/dependency_links.txt +1 -0
- fabcontext-0.1.0/fabcontext.egg-info/requires.txt +8 -0
- fabcontext-0.1.0/fabcontext.egg-info/top_level.txt +1 -0
- fabcontext-0.1.0/pyproject.toml +57 -0
- fabcontext-0.1.0/setup.cfg +4 -0
- fabcontext-0.1.0/tests/test_harvest.py +235 -0
- fabcontext-0.1.0/tests/test_no_duckrun.py +52 -0
- fabcontext-0.1.0/tests/test_publish.py +178 -0
fabcontext-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mimoune Djouallah
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fabcontext
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Harvest a Microsoft Fabric tenant into a ranked context layer, published as Delta tables in a lakehouse.
|
|
5
|
+
Author: mim
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/djouallah/fabric-context-layer
|
|
8
|
+
Project-URL: Repository, https://github.com/djouallah/fabric-context-layer
|
|
9
|
+
Project-URL: Issues, https://github.com/djouallah/fabric-context-layer/issues
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Topic :: Database
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: duckdb>=1.4
|
|
20
|
+
Requires-Dist: deltalake>=1.2
|
|
21
|
+
Requires-Dist: requests>=2
|
|
22
|
+
Requires-Dist: azure-identity>=1.25
|
|
23
|
+
Requires-Dist: azure-storage-file-datalake>=12.14
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# context-layer
|
|
29
|
+
|
|
30
|
+
There is a lot of talk about context layers, and the best way to learn one is to build
|
|
31
|
+
one. This is a toy, but the core ideas turned out to be simple.
|
|
32
|
+
|
|
33
|
+
- A data platform is full of signals: query history, who opens what, what is certified,
|
|
34
|
+
what is refreshed. That is enough to rank definitions.
|
|
35
|
+
- Nobody will maintain a knowledge graph by hand, and the ones that exist drift. The
|
|
36
|
+
graph has to build itself.
|
|
37
|
+
- A stateless agent is attractive: the ranking happens before the question, so the model
|
|
38
|
+
behind the agent can change without losing anything.
|
|
39
|
+
- A context layer is there to be useful, not to hold the ultimate truth.
|
|
40
|
+
|
|
41
|
+
The bet is that a context built automatically, at least to start, and ranked on how the
|
|
42
|
+
platform already behaves gets better as the platform is used. I do not know yet whether
|
|
43
|
+
that holds. What I do like is that it uses the semantic models already there and asks for
|
|
44
|
+
no new modelling.
|
|
45
|
+
|
|
46
|
+
So far the industry splits on who settles a conflict: the platform, by scoring it,
|
|
47
|
+
which scales with usage, or a person, by reviewing it, which scales with reviewer time; I
|
|
48
|
+
prefer the first, time will tell.
|
|
49
|
+
|
|
50
|
+
The harvest delivers one thing: a knowledge graph of the tenant - every term, its
|
|
51
|
+
competing definitions ranked, and what feeds what. That graph is the context. It runs
|
|
52
|
+
nightly on its own, an agent asks whenever, and the two meet at the graph without ever
|
|
53
|
+
calling each other.
|
|
54
|
+
|
|
55
|
+
**[See it on a real tenant](https://djouallah.github.io/fabric-context-layer/)**
|
|
56
|
+
|
|
57
|
+

|
|
58
|
+
|
|
59
|
+
Installing it, the ranking, what is harvested, the schema, the limits: **[run.md](run.md)**.
|
|
60
|
+
|
|
61
|
+
## Licence
|
|
62
|
+
|
|
63
|
+
MIT - see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# context-layer
|
|
2
|
+
|
|
3
|
+
There is a lot of talk about context layers, and the best way to learn one is to build
|
|
4
|
+
one. This is a toy, but the core ideas turned out to be simple.
|
|
5
|
+
|
|
6
|
+
- A data platform is full of signals: query history, who opens what, what is certified,
|
|
7
|
+
what is refreshed. That is enough to rank definitions.
|
|
8
|
+
- Nobody will maintain a knowledge graph by hand, and the ones that exist drift. The
|
|
9
|
+
graph has to build itself.
|
|
10
|
+
- A stateless agent is attractive: the ranking happens before the question, so the model
|
|
11
|
+
behind the agent can change without losing anything.
|
|
12
|
+
- A context layer is there to be useful, not to hold the ultimate truth.
|
|
13
|
+
|
|
14
|
+
The bet is that a context built automatically, at least to start, and ranked on how the
|
|
15
|
+
platform already behaves gets better as the platform is used. I do not know yet whether
|
|
16
|
+
that holds. What I do like is that it uses the semantic models already there and asks for
|
|
17
|
+
no new modelling.
|
|
18
|
+
|
|
19
|
+
So far the industry splits on who settles a conflict: the platform, by scoring it,
|
|
20
|
+
which scales with usage, or a person, by reviewing it, which scales with reviewer time; I
|
|
21
|
+
prefer the first, time will tell.
|
|
22
|
+
|
|
23
|
+
The harvest delivers one thing: a knowledge graph of the tenant - every term, its
|
|
24
|
+
competing definitions ranked, and what feeds what. That graph is the context. It runs
|
|
25
|
+
nightly on its own, an agent asks whenever, and the two meet at the graph without ever
|
|
26
|
+
calling each other.
|
|
27
|
+
|
|
28
|
+
**[See it on a real tenant](https://djouallah.github.io/fabric-context-layer/)**
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
Installing it, the ranking, what is harvested, the schema, the limits: **[run.md](run.md)**.
|
|
33
|
+
|
|
34
|
+
## Licence
|
|
35
|
+
|
|
36
|
+
MIT - see [LICENSE](LICENSE).
|
|
@@ -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)
|
|
@@ -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 + "`")
|