binaryrain-fabric-local-pyspark 0.1.2__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.
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.3
2
+ Name: binaryrain-fabric-local-pyspark
3
+ Version: 0.1.2
4
+ Summary: This tool can be used for local fabric development by providing a local caching layer to reduce capacity usage.
5
+ Keywords: binary rain,common,help,functions,data processing,fabric,local,pyspark
6
+ Author: Binary Rain, Marcel T.O
7
+ Classifier: Programming Language :: Python
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Requires-Dist: azure-identity>=1.26.0
11
+ Requires-Dist: azure-storage-file-datalake>=12.26.0
12
+ Requires-Dist: deltalake>=1.6.6
13
+ Requires-Dist: pandas>=3.0.6
14
+ Requires-Dist: pyarrow>=26.0.0
15
+ Requires-Dist: pycryptodome>=3.24.1
16
+ Requires-Dist: pyodbc>=5.3.0
17
+ Requires-Dist: pyspark>=4.2.0
18
+ Requires-Dist: python-dotenv>=1.2.4
19
+ Requires-Dist: sqlalchemy>=2.1.4
20
+ Requires-Python: >=3.12
21
+ Project-URL: Homepage, https://binaryrain-net.github.io/Binary-Rain-Helper-Toolkit/
22
+ Description-Content-Type: text/markdown
23
+
File without changes
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "binaryrain-fabric-local-pyspark"
3
+ version = "0.1.2"
4
+ description = "This tool can be used for local fabric development by providing a local caching layer to reduce capacity usage."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "azure-identity>=1.26.0",
9
+ "azure-storage-file-datalake>=12.26.0",
10
+ "deltalake>=1.6.6",
11
+ "pandas>=3.0.6",
12
+ "pyarrow>=26.0.0",
13
+ "pycryptodome>=3.24.1",
14
+ "pyodbc>=5.3.0",
15
+ "pyspark>=4.2.0",
16
+ "python-dotenv>=1.2.4",
17
+ "sqlalchemy>=2.1.4",
18
+ ]
19
+ keywords = [
20
+ "binary rain",
21
+ "common",
22
+ "help",
23
+ "functions",
24
+ "data processing",
25
+ "fabric",
26
+ "local",
27
+ "pyspark",
28
+ ]
29
+ classifiers = [
30
+ "Programming Language :: Python",
31
+ "Programming Language :: Python :: 3",
32
+ "Programming Language :: Python :: 3.12",
33
+ ]
34
+
35
+ [[project.authors]]
36
+ name = "Binary Rain"
37
+
38
+ [[project.authors]]
39
+ name = "Marcel T.O"
40
+
41
+ [project.urls]
42
+ Homepage = "https://binaryrain-net.github.io/Binary-Rain-Helper-Toolkit/"
43
+
44
+ [tool.ruff]
45
+ extend = "../../ruff.toml"
46
+
47
+ [build-system]
48
+ requires = ["uv_build>=0.11.24,<0.12.0"]
49
+ build-backend = "uv_build"
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "binaryrain-fabric-local-pyspark"
3
+ version = "0.1.2"
4
+ description = "This tool can be used for local fabric development by providing a local caching layer to reduce capacity usage."
5
+ readme = "README.md"
6
+ authors = [{ name = "Binary Rain" }, { name = "Marcel T.O" }]
7
+ requires-python = ">=3.12"
8
+ dependencies = [
9
+ "azure-identity>=1.26.0",
10
+ "azure-storage-file-datalake>=12.26.0",
11
+ "deltalake>=1.6.6",
12
+ "pandas>=3.0.6",
13
+ "pyarrow>=26.0.0",
14
+ "pycryptodome>=3.24.1",
15
+ "pyodbc>=5.3.0",
16
+ "pyspark>=4.2.0",
17
+ "python-dotenv>=1.2.4",
18
+ "sqlalchemy>=2.1.4",
19
+ ]
20
+
21
+ keywords = [
22
+ "binary rain",
23
+ "common",
24
+ "help",
25
+ "functions",
26
+ "data processing",
27
+ "fabric",
28
+ "local",
29
+ "pyspark",
30
+ ]
31
+
32
+ classifiers = [
33
+ "Programming Language :: Python",
34
+ "Programming Language :: Python :: 3",
35
+ "Programming Language :: Python :: 3.12",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://binaryrain-net.github.io/Binary-Rain-Helper-Toolkit/"
40
+
41
+ [tool.ruff]
42
+ # Extend the settings from the ruff.toml file.
43
+ extend = "../../ruff.toml"
44
+
45
+ [build-system]
46
+ requires = ["uv_build>=0.11.24,<0.12.0"]
47
+ build-backend = "uv_build"
@@ -0,0 +1 @@
1
+ """Local Spark and Fabric lakehouse caching helpers."""
@@ -0,0 +1,18 @@
1
+ """Create Azure credentials and access tokens for OneLake requests."""
2
+
3
+ from functools import cache
4
+
5
+ from azure.identity import DefaultAzureCredential
6
+
7
+ from binaryrain_helper_local_pyspark.config import STORAGE_SCOPE
8
+
9
+
10
+ @cache
11
+ def _credential() -> DefaultAzureCredential:
12
+ """Return the process-wide Azure credential used for OneLake access."""
13
+ return DefaultAzureCredential()
14
+
15
+
16
+ def _storage_token() -> str:
17
+ """Return an Azure Storage bearer token for OneLake requests."""
18
+ return _credential().get_token(STORAGE_SCOPE).token
@@ -0,0 +1,24 @@
1
+ """Configuration constants and environment variables used by the package.
2
+
3
+ ``FABRIC_CACHE_DIR`` overrides the cache root. When unset, cached data goes under
4
+ ``.cache/fabric`` relative to the package source tree. Set ``FABRIC_CACHE_REFRESH=1``
5
+ to refresh cached data. ``FABRIC_WORKSPACE_ID`` and ``FABRIC_LAKEHOUSE_ID`` identify
6
+ the default lakehouse for relative paths. ``FABRIC_SERVER`` and ``FABRIC_DATABASE``
7
+ configure the SQL endpoint, and ``FABRIC_SPARK_DEBUG=1`` enables verbose Spark output.
8
+ """
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ CACHE_DIR = Path(os.getenv("FABRIC_CACHE_DIR", Path(__file__).parents[1] / ".cache" / "fabric"))
14
+
15
+ LAKEHOUSE_AREAS = ("Files", "Tables")
16
+ MIN_ABFSS_SEGMENTS = 3 # <lakehouse>/<area>/<relative>
17
+ MIN_RELATIVE_SEGMENTS = 2 # <area>/<relative>
18
+
19
+ ONELAKE_HOST = "onelake.dfs.fabric.microsoft.com"
20
+ STORAGE_SCOPE = "https://storage.azure.com/.default"
21
+ FABRIC_TOKEN_SCOPE = "https://database.windows.net/.default"
22
+ # SQL_COPT_SS_ACCESS_TOKEN connection attribute for passing an AAD access token
23
+ # to the ODBC Driver for SQL Server.
24
+ SQL_COPT_SS_ACCESS_TOKEN = 1256
@@ -0,0 +1,222 @@
1
+ """Parse Fabric lakehouse paths and cache their data on the local filesystem."""
2
+
3
+ import logging
4
+ import os
5
+ import shutil
6
+ from pathlib import Path, PurePosixPath
7
+ from urllib.parse import urlsplit
8
+
9
+ import pyarrow.dataset as ds
10
+ from azure.core.exceptions import ResourceNotFoundError
11
+ from azure.storage.filedatalake import DataLakeServiceClient
12
+ from deltalake import DeltaTable
13
+
14
+ from binaryrain_helper_local_pyspark.auth import _credential, _storage_token
15
+ from binaryrain_helper_local_pyspark.config import (
16
+ LAKEHOUSE_AREAS,
17
+ MIN_ABFSS_SEGMENTS,
18
+ MIN_RELATIVE_SEGMENTS,
19
+ )
20
+ from binaryrain_helper_local_pyspark.schemas import FabricPath
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def parse_fabric_path(path: str) -> FabricPath | None:
26
+ """
27
+ Parse an abfss URI or a default lakehouse relative path into its parts.
28
+
29
+ ``abfss://<workspace>@<host>/<lakehouse>/<Files|Tables>/<relative>`` is split directly.
30
+ ``Files/<relative>`` and ``Tables/<relative>`` are resolved against the default lakehouse
31
+ from ``FABRIC_WORKSPACE_ID`` and ``FABRIC_LAKEHOUSE_ID``.
32
+
33
+ :param str path:
34
+ An abfss URI, a default lakehouse-relative path, or a local path.
35
+
36
+ :returns FabricPath | None:
37
+ Parsed Fabric path, or None when the input is not a recognized Fabric path.
38
+ exception : ValueError
39
+ If an abfss URI is malformed or the default lakehouse IDs are missing for a relative path.
40
+ """
41
+ if path.lower().startswith("abfss://"):
42
+ parts = urlsplit(path)
43
+ workspace, _, host = parts.netloc.partition("@")
44
+ segments = PurePosixPath(parts.path).parts[1:]
45
+ if (
46
+ not workspace
47
+ or not host
48
+ or len(segments) < MIN_ABFSS_SEGMENTS
49
+ or segments[1] not in LAKEHOUSE_AREAS
50
+ ):
51
+ raise ValueError(
52
+ f"Expected abfss://<workspace>@<host>/<lakehouse>/<Files|Tables>/..., got {path}"
53
+ )
54
+ return FabricPath(workspace, segments[0], segments[1], "/".join(segments[2:]), host)
55
+
56
+ segments = PurePosixPath(path.replace("\\", "/").lstrip("/")).parts
57
+ if len(segments) < MIN_RELATIVE_SEGMENTS or segments[0] not in LAKEHOUSE_AREAS:
58
+ return None
59
+
60
+ workspace = os.getenv("FABRIC_WORKSPACE_ID")
61
+ lakehouse = os.getenv("FABRIC_LAKEHOUSE_ID")
62
+ if not workspace or not lakehouse:
63
+ raise ValueError(
64
+ f"{path} uses the default lakehouse, so FABRIC_WORKSPACE_ID and "
65
+ "FABRIC_LAKEHOUSE_ID must be set"
66
+ )
67
+ return FabricPath(workspace, lakehouse, segments[0], "/".join(segments[1:]))
68
+
69
+
70
+ def resolve_local_path(path: str, refresh: bool | None = None) -> str:
71
+ """
72
+ Return a local path for ``path``, downloading Fabric data into the cache when needed.
73
+
74
+ :param str path:
75
+ abfss URI, default lakehouse relative path, or local path.
76
+ :param bool | None refresh:
77
+ Refresh the cache when True or use ``FABRIC_CACHE_REFRESH`` when None. False uses the
78
+ existing cache when available.
79
+
80
+ :returns str:
81
+ Local cached path for a Fabric path, or the original path for any other input.
82
+ exception : ValueError
83
+ If a default lakehouse-relative path is used but its workspace or lakehouse ID is unset.
84
+ """
85
+ fabric_path = parse_fabric_path(path)
86
+ if fabric_path is None:
87
+ return path
88
+ if refresh is None:
89
+ refresh = os.getenv("FABRIC_CACHE_REFRESH") == "1"
90
+ if fabric_path.area == "Tables":
91
+ return str(cache_table(fabric_path, refresh))
92
+ return str(cache_file(fabric_path, refresh))
93
+
94
+
95
+ def cache_table(table: FabricPath | str, refresh: bool = False) -> Path:
96
+ """
97
+ Cache a lakehouse Delta table locally as parquet and return its cache directory.
98
+
99
+ A cache hit is returned without checking the remote table unless ``refresh`` is True.
100
+ Refreshing replaces the cached directory with data from the current table version.
101
+
102
+ :param FabricPath | str table:
103
+ A ``Tables`` path, such as ``Tables/dbo/kunden``, or a full abfss URI.
104
+ :param bool refresh:
105
+ Download again even when a cached copy exists. Default is False.
106
+
107
+ :returns Path:
108
+ Path to the directory containing the table's cached parquet files.
109
+ exception : ValueError
110
+ If the path is not a lakehouse ``Tables`` path.
111
+ exception : deltalake.exceptions.TableNotFoundError
112
+ If the remote Delta table does not exist.
113
+ """
114
+ fabric_path = _require_area(table, "Tables")
115
+ target = fabric_path.local_path
116
+ if target.exists() and not refresh:
117
+ logger.info("Cache hit, reading local copy: %s", target)
118
+ return target
119
+
120
+ logger.info("Cache miss, downloading from Fabric: %s", fabric_path.uri)
121
+ options = {"bearer_token": _storage_token(), "use_fabric_endpoint": "true"}
122
+ dataset = DeltaTable(fabric_path.uri, storage_options=options).to_pyarrow_dataset()
123
+
124
+ tmp_path = target.with_name(f"{target.name}.tmp")
125
+ shutil.rmtree(tmp_path, ignore_errors=True)
126
+ ds.write_dataset(dataset, tmp_path, format="parquet")
127
+ _commit(tmp_path, target)
128
+ return target
129
+
130
+
131
+ def cache_file(file: FabricPath | str, refresh: bool = False) -> Path:
132
+ """
133
+ Cache a file or folder below the lakehouse ``Files`` area and return its local path.
134
+
135
+ A cache hit is returned without checking the remote item unless ``refresh`` is True.
136
+
137
+ :param FabricPath | str file:
138
+ A ``Files`` path, such as ``Files/onPrem/mssql/Kunden.parquet``, or a full abfss URI.
139
+ :param bool refresh:
140
+ Download again even when a cached copy exists. Default is False.
141
+
142
+ :returns Path:
143
+ Path to the cached file or folder.
144
+ exception : ValueError
145
+ If the path is not a lakehouse ``Files`` path.
146
+ exception : FileNotFoundError
147
+ If the remote file or folder does not exist.
148
+ """
149
+ fabric_path = _require_area(file, "Files")
150
+ target = fabric_path.local_path
151
+ if target.exists() and not refresh:
152
+ logger.info("Cache hit, reading local copy: %s", target)
153
+ return target
154
+
155
+ service = DataLakeServiceClient(f"https://{fabric_path.host}", credential=_credential())
156
+ file_system = service.get_file_system_client(fabric_path.workspace)
157
+ remote_root = fabric_path.lakehouse_path
158
+ logger.info("Cache miss, downloading from Fabric: %s", fabric_path.uri)
159
+
160
+ try:
161
+ properties = file_system.get_file_client(remote_root).get_file_properties()
162
+ except ResourceNotFoundError as exc:
163
+ raise FileNotFoundError(f"{exc.error_code}: {fabric_path.uri} not found") from exc
164
+
165
+ # Spark writes "x.parquet" as a folder, so both layouts must be supported.
166
+ is_folder = properties.metadata.get("hdi_isfolder") == "true"
167
+ if is_folder:
168
+ remote_files = [
169
+ item.name for item in file_system.get_paths(remote_root) if not item.is_directory
170
+ ]
171
+ else:
172
+ remote_files = [remote_root]
173
+
174
+ tmp_path = target.with_name(f"{target.name}.tmp")
175
+ shutil.rmtree(tmp_path, ignore_errors=True)
176
+ for remote_file in remote_files:
177
+ local_file = tmp_path
178
+ if is_folder:
179
+ local_file = tmp_path / PurePosixPath(remote_file).relative_to(remote_root)
180
+ local_file.parent.mkdir(parents=True, exist_ok=True)
181
+ with local_file.open("wb") as handle:
182
+ file_system.get_file_client(remote_file).download_file().readinto(handle)
183
+
184
+ _commit(tmp_path, target)
185
+ return target
186
+
187
+
188
+ def _require_area(path: FabricPath | str, area: str) -> FabricPath:
189
+ """
190
+ Parse ``path`` and check that it points into the expected lakehouse area.
191
+
192
+ :param FabricPath | str path:
193
+ Parsed path or a string accepted by ``parse_fabric_path``.
194
+ :param str area:
195
+ Required lakehouse area, either ``Files`` or ``Tables``.
196
+
197
+ :returns FabricPath:
198
+ Parsed lakehouse path.
199
+ exception : ValueError
200
+ If the path is not a Fabric path or points into another area.
201
+ """
202
+ fabric_path = path if isinstance(path, FabricPath) else parse_fabric_path(path)
203
+ if fabric_path is None or fabric_path.area != area:
204
+ raise ValueError(f"Expected a lakehouse {area} path, got {path}")
205
+ return fabric_path
206
+
207
+
208
+ def _commit(tmp_path: Path, target: Path) -> None:
209
+ """
210
+ Replace the target with a completed download from a sibling temporary path.
211
+
212
+ :param Path tmp_path:
213
+ Temporary location holding the completed download.
214
+ :param Path target:
215
+ Final cache location.
216
+ """
217
+ if target.is_dir():
218
+ shutil.rmtree(target)
219
+ elif target.exists():
220
+ target.unlink()
221
+ target.parent.mkdir(parents=True, exist_ok=True)
222
+ tmp_path.rename(target)
@@ -0,0 +1,44 @@
1
+ """Data structures for representing Fabric lakehouse paths."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from binaryrain_helper_local_pyspark.config import CACHE_DIR, ONELAKE_HOST
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class FabricPath:
11
+ """Represent a location inside a Fabric lakehouse.
12
+
13
+ :param str workspace:
14
+ Workspace ID or name, which identifies the abfss file-system container.
15
+ :param str lakehouse:
16
+ Lakehouse ID or ``<name>.Lakehouse``.
17
+ :param str area:
18
+ Either ``Files`` or ``Tables``.
19
+ :param str relative:
20
+ Path below the area, for example ``onPrem/mssql/Kunden.parquet`` or ``dbo/kunden``.
21
+ :param str host:
22
+ OneLake host name. Default is the global OneLake endpoint.
23
+ """
24
+
25
+ workspace: str
26
+ lakehouse: str
27
+ area: str
28
+ relative: str
29
+ host: str = ONELAKE_HOST
30
+
31
+ @property
32
+ def lakehouse_path(self) -> str:
33
+ """Return the path below the workspace container, beginning with the lakehouse."""
34
+ return f"{self.lakehouse}/{self.area}/{self.relative}"
35
+
36
+ @property
37
+ def uri(self) -> str:
38
+ """Return the full abfss URI for this lakehouse location."""
39
+ return f"abfss://{self.workspace}@{self.host}/{self.lakehouse_path}"
40
+
41
+ @property
42
+ def local_path(self) -> Path:
43
+ """Return the local cache path corresponding to this lakehouse location."""
44
+ return CACHE_DIR / self.workspace / self.lakehouse / self.area / self.relative
@@ -0,0 +1,166 @@
1
+ """Provide local Spark readers and SQL execution with Fabric-backed caching."""
2
+
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ import subprocess
7
+ from functools import partial
8
+ from typing import Any
9
+
10
+ from pyspark import SparkConf, SparkContext, java_gateway
11
+ from pyspark.sql import DataFrame
12
+ from pyspark.sql import SparkSession as OriginalSparkSession
13
+ from pyspark.sql.readwriter import DataFrameReader as OriginalDataFrameReader
14
+
15
+ from binaryrain_helper_local_pyspark.config import CACHE_DIR
16
+ from binaryrain_helper_local_pyspark.fabric import resolve_local_path
17
+ from binaryrain_helper_local_pyspark.sql import get_fabric_engine, get_fabric_table_from_lakehouse
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class DataFrameReader(OriginalDataFrameReader):
23
+ """Resolve Fabric lakehouse parquet paths to local cache paths before reading."""
24
+
25
+ def parquet(self, *paths: str, **options: str | float | bool | None) -> DataFrame:
26
+ """
27
+ Read parquet data, downloading Fabric lakehouse paths to the cache when needed.
28
+
29
+ :param paths:
30
+ One or more abfss URIs, default lakehouse-relative paths, or local paths.
31
+ :param options:
32
+ Keyword options passed unchanged to PySpark's parquet reader.
33
+
34
+ :returns DataFrame:
35
+ DataFrame loaded from the resolved local paths.
36
+ exception : ValueError
37
+ If a default lakehouse-relative path is used without the required Fabric IDs.
38
+ """
39
+ return super().parquet(*(resolve_local_path(path) for path in paths), **options)
40
+
41
+
42
+ class SparkSession(OriginalSparkSession):
43
+ """Use the caching reader and Fabric SQL endpoint for supported Spark operations."""
44
+
45
+ @property
46
+ def read(self) -> DataFrameReader:
47
+ """Return a reader that resolves Fabric lakehouse paths through the local cache."""
48
+ return DataFrameReader(self)
49
+
50
+ def sql(
51
+ self,
52
+ sqlQuery: str,
53
+ args: dict[str, Any] | list[Any] | None = None,
54
+ **kwargs: Any,
55
+ ) -> DataFrame:
56
+ """
57
+ Run a SQL query through the Fabric endpoint and cache its result, or use local Spark.
58
+
59
+ The cache key is a SHA-256 hash of the endpoint and the query text, so the same
60
+ query maps to the same parquet file. Non-empty ``args`` or ``kwargs`` bypass the
61
+ Fabric endpoint and cache, and are passed to the base Spark SQL implementation.
62
+
63
+ :param str sqlQuery:
64
+ T-SQL query to run through the Fabric SQL endpoint when no arguments are supplied.
65
+ :param dict[str, Any] | list[Any] | None args:
66
+ Spark SQL parameters. Non-empty values select local Spark execution. Default is None.
67
+ :param kwargs:
68
+ Keyword arguments passed to the base Spark SQL implementation. Non-empty values
69
+ select local Spark execution.
70
+
71
+ :returns DataFrame:
72
+ The query result, loaded from the cache for Fabric endpoint queries.
73
+ exception : ValueError
74
+ If no server is configured when the SQL engine is first created.
75
+ """
76
+ if args or kwargs:
77
+ return super().sql(sqlQuery, args, **kwargs)
78
+
79
+ server = os.getenv("FABRIC_SERVER", "")
80
+ cache_id = hashlib.sha256(f"{server}\n{sqlQuery.strip()}".encode()).hexdigest()
81
+ target = CACHE_DIR.parent / "sql" / f"{cache_id}.parquet"
82
+
83
+ if target.exists() and os.getenv("FABRIC_CACHE_REFRESH") != "1":
84
+ logger.info("Cache hit, reading local query result: %s", target)
85
+ else:
86
+ logger.info("Cache miss, querying Fabric SQL endpoint: %s", cache_id)
87
+ result = get_fabric_table_from_lakehouse(get_fabric_engine(), sqlQuery)
88
+ target.parent.mkdir(parents=True, exist_ok=True)
89
+ tmp_path = target.with_name(f"{target.name}.tmp")
90
+ # Spark cannot read nanosecond timestamps, which pandas writes by default.
91
+ result.to_parquet(
92
+ tmp_path,
93
+ index=False,
94
+ coerce_timestamps="us",
95
+ allow_truncated_timestamps=True,
96
+ )
97
+ tmp_path.replace(target)
98
+
99
+ return self.read.parquet(str(target))
100
+
101
+
102
+ def get_local_spark() -> SparkSession:
103
+ """
104
+ Create or reuse a local Spark session with the Fabric-aware reader installed.
105
+
106
+ The PySpark builder creates its base session class. This function changes the resulting
107
+ session's class so its ``read`` property uses :class:`DataFrameReader`.
108
+
109
+ :returns SparkSession:
110
+ Local Spark session configured to use UTC and resolve Fabric data through the cache.
111
+ """
112
+ verbose = _spark_verbose()
113
+ conf = (
114
+ SparkConf()
115
+ .setMaster("local[*]")
116
+ .setAppName("local-fabric")
117
+ .set("spark.sql.session.timeZone", "UTC")
118
+ .set("spark.sql.debug.maxToStringFields", "200")
119
+ .set("spark.ui.showConsoleProgress", str(verbose).lower())
120
+ )
121
+ if not verbose:
122
+ _launch_quiet_gateway(conf)
123
+ spark = OriginalSparkSession.builder.config(conf=conf).getOrCreate()
124
+ spark.sparkContext.setLogLevel("INFO" if verbose else "ERROR")
125
+ spark.__class__ = SparkSession
126
+ return spark
127
+
128
+
129
+ def _spark_verbose() -> bool:
130
+ """
131
+ Return whether Spark and JVM console output should be shown.
132
+
133
+ Verbose output is enabled when the package logger is at DEBUG level or
134
+ ``FABRIC_SPARK_DEBUG`` is set to ``1``.
135
+
136
+ :returns bool:
137
+ Whether verbose Spark output is wanted.
138
+ """
139
+ return logger.isEnabledFor(logging.DEBUG) or os.getenv("FABRIC_SPARK_DEBUG") == "1"
140
+
141
+
142
+ def _launch_quiet_gateway(conf: SparkConf) -> None:
143
+ """
144
+ Start the Spark JVM with its console output redirected to a log file.
145
+
146
+ The JVM prints startup warnings before any log level can be applied, and PySpark
147
+ runs ``taskkill`` at exit on Windows, which prints to the console. Both are routed
148
+ away from the console here.
149
+
150
+ :param SparkConf conf:
151
+ Spark configuration used to launch the JVM.
152
+ """
153
+ if SparkContext._gateway is not None:
154
+ return
155
+ log_path = CACHE_DIR.parent / "spark.log"
156
+ log_path.parent.mkdir(parents=True, exist_ok=True)
157
+ log_file = log_path.open("w", encoding="utf-8")
158
+ gateway = java_gateway.launch_gateway(
159
+ conf, popen_kwargs={"stdout": log_file, "stderr": subprocess.STDOUT}
160
+ )
161
+ SparkContext._gateway = gateway
162
+ SparkContext._jvm = gateway.jvm
163
+ java_gateway.Popen = partial(
164
+ subprocess.Popen, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
165
+ )
166
+ logger.debug("Spark JVM output is written to %s", log_path)
@@ -0,0 +1,81 @@
1
+ """Connect to a Fabric lakehouse SQL endpoint using Azure token authentication."""
2
+
3
+ import os
4
+ import struct
5
+ from functools import cache
6
+ from urllib.parse import quote_plus
7
+
8
+ import pandas as pd
9
+ from azure.identity import DefaultAzureCredential
10
+ from sqlalchemy import create_engine, event
11
+ from sqlalchemy.engine import Engine
12
+
13
+ from binaryrain_helper_local_pyspark.config import FABRIC_TOKEN_SCOPE, SQL_COPT_SS_ACCESS_TOKEN
14
+
15
+
16
+ @cache
17
+ def get_fabric_engine(
18
+ server: str | None = None,
19
+ database: str | None = None,
20
+ ) -> Engine:
21
+ """Create a cached SQLAlchemy engine for a Fabric lakehouse SQL endpoint.
22
+
23
+ Each new physical connection receives a fresh Azure Active Directory access token.
24
+ ``server`` and ``database`` override ``FABRIC_SERVER`` and ``FABRIC_DATABASE``.
25
+
26
+ :param str | None server:
27
+ Fabric SQL endpoint host. Defaults to ``FABRIC_SERVER``.
28
+ :param str | None database:
29
+ Lakehouse database name. Defaults to ``FABRIC_DATABASE``, then ``default``.
30
+
31
+ :returns Engine:
32
+ SQLAlchemy engine configured for ODBC Driver 18 and Azure token authentication.
33
+ exception : ValueError
34
+ If neither ``server`` nor ``FABRIC_SERVER`` provides a server name.
35
+ """
36
+
37
+ fabric_server = server or os.getenv("FABRIC_SERVER")
38
+ database = database or os.getenv("FABRIC_DATABASE", "default")
39
+ if not fabric_server:
40
+ raise ValueError("FABRIC_SERVER environment variables must be set.")
41
+
42
+ connection_string = (
43
+ f"Driver={{ODBC Driver 18 for SQL Server}};"
44
+ f"Server={fabric_server};"
45
+ f"Database={database or 'default'};"
46
+ f"Encrypt=yes;"
47
+ f"TrustServerCertificate=no;"
48
+ )
49
+ url = f"mssql+pyodbc:///?odbc_connect={quote_plus(connection_string)}"
50
+ engine = create_engine(url)
51
+
52
+ credential = DefaultAzureCredential()
53
+
54
+ @event.listens_for(engine, "do_connect")
55
+ def provide_token(dialect, conn_rec, cargs, cparams):
56
+ """Set the Azure access token on the ODBC connection parameters."""
57
+ token = credential.get_token(FABRIC_TOKEN_SCOPE).token.encode("utf-16-le")
58
+ token_struct = struct.pack(f"<I{len(token)}s", len(token), token)
59
+ cparams["attrs_before"] = {SQL_COPT_SS_ACCESS_TOKEN: token_struct}
60
+
61
+ return engine
62
+
63
+
64
+ def get_fabric_table_from_lakehouse(
65
+ engine: Engine,
66
+ query: str,
67
+ ) -> pd.DataFrame:
68
+ """Execute a SQL query against Fabric and return its rows as a DataFrame.
69
+
70
+ :param Engine engine:
71
+ SQLAlchemy engine connected to the Fabric lakehouse endpoint.
72
+ :param str query:
73
+ SQL query to execute.
74
+
75
+ :returns pd.DataFrame:
76
+ Rows returned by the query, with columns named from the result set.
77
+ """
78
+ result = pd.DataFrame()
79
+ with engine.connect() as conn:
80
+ result = pd.read_sql(query, conn)
81
+ return result