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 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])