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.
- interloper_google_cloud-0.94.2/PKG-INFO +65 -0
- interloper_google_cloud-0.94.2/README.md +49 -0
- {interloper_google_cloud-0.93.0 → interloper_google_cloud-0.94.2}/pyproject.toml +2 -2
- {interloper_google_cloud-0.93.0 → interloper_google_cloud-0.94.2}/pyproject.toml.orig +2 -2
- {interloper_google_cloud-0.93.0 → interloper_google_cloud-0.94.2}/src/interloper_google_cloud/__init__.py +3 -1
- interloper_google_cloud-0.94.2/src/interloper_google_cloud/gcs/destination.py +113 -0
- interloper_google_cloud-0.94.2/src/interloper_google_cloud/google_sheets/__init__.py +7 -0
- interloper_google_cloud-0.94.2/src/interloper_google_cloud/google_sheets/destination.py +466 -0
- interloper_google_cloud-0.93.0/PKG-INFO +0 -19
- interloper_google_cloud-0.93.0/README.md +0 -3
- interloper_google_cloud-0.93.0/src/interloper_google_cloud/gcs/destination.py +0 -239
- interloper_google_cloud-0.93.0/src/interloper_google_cloud/gcs/formats.py +0 -265
- interloper_google_cloud-0.93.0/src/interloper_google_cloud/serialization.py +0 -53
- {interloper_google_cloud-0.93.0 → interloper_google_cloud-0.94.2}/src/interloper_google_cloud/bigquery/__init__.py +0 -0
- {interloper_google_cloud-0.93.0 → interloper_google_cloud-0.94.2}/src/interloper_google_cloud/bigquery/destination.py +0 -0
- {interloper_google_cloud-0.93.0 → interloper_google_cloud-0.94.2}/src/interloper_google_cloud/bigquery/types.py +0 -0
- {interloper_google_cloud-0.93.0 → interloper_google_cloud-0.94.2}/src/interloper_google_cloud/connection.py +0 -0
- {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.
|
|
4
|
-
description = "Interloper Google Cloud integration: BigQuery
|
|
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.
|
|
7
|
-
description = "Interloper Google Cloud integration: BigQuery
|
|
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
|
|
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,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,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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|