interloper-google-cloud 0.93.0__tar.gz → 0.94.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.
Files changed (18) hide show
  1. interloper_google_cloud-0.94.2/PKG-INFO +65 -0
  2. interloper_google_cloud-0.94.2/README.md +49 -0
  3. {interloper_google_cloud-0.93.0 → interloper_google_cloud-0.94.2}/pyproject.toml +2 -2
  4. {interloper_google_cloud-0.93.0 → interloper_google_cloud-0.94.2}/pyproject.toml.orig +2 -2
  5. {interloper_google_cloud-0.93.0 → interloper_google_cloud-0.94.2}/src/interloper_google_cloud/__init__.py +3 -1
  6. interloper_google_cloud-0.94.2/src/interloper_google_cloud/gcs/destination.py +113 -0
  7. interloper_google_cloud-0.94.2/src/interloper_google_cloud/google_sheets/__init__.py +7 -0
  8. interloper_google_cloud-0.94.2/src/interloper_google_cloud/google_sheets/destination.py +466 -0
  9. interloper_google_cloud-0.93.0/PKG-INFO +0 -19
  10. interloper_google_cloud-0.93.0/README.md +0 -3
  11. interloper_google_cloud-0.93.0/src/interloper_google_cloud/gcs/destination.py +0 -239
  12. interloper_google_cloud-0.93.0/src/interloper_google_cloud/gcs/formats.py +0 -265
  13. interloper_google_cloud-0.93.0/src/interloper_google_cloud/serialization.py +0 -53
  14. {interloper_google_cloud-0.93.0 → interloper_google_cloud-0.94.2}/src/interloper_google_cloud/bigquery/__init__.py +0 -0
  15. {interloper_google_cloud-0.93.0 → interloper_google_cloud-0.94.2}/src/interloper_google_cloud/bigquery/destination.py +0 -0
  16. {interloper_google_cloud-0.93.0 → interloper_google_cloud-0.94.2}/src/interloper_google_cloud/bigquery/types.py +0 -0
  17. {interloper_google_cloud-0.93.0 → interloper_google_cloud-0.94.2}/src/interloper_google_cloud/connection.py +0 -0
  18. {interloper_google_cloud-0.93.0 → interloper_google_cloud-0.94.2}/src/interloper_google_cloud/gcs/__init__.py +0 -0
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.3
2
+ Name: interloper-google-cloud
3
+ Version: 0.94.2
4
+ Summary: Interloper Google Cloud integration: BigQuery, Cloud Storage and Google Sheets destinations
5
+ Author: Guillaume Onfroy
6
+ Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
7
+ Requires-Dist: google-cloud-bigquery>=3.0
8
+ Requires-Dist: google-cloud-storage>=2.0
9
+ Requires-Dist: interloper-core
10
+ Requires-Dist: interloper-pandas
11
+ Requires-Dist: pyarrow>=14
12
+ Requires-Dist: db-dtypes>=1.0
13
+ Requires-Dist: pandas-gbq>=0.26.1
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+
17
+ # interloper-google-cloud
18
+
19
+ Google Cloud integration for the Interloper framework. Provides BigQuery, Cloud Storage and Google Sheets destinations and the Google Cloud connection resource.
20
+
21
+ ## Google Sheets
22
+
23
+ `GoogleSheetsDestination` writes assets into one spreadsheet, one tab per asset. It authenticates with the
24
+ `GoogleCloudConnection`'s service-account key, or with ambient credentials when the key is empty.
25
+
26
+ ### Setup
27
+
28
+ 1. Create the spreadsheet and copy its id from the URL
29
+ (`https://docs.google.com/spreadsheets/d/<id>/edit`).
30
+ 2. Share it with the service account's email address (`client_email` in the key) as an **editor**. The
31
+ service account sees no spreadsheet it was not shared on.
32
+
33
+ ### Usage
34
+
35
+ ```python
36
+ from interloper_google_cloud import GoogleCloudConnection, GoogleSheetsDestination
37
+
38
+ destination = GoogleSheetsDestination(
39
+ connection=GoogleCloudConnection(service_account_key="..."),
40
+ spreadsheet_id="1AbC...",
41
+ )
42
+ ```
43
+
44
+ ### Layout
45
+
46
+ - Each asset is a tab titled after its table, or `{dataset}.{table}` when the asset has a dataset (at most 100
47
+ characters, Sheets' limit).
48
+ - The first row is the header, written when the tab is created from the asset's schema. Later writes are
49
+ aligned to it: columns missing from the data are left empty, columns missing from the header are dropped with
50
+ a warning. The header is never altered.
51
+ - Rows carry the partition column. Replacing a partition deletes its rows, then appends the new ones; Sheets
52
+ has no transactions, so the pair is not atomic.
53
+
54
+ ### Types
55
+
56
+ Values are written raw: numbers and booleans as is, `None` as an empty cell, decimals as strings, dates and
57
+ datetimes as ISO 8601 strings, dicts and lists as JSON. Time partitions are matched by comparing those ISO
58
+ strings, which orders chronologically, so a time-partitioned asset's partition column must be a date or a
59
+ datetime (or an ISO string). On read, empty cells come back as `None` and rows are reconciled against the
60
+ asset's schema, restoring the declared types.
61
+
62
+ ### Limits
63
+
64
+ Sheets caps a spreadsheet at 10 million cells, and replacing a partition reads the whole tab and rewrites it. The
65
+ destination suits small reporting tables that people open in Sheets, not a warehouse.
@@ -0,0 +1,49 @@
1
+ # interloper-google-cloud
2
+
3
+ Google Cloud integration for the Interloper framework. Provides BigQuery, Cloud Storage and Google Sheets destinations and the Google Cloud connection resource.
4
+
5
+ ## Google Sheets
6
+
7
+ `GoogleSheetsDestination` writes assets into one spreadsheet, one tab per asset. It authenticates with the
8
+ `GoogleCloudConnection`'s service-account key, or with ambient credentials when the key is empty.
9
+
10
+ ### Setup
11
+
12
+ 1. Create the spreadsheet and copy its id from the URL
13
+ (`https://docs.google.com/spreadsheets/d/<id>/edit`).
14
+ 2. Share it with the service account's email address (`client_email` in the key) as an **editor**. The
15
+ service account sees no spreadsheet it was not shared on.
16
+
17
+ ### Usage
18
+
19
+ ```python
20
+ from interloper_google_cloud import GoogleCloudConnection, GoogleSheetsDestination
21
+
22
+ destination = GoogleSheetsDestination(
23
+ connection=GoogleCloudConnection(service_account_key="..."),
24
+ spreadsheet_id="1AbC...",
25
+ )
26
+ ```
27
+
28
+ ### Layout
29
+
30
+ - Each asset is a tab titled after its table, or `{dataset}.{table}` when the asset has a dataset (at most 100
31
+ characters, Sheets' limit).
32
+ - The first row is the header, written when the tab is created from the asset's schema. Later writes are
33
+ aligned to it: columns missing from the data are left empty, columns missing from the header are dropped with
34
+ a warning. The header is never altered.
35
+ - Rows carry the partition column. Replacing a partition deletes its rows, then appends the new ones; Sheets
36
+ has no transactions, so the pair is not atomic.
37
+
38
+ ### Types
39
+
40
+ Values are written raw: numbers and booleans as is, `None` as an empty cell, decimals as strings, dates and
41
+ datetimes as ISO 8601 strings, dicts and lists as JSON. Time partitions are matched by comparing those ISO
42
+ strings, which orders chronologically, so a time-partitioned asset's partition column must be a date or a
43
+ datetime (or an ISO string). On read, empty cells come back as `None` and rows are reconciled against the
44
+ asset's schema, restoring the declared types.
45
+
46
+ ### Limits
47
+
48
+ Sheets caps a spreadsheet at 10 million cells, and replacing a partition reads the whole tab and rewrites it. The
49
+ destination suits small reporting tables that people open in Sheets, not a warehouse.
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "interloper-google-cloud"
3
- version = "0.93.0"
4
- description = "Interloper Google Cloud integration: BigQuery and Cloud Storage destinations"
3
+ version = "0.94.2"
4
+ description = "Interloper Google Cloud integration: BigQuery, Cloud Storage and Google Sheets destinations"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  dependencies = [
@@ -3,8 +3,8 @@
3
3
  # ###############
4
4
  [project]
5
5
  name = "interloper-google-cloud"
6
- version = "0.93.0"
7
- description = "Interloper Google Cloud integration: BigQuery and Cloud Storage destinations"
6
+ version = "0.94.2"
7
+ description = "Interloper Google Cloud integration: BigQuery, Cloud Storage and Google Sheets destinations"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
10
10
  requires-python = ">=3.10"
@@ -1,11 +1,13 @@
1
- """Interloper Google Cloud integration: BigQuery and Cloud Storage destinations and connection."""
1
+ """Interloper Google Cloud integration: BigQuery, Cloud Storage and Google Sheets destinations and connection."""
2
2
 
3
3
  from interloper_google_cloud.bigquery import BigQueryDestination
4
4
  from interloper_google_cloud.connection import GoogleCloudConnection
5
5
  from interloper_google_cloud.gcs import GCSDestination
6
+ from interloper_google_cloud.google_sheets import GoogleSheetsDestination
6
7
 
7
8
  __all__ = [
8
9
  "BigQueryDestination",
9
10
  "GCSDestination",
10
11
  "GoogleCloudConnection",
12
+ "GoogleSheetsDestination",
11
13
  ]
@@ -0,0 +1,113 @@
1
+ """Google Cloud Storage destination implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterator
7
+ from functools import cached_property
8
+
9
+ import google.auth
10
+ from google.cloud import storage
11
+ from google.cloud.exceptions import NotFound
12
+ from google.oauth2 import service_account
13
+ from interloper.destination import ObjectStoreDestination, StoredObject, destination
14
+ from interloper.resource.fields import FetchField
15
+
16
+ from interloper_google_cloud.connection import GoogleCloudConnection
17
+
18
+
19
+ @destination(
20
+ key="gcs_destination",
21
+ name="Google Cloud Storage",
22
+ icon="icon:gcs",
23
+ tags=["Cloud"],
24
+ )
25
+ class GCSDestination(ObjectStoreDestination):
26
+ """Google Cloud Storage destination.
27
+
28
+ An :class:`~interloper.destination.ObjectStoreDestination` over Cloud
29
+ Storage: the base owns the hive layout, the formats and the row-count
30
+ metadata, so objects land at::
31
+
32
+ gs://{bucket}/{prefix}/{dataset}/{table}/data.{ext}
33
+ gs://{bucket}/{prefix}/{dataset}/{table}/{column}={partition}/data.{ext}
34
+ """
35
+
36
+ connection: GoogleCloudConnection
37
+
38
+ bucket: str = FetchField(
39
+ provider="connection.buckets",
40
+ label_key="name",
41
+ value_key="name",
42
+ description="Cloud Storage bucket",
43
+ discriminator=True,
44
+ )
45
+
46
+ @cached_property
47
+ def client(self) -> storage.Client:
48
+ """The Cloud Storage client every read and write goes through.
49
+
50
+ Built from the connection's service-account key when there is one,
51
+ else from ambient credentials (workload identity in-cluster).
52
+
53
+ Returns:
54
+ The client, cached per destination instance.
55
+ """
56
+ if self.connection and self.connection.service_account_key:
57
+ key_info = json.loads(self.connection.service_account_key)
58
+ credentials = service_account.Credentials.from_service_account_info(key_info)
59
+ return storage.Client(project=key_info.get("project_id"), credentials=credentials)
60
+ credentials, project = google.auth.default()
61
+ return storage.Client(project=project, credentials=credentials)
62
+
63
+ # -- Object hooks ----------------------------------------------------------
64
+
65
+ def put_object(self, name: str, payload: bytes, content_type: str, metadata: dict[str, str]) -> None:
66
+ """Upload a blob, overwriting any blob of the same name.
67
+
68
+ Args:
69
+ name: The blob name, relative to the bucket root.
70
+ payload: The blob's bytes.
71
+ content_type: The payload's media type.
72
+ metadata: Custom metadata to store on the blob.
73
+ """
74
+ blob = self.client.bucket(self.bucket).blob(name)
75
+ blob.metadata = metadata
76
+ blob.upload_from_string(payload, content_type=content_type)
77
+
78
+ def get_object(self, name: str) -> bytes | None:
79
+ """Download a blob.
80
+
81
+ Args:
82
+ name: The blob name, relative to the bucket root.
83
+
84
+ Returns:
85
+ The blob's bytes, or ``None`` when no blob has that name.
86
+ """
87
+ try:
88
+ return self.client.bucket(self.bucket).blob(name).download_as_bytes()
89
+ except NotFound:
90
+ return None
91
+
92
+ def list_objects(self, prefix: str) -> Iterator[StoredObject]:
93
+ """List the blobs under a name prefix; the listing carries their metadata.
94
+
95
+ Args:
96
+ prefix: The name prefix, ending with ``/``.
97
+
98
+ Yields:
99
+ One stored object per blob.
100
+ """
101
+ for blob in self.client.list_blobs(self.bucket, prefix=prefix):
102
+ yield StoredObject(name=blob.name, metadata=dict(blob.metadata or {}))
103
+
104
+ def object_uri(self, name: str) -> str:
105
+ """Return the ``gs://`` URI of a blob.
106
+
107
+ Args:
108
+ name: The blob name, relative to the bucket root.
109
+
110
+ Returns:
111
+ ``gs://{bucket}/{name}``.
112
+ """
113
+ return f"gs://{self.bucket}/{name}"
@@ -0,0 +1,7 @@
1
+ """Google Sheets destination implementation."""
2
+
3
+ from interloper_google_cloud.google_sheets.destination import GoogleSheetsDestination
4
+
5
+ __all__ = [
6
+ "GoogleSheetsDestination",
7
+ ]
@@ -0,0 +1,466 @@
1
+ """Google Sheets destination implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import json
7
+ import math
8
+ import numbers
9
+ import warnings
10
+ from decimal import Decimal
11
+ from functools import cached_property
12
+ from typing import Any, get_origin
13
+ from urllib.parse import quote
14
+
15
+ import google.auth
16
+ from google.auth.credentials import Credentials
17
+ from google.auth.transport.requests import Request
18
+ from google.oauth2 import service_account
19
+ from interloper.destination import IOContext, destination
20
+ from interloper.destination.database import DatabaseDestination, PartitionFilter
21
+ from interloper.errors import ConfigError, DataNotFoundError
22
+ from interloper.partitioning import Partition
23
+ from interloper.representation import Representation
24
+ from interloper.resource.fields import InputField
25
+ from interloper.rest import RESTClient
26
+ from interloper.schema import FieldSpec
27
+ from interloper.utils.json import json_default, replace_non_finite
28
+
29
+ from interloper_google_cloud.connection import GoogleCloudConnection
30
+
31
+ SHEETS_SCOPE = "https://www.googleapis.com/auth/spreadsheets"
32
+
33
+ _API_BASE = "https://sheets.googleapis.com/v4/spreadsheets"
34
+ _MAX_TITLE_LENGTH = 100
35
+ _APPEND_CHUNK_ROWS = 10_000
36
+ _TIMEOUT = 60.0
37
+
38
+
39
+ def _credentials(connection: GoogleCloudConnection | None) -> Credentials:
40
+ """Build Sheets-scoped credentials for a connection.
41
+
42
+ Args:
43
+ connection: The connection whose service-account key signs requests;
44
+ ``None`` or an empty key falls back to ambient credentials
45
+ (workload identity in-cluster).
46
+
47
+ Returns:
48
+ Credentials carrying the spreadsheets scope.
49
+ """
50
+ if connection and connection.service_account_key:
51
+ key_info = json.loads(connection.service_account_key)
52
+ return service_account.Credentials.from_service_account_info(key_info).with_scopes([SHEETS_SCOPE])
53
+ credentials, _ = google.auth.default(scopes=[SHEETS_SCOPE])
54
+ return credentials
55
+
56
+
57
+ class _SheetsAPI:
58
+ """The Sheets REST API v4 calls one spreadsheet needs, over httpx2."""
59
+
60
+ def __init__(self, spreadsheet_id: str, credentials: Credentials, **client_kwargs: Any) -> None:
61
+ """Bind the API to a spreadsheet.
62
+
63
+ Args:
64
+ spreadsheet_id: The spreadsheet every call addresses.
65
+ credentials: Credentials whose bearer token authorizes each call.
66
+ **client_kwargs: Forwarded to the :class:`RESTClient` (a ``transport`` in tests).
67
+ """
68
+ self.spreadsheet_id = spreadsheet_id
69
+ self._credentials = credentials
70
+ self._path = "/" + quote(spreadsheet_id, safe="")
71
+ self._client = RESTClient(_API_BASE, timeout=_TIMEOUT, **client_kwargs)
72
+
73
+ def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
74
+ """Send one authorized request, refreshing the token when it has expired.
75
+
76
+ Args:
77
+ method: The HTTP method.
78
+ path: The suffix to the spreadsheet's path (``""`` for the spreadsheet itself).
79
+ **kwargs: Forwarded to the client (``params``, ``json``).
80
+
81
+ Returns:
82
+ The decoded JSON response body.
83
+ """
84
+ if not self._credentials.valid:
85
+ self._credentials.refresh(Request())
86
+ response = self._client.request(
87
+ method, self._path + path, headers={"Authorization": f"Bearer {self._credentials.token}"}, **kwargs
88
+ )
89
+ response.raise_for_status()
90
+ return response.json()
91
+
92
+ @staticmethod
93
+ def _range(title: str) -> str:
94
+ """Render a tab title as a URL-safe A1 range covering the whole tab.
95
+
96
+ Args:
97
+ title: The tab title.
98
+
99
+ Returns:
100
+ The quoted, percent-encoded range.
101
+ """
102
+ return quote("'" + title.replace("'", "''") + "'", safe="")
103
+
104
+ def sheet_titles(self) -> set[str]:
105
+ """List the spreadsheet's tab titles.
106
+
107
+ Returns:
108
+ Every tab title.
109
+ """
110
+ body = self._request("GET", "", params={"fields": "sheets.properties.title"})
111
+ return {sheet["properties"]["title"] for sheet in body.get("sheets", [])}
112
+
113
+ def values(self, title: str) -> list[list[Any]]:
114
+ """Read every row of a tab, header first.
115
+
116
+ Values come back unformatted so numbers keep their precision; rows
117
+ are ragged, the API omitting trailing empty cells.
118
+
119
+ Args:
120
+ title: The tab title.
121
+
122
+ Returns:
123
+ The tab's rows.
124
+ """
125
+ body = self._request("GET", f"/values/{self._range(title)}", params={"valueRenderOption": "UNFORMATTED_VALUE"})
126
+ return body.get("values", [])
127
+
128
+ def append(self, title: str, rows: list[list[Any]]) -> None:
129
+ """Append rows after a tab's last row, in chunks.
130
+
131
+ Args:
132
+ title: The tab title.
133
+ rows: The rows to append.
134
+ """
135
+ for start in range(0, len(rows), _APPEND_CHUNK_ROWS):
136
+ self._request(
137
+ "POST",
138
+ f"/values/{self._range(title)}:append",
139
+ params={"valueInputOption": "RAW", "insertDataOption": "INSERT_ROWS"},
140
+ json={"values": rows[start : start + _APPEND_CHUNK_ROWS]},
141
+ )
142
+
143
+ def rewrite(self, title: str, rows: list[list[Any]]) -> None:
144
+ """Replace a tab's contents with rows.
145
+
146
+ Args:
147
+ title: The tab title.
148
+ rows: The rows the tab holds afterwards, header first.
149
+ """
150
+ self._request("POST", f"/values/{self._range(title)}:clear", json={})
151
+ self.append(title, rows)
152
+
153
+ def add_sheet(self, title: str) -> None:
154
+ """Add an empty tab to the spreadsheet.
155
+
156
+ Args:
157
+ title: The new tab's title.
158
+ """
159
+ self._request("POST", ":batchUpdate", json={"requests": [{"addSheet": {"properties": {"title": title}}}]})
160
+
161
+
162
+ @destination(
163
+ key="google_sheets_destination",
164
+ name="Google Sheets",
165
+ icon="icon:google_sheets",
166
+ tags=["Cloud"],
167
+ )
168
+ class GoogleSheetsDestination(DatabaseDestination):
169
+ """Google Sheets destination.
170
+
171
+ The spreadsheet is the database and each asset is a tab, titled after its
172
+ table (``{dataset}.{table}`` when the asset has a dataset). The first row
173
+ is the header, fixed when the tab is created; every later row is a record
174
+ carrying the partition column like any other.
175
+
176
+ Sheets has no transactions: replacing a partition deletes its rows (a
177
+ read, then a rewrite of the rows kept) and then appends the new ones, so a
178
+ failure in between leaves the partition empty until the next write, and
179
+ concurrent writes to one tab are not safe.
180
+ """
181
+
182
+ connection: GoogleCloudConnection
183
+
184
+ spreadsheet_id: str = InputField(
185
+ label="Spreadsheet ID",
186
+ description="The id in the spreadsheet's URL",
187
+ info="Share the spreadsheet with the service account's email address (editor).",
188
+ discriminator=True,
189
+ )
190
+
191
+ @cached_property
192
+ def api(self) -> _SheetsAPI:
193
+ """The Sheets API every read and write goes through.
194
+
195
+ Returns:
196
+ The API bound to the spreadsheet, cached per destination instance.
197
+ """
198
+ return _SheetsAPI(self.spreadsheet_id, _credentials(self.connection))
199
+
200
+ # -- Helpers ---------------------------------------------------------------
201
+
202
+ @staticmethod
203
+ def _title(table: str, dataset: str | None) -> str:
204
+ """Return the tab title an asset's table maps to.
205
+
206
+ Args:
207
+ table: Target table name.
208
+ dataset: The asset's dataset, or ``None``.
209
+
210
+ Returns:
211
+ ``table``, or ``{dataset}.{table}`` when there is a dataset.
212
+
213
+ Raises:
214
+ ConfigError: If the title exceeds Sheets' 100-character limit.
215
+ """
216
+ title = f"{dataset}.{table}" if dataset else table
217
+ if len(title) > _MAX_TITLE_LENGTH:
218
+ raise ConfigError(
219
+ f"Sheet title '{title}' is {len(title)} characters long; Google Sheets allows {_MAX_TITLE_LENGTH}."
220
+ )
221
+ return title
222
+
223
+ # -- DatabaseDestination hooks ---------------------------------------------
224
+
225
+ def insert(self, table: str, dataset: str | None, data: Any, context: IOContext) -> None:
226
+ """Append data to its tab, creating the tab and its header on first write.
227
+
228
+ A new tab's header is the effective schema's columns (inferred from
229
+ the data when the context carries none); an existing tab keeps its
230
+ own. Rows are aligned to the header: header columns the data lacks
231
+ are left empty, and columns the header lacks are dropped with a
232
+ warning.
233
+
234
+ Args:
235
+ table: Target table name.
236
+ dataset: The asset's dataset, or ``None``.
237
+ data: The data in its native representation.
238
+ context: IO context carrying the effective schema.
239
+ """
240
+ title = self._title(table, dataset)
241
+ view = Representation.of(data)
242
+ schema = context.schema if context.schema is not None else view.infer()
243
+ columns = [spec.name for spec in schema.field_specs()]
244
+
245
+ rows: list[list[Any]] = []
246
+ if title not in self.api.sheet_titles():
247
+ self.api.add_sheet(title)
248
+ existing = []
249
+ else:
250
+ existing = self.api.values(title)
251
+ header = [str(cell) for cell in existing[0]] if existing else columns
252
+ if not existing:
253
+ rows.append(header)
254
+
255
+ extras = [column for column in dict.fromkeys([*columns, *view.columns]) if column not in header]
256
+ if extras:
257
+ warnings.warn(
258
+ f"Columns {extras} are not in the schema for '{title}' and will not be written.",
259
+ UserWarning,
260
+ stacklevel=2,
261
+ )
262
+ rows.extend([_cell(record.get(column)) for column in header] for record in view.records)
263
+ self.api.append(title, rows)
264
+
265
+ def delete(self, table: str, dataset: str | None, where: PartitionFilter | None) -> None:
266
+ """Delete the rows a filter selects, or every row but the header.
267
+
268
+ A tab that does not exist has nothing to delete, and a tab left
269
+ unchanged by the filter is not rewritten.
270
+
271
+ Args:
272
+ table: Target table name.
273
+ dataset: The asset's dataset, or ``None``.
274
+ where: The rows to delete; ``None`` for the whole table.
275
+ """
276
+ title = self._title(table, dataset)
277
+ if title not in self.api.sheet_titles():
278
+ return
279
+ existing = self.api.values(title)
280
+ if not existing:
281
+ return
282
+ header, body = existing[0], existing[1:]
283
+ kept = [] if where is None else [row for row in body if not _matches(header, row, where)]
284
+ if len(kept) < len(body):
285
+ self.api.rewrite(title, [header, *kept])
286
+
287
+ def select(self, table: str, dataset: str | None, where: PartitionFilter | None) -> list[dict[str, Any]]:
288
+ """Select the rows a filter selects, or every row, as records.
289
+
290
+ Empty cells read back as ``None``; every other value comes back as
291
+ Sheets stores it (numbers and booleans native, the rest strings).
292
+
293
+ Args:
294
+ table: Target table name.
295
+ dataset: The asset's dataset, or ``None``.
296
+ where: The rows to select; ``None`` for the whole table.
297
+
298
+ Returns:
299
+ The selected rows.
300
+
301
+ Raises:
302
+ DataNotFoundError: If the tab does not exist.
303
+ """
304
+ title = self._title(table, dataset)
305
+ if title not in self.api.sheet_titles():
306
+ raise DataNotFoundError(
307
+ f"Sheet '{title}' does not exist in spreadsheet '{self.spreadsheet_id}'. "
308
+ "Has the asset been materialized?"
309
+ )
310
+ existing = self.api.values(title)
311
+ if not existing:
312
+ return []
313
+ header, body = [str(cell) for cell in existing[0]], existing[1:]
314
+ return [
315
+ {column: _read(row[i] if i < len(row) else "") for i, column in enumerate(header)}
316
+ for row in body
317
+ if where is None or _matches(header, row, where)
318
+ ]
319
+
320
+ def read_partition(self, context: IOContext, partition: Partition | None) -> list[dict[str, Any]]:
321
+ """Load one partition, restoring the schema's types when the context carries one.
322
+
323
+ Dates and decimals are stored as strings and nested values as JSON,
324
+ so rows are reconciled against ``context.schema`` (JSON cells decoded
325
+ first) rather than handed back as Sheets holds them.
326
+
327
+ Args:
328
+ context: IO context carrying the asset and the effective schema.
329
+ partition: The partition to load, or ``None`` for the whole table.
330
+
331
+ Returns:
332
+ The partition's rows.
333
+ """
334
+ rows = super().read_partition(context, partition)
335
+ if context.schema is None:
336
+ return rows
337
+ specs = context.schema.field_specs()
338
+ json_columns = [spec.name for spec in specs if _is_json(spec)]
339
+ for row in rows:
340
+ for column in json_columns:
341
+ if isinstance(row.get(column), str):
342
+ row[column] = json.loads(row[column])
343
+ return context.schema.reconcile(rows)
344
+
345
+ def count(self, table: str, dataset: str | None, column: str) -> dict[str, int]:
346
+ """Return row counts grouped by a column's stored value.
347
+
348
+ Args:
349
+ table: Target table name.
350
+ dataset: The asset's dataset, or ``None``.
351
+ column: The column to group by.
352
+
353
+ Returns:
354
+ Each distinct value, as a string, to its row count.
355
+
356
+ Raises:
357
+ DataNotFoundError: If the tab does not exist.
358
+ """
359
+ title = self._title(table, dataset)
360
+ if title not in self.api.sheet_titles():
361
+ raise DataNotFoundError(
362
+ f"Sheet '{title}' does not exist in spreadsheet '{self.spreadsheet_id}'. "
363
+ "Has the asset been materialized?"
364
+ )
365
+ existing = self.api.values(title)
366
+ if not existing or column not in existing[0]:
367
+ return {}
368
+ index = existing[0].index(column)
369
+ counts: dict[str, int] = {}
370
+ for row in existing[1:]:
371
+ value = _text(row[index] if index < len(row) else "")
372
+ counts[value] = counts.get(value, 0) + 1
373
+ return counts
374
+
375
+
376
+ # -- Cell encoding -------------------------------------------------------------
377
+
378
+
379
+ def _cell(value: Any) -> Any:
380
+ """Encode a value as a Sheets cell.
381
+
382
+ Args:
383
+ value: The value to encode.
384
+
385
+ Returns:
386
+ ``""`` for ``None`` and non-finite floats, booleans and numbers as
387
+ is, decimals as strings, dates and datetimes as ISO 8601, dicts and
388
+ lists as JSON, anything else as its string.
389
+ """
390
+ if value is None:
391
+ return ""
392
+ if isinstance(value, bool):
393
+ return value
394
+ if isinstance(value, numbers.Integral):
395
+ return int(value)
396
+ if isinstance(value, numbers.Real):
397
+ number = float(value)
398
+ return number if math.isfinite(number) else ""
399
+ if isinstance(value, Decimal):
400
+ return str(value)
401
+ if isinstance(value, (datetime.date, datetime.datetime)):
402
+ return value.isoformat()
403
+ if isinstance(value, (dict, list)):
404
+ return json.dumps(replace_non_finite(value), default=json_default)
405
+ return str(value)
406
+
407
+
408
+ def _read(cell: Any) -> Any:
409
+ """Decode a stored cell, an empty one reading as ``None``.
410
+
411
+ Args:
412
+ cell: The cell as the API returns it.
413
+
414
+ Returns:
415
+ The cell's value.
416
+ """
417
+ return None if cell == "" else cell
418
+
419
+
420
+ def _text(cell: Any) -> str:
421
+ """Render a cell as the string it compares and groups by.
422
+
423
+ Args:
424
+ cell: A cell as stored or as :func:`_cell` encodes it.
425
+
426
+ Returns:
427
+ Strings as is, anything else as JSON (``5``, ``1.5``, ``true``).
428
+ """
429
+ return cell if isinstance(cell, str) else json.dumps(cell)
430
+
431
+
432
+ def _matches(header: list[Any], row: list[Any], where: PartitionFilter) -> bool:
433
+ """Return whether a stored row falls in a partition filter.
434
+
435
+ Bounds compare as strings, which is chronological because the partition
436
+ column is stored as ISO 8601 (a date is a prefix of the datetimes it
437
+ contains).
438
+
439
+ Args:
440
+ header: The tab's header row.
441
+ row: A stored row, possibly ragged.
442
+ where: The filter to apply.
443
+
444
+ Returns:
445
+ ``True`` if the row's partition cell matches.
446
+ """
447
+ if where.column not in header:
448
+ return False
449
+ index = header.index(where.column)
450
+ cell = _text(row[index] if index < len(row) else "")
451
+ if where.bounds is None:
452
+ return cell == _text(_cell(where.value))
453
+ start, end = where.bounds
454
+ return _text(_cell(start)) <= cell < _text(_cell(end))
455
+
456
+
457
+ def _is_json(spec: FieldSpec) -> bool:
458
+ """Return whether a field is stored as JSON text.
459
+
460
+ Args:
461
+ spec: The field spec.
462
+
463
+ Returns:
464
+ ``True`` for nested and repeated fields, and ``dict`` or ``list`` typed ones.
465
+ """
466
+ return spec.repeated or spec.fields is not None or (get_origin(spec.type) or spec.type) in (dict, list)
@@ -1,19 +0,0 @@
1
- Metadata-Version: 2.3
2
- Name: interloper-google-cloud
3
- Version: 0.93.0
4
- Summary: Interloper Google Cloud integration: BigQuery and Cloud Storage destinations
5
- Author: Guillaume Onfroy
6
- Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
7
- Requires-Dist: google-cloud-bigquery>=3.0
8
- Requires-Dist: google-cloud-storage>=2.0
9
- Requires-Dist: interloper-core
10
- Requires-Dist: interloper-pandas
11
- Requires-Dist: pyarrow>=14
12
- Requires-Dist: db-dtypes>=1.0
13
- Requires-Dist: pandas-gbq>=0.26.1
14
- Requires-Python: >=3.10
15
- Description-Content-Type: text/markdown
16
-
17
- # interloper-google-cloud
18
-
19
- Google Cloud integration for the Interloper framework. Provides a BigQuery destination and Google Cloud connection resource.
@@ -1,3 +0,0 @@
1
- # interloper-google-cloud
2
-
3
- Google Cloud integration for the Interloper framework. Provides a BigQuery destination and Google Cloud connection resource.
@@ -1,239 +0,0 @@
1
- """Google Cloud Storage destination implementation."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- from functools import cached_property
7
- from typing import Any
8
-
9
- import google.auth
10
- from google.cloud import storage
11
- from google.cloud.exceptions import NotFound
12
- from google.oauth2 import service_account
13
- from interloper.destination import IOContext, destination
14
- from interloper.destination.base import Destination
15
- from interloper.errors import DataNotFoundError
16
- from interloper.partitioning import Partition
17
- from interloper.representation import Representation
18
- from interloper.resource.fields import FetchField, InputField, SelectField
19
- from interloper.schema import FieldSpec
20
-
21
- from interloper_google_cloud.connection import GoogleCloudConnection
22
- from interloper_google_cloud.gcs.formats import FORMATS, FileFormat
23
-
24
- # Custom blob metadata key carrying the partition's row count, so
25
- # partition_row_counts introspects from a single list call without downloads.
26
- _ROW_COUNT_METADATA_KEY = "row_count"
27
-
28
-
29
- @destination(
30
- key="gcs_destination",
31
- name="Google Cloud Storage",
32
- icon="icon:gcs",
33
- tags=["Cloud"],
34
- )
35
- class GCSDestination(Destination):
36
- """Google Cloud Storage destination.
37
-
38
- Writes one object per partition in a hive-partitioned layout::
39
-
40
- gs://{bucket}/{prefix}/{dataset}/{table}/data.{ext}
41
- gs://{bucket}/{prefix}/{dataset}/{table}/{column}={partition}/data.{ext}
42
-
43
- Following the hive convention, the partition column lives in the *path
44
- only*: it is dropped from partitioned file contents on write (external
45
- readers like BigQuery external tables and DuckDB reject a duplicate
46
- partition column) and re-injected from the partition on read, so
47
- interloper round-trips stay lossless.
48
- """
49
-
50
- connection: GoogleCloudConnection
51
-
52
- bucket: str = FetchField(
53
- provider="connection.buckets",
54
- label_key="name",
55
- value_key="name",
56
- description="Cloud Storage bucket",
57
- discriminator=True,
58
- )
59
- format: str = SelectField(
60
- default="parquet",
61
- description="Output file format",
62
- options=[
63
- {"label": "Parquet", "value": "parquet"},
64
- {"label": "JSONL", "value": "jsonl"},
65
- {"label": "CSV", "value": "csv"},
66
- ],
67
- )
68
- prefix: str | None = InputField(default=None, description="Path prefix inside the bucket")
69
-
70
- @cached_property
71
- def client(self) -> storage.Client:
72
- """The Cloud Storage client every read and write goes through.
73
-
74
- Built from the connection's service-account key when there is one,
75
- else from ambient credentials (workload identity in-cluster).
76
-
77
- Returns:
78
- The client, cached per destination instance.
79
- """
80
- if self.connection and self.connection.service_account_key:
81
- key_info = json.loads(self.connection.service_account_key)
82
- credentials = service_account.Credentials.from_service_account_info(key_info)
83
- return storage.Client(project=key_info.get("project_id"), credentials=credentials)
84
- credentials, project = google.auth.default()
85
- return storage.Client(project=project, credentials=credentials)
86
-
87
- # -- Helpers ---------------------------------------------------------------
88
-
89
- @property
90
- def _format(self) -> FileFormat:
91
- """The configured file format strategy.
92
-
93
- Returns:
94
- The strategy for the configured format.
95
-
96
- """
97
- return FORMATS[self.format]
98
-
99
- def _asset_prefix(self, context: IOContext) -> str:
100
- """Return the object-name prefix for an asset (no trailing slash).
101
-
102
- Args:
103
- context: The IO context naming the asset.
104
-
105
- Returns:
106
- The prefix every object for the asset sits under.
107
-
108
- """
109
- parts = [self.prefix or "", context.asset.dataset or "", context.asset.table]
110
- return "/".join(part.strip("/") for part in parts if part and part.strip("/"))
111
-
112
- def _blob_name(self, context: IOContext, partition: Partition | None) -> str:
113
- """Build the object name for a partition.
114
-
115
- Args:
116
- context: The IO context naming the asset.
117
- partition: The partition being addressed, or ``None`` for the whole.
118
-
119
- Returns:
120
- ``.../data.{ext}``, inside a ``{column}={id}`` segment for
121
- partitions.
122
- """
123
- parts = [self._asset_prefix(context)]
124
- if partition is not None:
125
- assert context.asset.partitioning
126
- parts.append(f"{context.asset.partitioning.column}={partition.id}")
127
- parts.append(f"data.{self._format.extension}")
128
- return "/".join(parts)
129
-
130
- def _effective_specs(self, context: IOContext, partition: Partition | None) -> list[FieldSpec] | None:
131
- """Return the field specs for a partition's file contents.
132
-
133
- Partitions exclude the partition column (its value lives in the
134
- object path).
135
-
136
- Args:
137
- context: The IO context carrying the schema.
138
- partition: The partition being addressed, or ``None`` for the whole.
139
-
140
- Returns:
141
- The specs, or ``None`` when the context carries no schema.
142
- """
143
- if context.schema is None:
144
- return None
145
- specs = context.schema.field_specs()
146
- if partition is not None:
147
- assert context.asset.partitioning
148
- specs = [spec for spec in specs if spec.name != context.asset.partitioning.column]
149
- return specs
150
-
151
- # -- Partition hooks -------------------------------------------------------
152
-
153
- def write_partition(self, context: IOContext, partition: Partition | None, data: Any) -> None:
154
- """Serialize one partition's data and upload it, overwriting its object.
155
-
156
- The row count is stamped as blob metadata so introspection never has
157
- to download data.
158
-
159
- Args:
160
- context: The IO context naming the asset.
161
- partition: The partition being written, or ``None`` for the whole.
162
- data: The rows to write.
163
-
164
- """
165
- rows = Representation.of(data).records
166
- if partition is not None:
167
- assert context.asset.partitioning
168
- column = context.asset.partitioning.column
169
- rows = [{k: v for k, v in row.items() if k != column} for row in rows]
170
-
171
- payload = self._format.serialize(rows, self._effective_specs(context, partition))
172
- blob = self.client.bucket(self.bucket).blob(self._blob_name(context, partition))
173
- blob.metadata = {_ROW_COUNT_METADATA_KEY: str(len(rows))}
174
- blob.upload_from_string(payload, content_type=self._format.content_type)
175
-
176
- def read_partition(self, context: IOContext, partition: Partition | None) -> list[dict[str, Any]]:
177
- """Download and parse one partition's object.
178
-
179
- The partition column is re-injected from the partition, and rows are
180
- reconciled against the context schema when one is set (restoring the
181
- declared types — text formats read everything back as strings).
182
-
183
- Args:
184
- context: The IO context carrying the schema.
185
- partition: The partition being read, or ``None`` for the whole.
186
-
187
- Returns:
188
- Rows as a list of dicts.
189
-
190
- Raises:
191
- DataNotFoundError: If the partition's object does not exist.
192
- """
193
- name = self._blob_name(context, partition)
194
- try:
195
- payload = self.client.bucket(self.bucket).blob(name).download_as_bytes()
196
- except NotFound:
197
- raise DataNotFoundError(
198
- f"Object 'gs://{self.bucket}/{name}' does not exist. Has the asset been materialized?"
199
- ) from None
200
-
201
- rows = self._format.deserialize(payload)
202
- if partition is not None:
203
- assert context.asset.partitioning
204
- column = context.asset.partitioning.column
205
- rows = [{**row, column: partition.id} for row in rows]
206
- if context.schema is not None:
207
- rows = context.schema.reconcile(rows)
208
- return rows
209
-
210
- # -- Introspection ---------------------------------------------------------
211
-
212
- def partition_row_counts(self, context: IOContext) -> dict[str, int]:
213
- """Return row counts grouped by partition from a single list call.
214
-
215
- Counts come from the ``row_count`` blob metadata stamped at write
216
- time; objects missing it (written by other tools) are downloaded and
217
- counted.
218
-
219
- Args:
220
- context: The IO context naming the asset.
221
-
222
- Returns:
223
- Mapping from partition value (as string) to row count.
224
- """
225
- assert context.asset.partitioning is not None
226
- column = context.asset.partitioning.column
227
- prefix = self._asset_prefix(context) + "/"
228
-
229
- counts: dict[str, int] = {}
230
- for blob in self.client.list_blobs(self.bucket, prefix=prefix):
231
- segment = blob.name[len(prefix) :].split("/", 1)[0]
232
- if not segment.startswith(f"{column}="):
233
- continue
234
- value = segment.split("=", 1)[1]
235
- row_count = (blob.metadata or {}).get(_ROW_COUNT_METADATA_KEY)
236
- if row_count is None:
237
- row_count = len(self._format.deserialize(blob.download_as_bytes()))
238
- counts[value] = counts.get(value, 0) + int(row_count)
239
- return counts
@@ -1,265 +0,0 @@
1
- """File formats for the GCS destination: records ⇄ bytes, one class per format."""
2
-
3
- from __future__ import annotations
4
-
5
- import csv
6
- import datetime
7
- import io
8
- import json
9
- from abc import ABC, abstractmethod
10
- from decimal import Decimal
11
- from typing import Any, ClassVar
12
-
13
- import pyarrow as pa
14
- import pyarrow.parquet as pq
15
- from interloper.schema import FieldSpec
16
-
17
- from interloper_google_cloud.serialization import json_default, replace_non_finite
18
-
19
-
20
- class FileFormat(ABC):
21
- """Serialization strategy for one file format.
22
-
23
- ``serialize`` receives records (and the effective field specs, when a
24
- schema is known) and returns the object payload; ``deserialize`` parses a
25
- payload back into records. Type restoration from text formats is the
26
- caller's concern (``Schema.reconcile``) — formats only deal in bytes.
27
- """
28
-
29
- key: ClassVar[str]
30
- extension: ClassVar[str]
31
- content_type: ClassVar[str]
32
-
33
- @abstractmethod
34
- def serialize(self, rows: list[dict[str, Any]], specs: list[FieldSpec] | None) -> bytes:
35
- """Serialize records into the format's byte payload.
36
-
37
- Args:
38
- rows: The records to serialize.
39
- specs: The field specs, when the format uses a declared schema.
40
-
41
- Returns:
42
- The encoded payload.
43
-
44
- """
45
-
46
- @abstractmethod
47
- def deserialize(self, payload: bytes) -> list[dict[str, Any]]:
48
- """Parse a byte payload back into records.
49
-
50
- Args:
51
- payload: The encoded payload.
52
-
53
- Returns:
54
- One dict per record.
55
-
56
- """
57
-
58
-
59
- class JSONLFormat(FileFormat):
60
- """Newline-delimited JSON — the load format BigQuery and most lakes expect."""
61
-
62
- key = "jsonl"
63
- extension = "jsonl"
64
- content_type = "application/jsonl"
65
-
66
- def serialize(self, rows: list[dict[str, Any]], specs: list[FieldSpec] | None) -> bytes:
67
- """Serialize records as one JSON object per line.
68
-
69
- Non-finite floats become ``null`` (invalid JSON otherwise); dates and
70
- decimals serialize via :func:`json_default`.
71
-
72
- Args:
73
- rows: The records to serialize.
74
- specs: Accepted for the interface and unused by NDJSON.
75
-
76
- Returns:
77
- The NDJSON payload.
78
- """
79
- lines = (json.dumps(replace_non_finite(row), default=json_default) for row in rows)
80
- return "\n".join(lines).encode()
81
-
82
- def deserialize(self, payload: bytes) -> list[dict[str, Any]]:
83
- """Parse NDJSON lines into records.
84
-
85
- Args:
86
- payload: The NDJSON payload.
87
-
88
- Returns:
89
- One dict per non-empty line.
90
- """
91
- return [json.loads(line) for line in payload.decode().splitlines() if line.strip()]
92
-
93
-
94
- class CSVFormat(FileFormat):
95
- """CSV with a header row; every value is stored as a string."""
96
-
97
- key = "csv"
98
- extension = "csv"
99
- content_type = "text/csv"
100
-
101
- def serialize(self, rows: list[dict[str, Any]], specs: list[FieldSpec] | None) -> bytes:
102
- """Serialize records as CSV (the first row's keys are the headers).
103
-
104
- Args:
105
- rows: The records to serialize.
106
- specs: Accepted for the interface and unused by CSV.
107
-
108
- Returns:
109
- The CSV payload; empty for no rows.
110
- """
111
- if not rows:
112
- return b""
113
- buffer = io.StringIO()
114
- writer = csv.DictWriter(buffer, fieldnames=list(rows[0].keys()))
115
- writer.writeheader()
116
- writer.writerows(rows)
117
- return buffer.getvalue().encode()
118
-
119
- def deserialize(self, payload: bytes) -> list[dict[str, Any]]:
120
- """Parse CSV into records, reading empty cells as ``None``.
121
-
122
- Args:
123
- payload: The CSV payload.
124
-
125
- Returns:
126
- One dict per data row.
127
- """
128
- reader = csv.DictReader(io.StringIO(payload.decode()))
129
- return [{k: (None if v == "" else v) for k, v in row.items()} for row in reader]
130
-
131
-
132
- class ParquetFormat(FileFormat):
133
- """Parquet via pyarrow — full type fidelity, ideal for external tables."""
134
-
135
- key = "parquet"
136
- extension = "parquet"
137
- content_type = "application/vnd.apache.parquet"
138
-
139
- def serialize(self, rows: list[dict[str, Any]], specs: list[FieldSpec] | None) -> bytes:
140
- """Serialize records as a Parquet file.
141
-
142
- With field specs, the Arrow schema is built from them so the file
143
- shape is deterministic and stable across partitions; without, pyarrow
144
- infers it from the data.
145
-
146
- Args:
147
- rows: The records to serialize.
148
- specs: The field specs the Arrow schema is built from, when given.
149
-
150
- Returns:
151
- The Parquet payload.
152
- """
153
- if specs is not None:
154
- rows = _stringify_untyped(rows, specs)
155
- table = pa.Table.from_pylist(rows, schema=pa.schema([_spec_to_arrow_field(spec) for spec in specs]))
156
- else:
157
- table = pa.Table.from_pylist(rows)
158
- buffer = pa.BufferOutputStream()
159
- pq.write_table(table, buffer)
160
- return buffer.getvalue().to_pybytes()
161
-
162
- def deserialize(self, payload: bytes) -> list[dict[str, Any]]:
163
- """Read a Parquet payload into records (types preserved).
164
-
165
- Args:
166
- payload: The Parquet payload.
167
-
168
- Returns:
169
- One dict per row.
170
- """
171
- return pq.read_table(pa.BufferReader(payload)).to_pylist()
172
-
173
-
174
- FORMATS: dict[str, FileFormat] = {fmt.key: fmt for fmt in (ParquetFormat(), JSONLFormat(), CSVFormat())}
175
-
176
-
177
- def _spec_to_arrow_field(spec: FieldSpec) -> pa.Field:
178
- """Map a field spec to an Arrow field (nested records as structs).
179
-
180
- Args:
181
- spec: The field spec to map.
182
-
183
- Returns:
184
- The Arrow field, wrapped in ``list_`` when the spec is repeated.
185
- """
186
- if spec.fields is not None:
187
- arrow_type: pa.DataType = pa.struct([_spec_to_arrow_field(sub) for sub in spec.fields])
188
- else:
189
- arrow_type = _py_type_to_arrow_type(spec.type)
190
- if spec.repeated:
191
- arrow_type = pa.list_(arrow_type)
192
- return pa.field(spec.name, arrow_type, nullable=spec.nullable)
193
-
194
-
195
- def _is_concrete_type(py_type: Any) -> bool:
196
- """Return whether *py_type* is a real class the Arrow mapping can use.
197
-
198
- ``typing.Any`` is excluded explicitly — on Python 3.11+ it *is* an
199
- ``isinstance(..., type)``, but it types nothing.
200
-
201
- Args:
202
- py_type: The declared type to test.
203
-
204
- Returns:
205
- True when the type is a concrete class.
206
-
207
- """
208
- return py_type is not Any and isinstance(py_type, type)
209
-
210
-
211
- def _py_type_to_arrow_type(py_type: Any) -> pa.DataType:
212
- """Map a Python *type* (from a FieldSpec) to an Arrow type.
213
-
214
- Mirrors the BigQuery mapping (``Decimal`` → NUMERIC precision/scale) so
215
- parquet files load into BigQuery without casts.
216
-
217
- Args:
218
- py_type: The declared Python type.
219
-
220
- Returns:
221
- The Arrow type.
222
-
223
- """
224
- if not _is_concrete_type(py_type):
225
- return pa.string() # typing.Any or unresolvable annotations
226
- if issubclass(py_type, bool):
227
- return pa.bool_()
228
- if issubclass(py_type, int):
229
- return pa.int64()
230
- if issubclass(py_type, float):
231
- return pa.float64()
232
- if issubclass(py_type, Decimal):
233
- return pa.decimal128(38, 9)
234
- if issubclass(py_type, datetime.datetime):
235
- return pa.timestamp("us")
236
- if issubclass(py_type, datetime.date):
237
- return pa.date32()
238
- if issubclass(py_type, bytes):
239
- return pa.binary()
240
- return pa.string()
241
-
242
-
243
- def _stringify_untyped(rows: list[dict[str, Any]], specs: list[FieldSpec]) -> list[dict[str, Any]]:
244
- """Stringify values of untyped (``Any``) scalar fields.
245
-
246
- Those fields map to Arrow strings, and ``from_pylist`` does not coerce
247
- e.g. ints into a string column — pre-convert so the write cannot fail on
248
- a column the schema could not type.
249
-
250
- Args:
251
- rows: The records to rewrite.
252
- specs: The field specs deciding which fields are untyped.
253
-
254
- Returns:
255
- The rows, copied only when a conversion applies.
256
- """
257
- untyped = {
258
- spec.name for spec in specs if spec.fields is None and not spec.repeated and not _is_concrete_type(spec.type)
259
- }
260
- if not untyped:
261
- return rows
262
- return [
263
- {k: (str(v) if k in untyped and v is not None and not isinstance(v, str) else v) for k, v in row.items()}
264
- for row in rows
265
- ]
@@ -1,53 +0,0 @@
1
- """JSON serialization helpers shared by the Google Cloud destinations."""
2
-
3
- from __future__ import annotations
4
-
5
- import datetime
6
- import math
7
- from decimal import Decimal
8
- from typing import Any
9
-
10
-
11
- def replace_non_finite(obj: Any) -> Any:
12
- """Recursively replace non-finite floats (``NaN``, ``Infinity``) with ``None``.
13
-
14
- pandas represents missing numeric values as ``float('nan')``. Python's
15
- ``json.dumps`` serialises these to the bare tokens ``NaN`` / ``Infinity``,
16
- which are invalid JSON — BigQuery's load parser rejects them with "Parser
17
- terminated before end of string". Neither BigQuery nor JSONL files have a
18
- NaN concept, so map non-finite floats to ``None`` (JSON ``null``).
19
-
20
- Args:
21
- obj: The value or container to rewrite.
22
-
23
- Returns:
24
- The value with non-finite floats replaced.
25
-
26
- """
27
- if isinstance(obj, float):
28
- return obj if math.isfinite(obj) else None
29
- if isinstance(obj, dict):
30
- return {k: replace_non_finite(v) for k, v in obj.items()}
31
- if isinstance(obj, list):
32
- return [replace_non_finite(v) for v in obj]
33
- return obj
34
-
35
-
36
- def json_default(o: Any) -> Any:
37
- """JSON serializer for types not handled by the default encoder.
38
-
39
- Args:
40
- o: The object the encoder could not serialize.
41
-
42
- Returns:
43
- A JSON-encodable stand-in.
44
-
45
- Raises:
46
- TypeError: If the object has no JSON representation.
47
-
48
- """
49
- if isinstance(o, (datetime.date, datetime.datetime)):
50
- return o.isoformat()
51
- if isinstance(o, Decimal):
52
- return str(o)
53
- raise TypeError(f"Object of type {type(o).__name__} is not JSON serializable")