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.
Files changed (37) hide show
  1. fabcontext-0.1.0/LICENSE +21 -0
  2. fabcontext-0.1.0/PKG-INFO +63 -0
  3. fabcontext-0.1.0/README.md +36 -0
  4. fabcontext-0.1.0/fabcontext/__init__.py +187 -0
  5. fabcontext-0.1.0/fabcontext/__main__.py +64 -0
  6. fabcontext-0.1.0/fabcontext/_fabric/__init__.py +20 -0
  7. fabcontext-0.1.0/fabcontext/_fabric/auth.py +167 -0
  8. fabcontext-0.1.0/fabcontext/_fabric/delta.py +136 -0
  9. fabcontext-0.1.0/fabcontext/_fabric/onelake.py +221 -0
  10. fabcontext-0.1.0/fabcontext/_fabric/patterns.py +62 -0
  11. fabcontext-0.1.0/fabcontext/_fabric/rest.py +175 -0
  12. fabcontext-0.1.0/fabcontext/_fabric/workspace.py +126 -0
  13. fabcontext-0.1.0/fabcontext/api.py +272 -0
  14. fabcontext-0.1.0/fabcontext/common.py +361 -0
  15. fabcontext-0.1.0/fabcontext/fetch.py +344 -0
  16. fabcontext-0.1.0/fabcontext/files.py +133 -0
  17. fabcontext-0.1.0/fabcontext/graph.py +420 -0
  18. fabcontext-0.1.0/fabcontext/graph_template.html +642 -0
  19. fabcontext-0.1.0/fabcontext/parse.py +645 -0
  20. fabcontext-0.1.0/fabcontext/parse_code.py +269 -0
  21. fabcontext-0.1.0/fabcontext/parse_model.py +271 -0
  22. fabcontext-0.1.0/fabcontext/parse_report.py +244 -0
  23. fabcontext-0.1.0/fabcontext/profiling.py +277 -0
  24. fabcontext-0.1.0/fabcontext/publish.py +83 -0
  25. fabcontext-0.1.0/fabcontext/schema.sql +110 -0
  26. fabcontext-0.1.0/fabcontext/viz.py +102 -0
  27. fabcontext-0.1.0/fabcontext/wiki.py +695 -0
  28. fabcontext-0.1.0/fabcontext.egg-info/PKG-INFO +63 -0
  29. fabcontext-0.1.0/fabcontext.egg-info/SOURCES.txt +35 -0
  30. fabcontext-0.1.0/fabcontext.egg-info/dependency_links.txt +1 -0
  31. fabcontext-0.1.0/fabcontext.egg-info/requires.txt +8 -0
  32. fabcontext-0.1.0/fabcontext.egg-info/top_level.txt +1 -0
  33. fabcontext-0.1.0/pyproject.toml +57 -0
  34. fabcontext-0.1.0/setup.cfg +4 -0
  35. fabcontext-0.1.0/tests/test_harvest.py +235 -0
  36. fabcontext-0.1.0/tests/test_no_duckrun.py +52 -0
  37. fabcontext-0.1.0/tests/test_publish.py +178 -0
@@ -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
+ ![Inside the platform, the harvest side reads a Fabric workspace, builds and ranks the graph, and publishes it as the context. Outside it, any stateless agent - on a laptop, in a notebook, in CI, in a chat - searches and defines a term from that context, runs rank 1 as DAX on the model that owns it, and answers with the number, its source and a confidence.](docs/how-it-works-dark.svg)
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
+ ![Inside the platform, the harvest side reads a Fabric workspace, builds and ranks the graph, and publishes it as the context. Outside it, any stateless agent - on a laptop, in a notebook, in CI, in a chat - searches and defines a term from that context, runs rank 1 as DAX on the model that owns it, and answers with the number, its source and a confidence.](docs/how-it-works-dark.svg)
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 + "`")