interloper-google-cloud 0.72.0__tar.gz → 0.74.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {interloper_google_cloud-0.72.0 → interloper_google_cloud-0.74.0}/PKG-INFO +1 -1
- {interloper_google_cloud-0.72.0 → interloper_google_cloud-0.74.0}/pyproject.toml +11 -1
- {interloper_google_cloud-0.72.0 → interloper_google_cloud-0.74.0}/pyproject.toml.orig +7 -3
- {interloper_google_cloud-0.72.0 → interloper_google_cloud-0.74.0}/src/interloper_google_cloud/bigquery/destination.py +45 -3
- {interloper_google_cloud-0.72.0 → interloper_google_cloud-0.74.0}/src/interloper_google_cloud/connection.py +28 -1
- {interloper_google_cloud-0.72.0 → interloper_google_cloud-0.74.0}/src/interloper_google_cloud/gcs/destination.py +45 -2
- {interloper_google_cloud-0.72.0 → interloper_google_cloud-0.74.0}/src/interloper_google_cloud/gcs/formats.py +61 -2
- {interloper_google_cloud-0.72.0 → interloper_google_cloud-0.74.0}/src/interloper_google_cloud/serialization.py +19 -1
- {interloper_google_cloud-0.72.0 → interloper_google_cloud-0.74.0}/README.md +0 -0
- {interloper_google_cloud-0.72.0 → interloper_google_cloud-0.74.0}/src/interloper_google_cloud/__init__.py +0 -0
- {interloper_google_cloud-0.72.0 → interloper_google_cloud-0.74.0}/src/interloper_google_cloud/bigquery/__init__.py +0 -0
- {interloper_google_cloud-0.72.0 → interloper_google_cloud-0.74.0}/src/interloper_google_cloud/gcs/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "interloper-google-cloud"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.74.0"
|
|
4
4
|
description = "Interloper Google Cloud integration: BigQuery and Cloud Storage destinations"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -34,6 +34,7 @@ workspace = true
|
|
|
34
34
|
line-length = 120
|
|
35
35
|
|
|
36
36
|
[tool.ruff.lint]
|
|
37
|
+
preview = true
|
|
37
38
|
extend-select = [
|
|
38
39
|
"E",
|
|
39
40
|
"I",
|
|
@@ -41,8 +42,13 @@ extend-select = [
|
|
|
41
42
|
"ANN001",
|
|
42
43
|
"ANN201",
|
|
43
44
|
"ANN202",
|
|
45
|
+
"DOC",
|
|
46
|
+
"D",
|
|
44
47
|
]
|
|
45
48
|
|
|
49
|
+
[tool.ruff.lint.pydocstyle]
|
|
50
|
+
convention = "google"
|
|
51
|
+
|
|
46
52
|
[tool.ruff.lint.per-file-ignores]
|
|
47
53
|
"__init__.py" = [
|
|
48
54
|
"F401",
|
|
@@ -51,4 +57,8 @@ extend-select = [
|
|
|
51
57
|
"tests/**" = [
|
|
52
58
|
"ANN",
|
|
53
59
|
"F811",
|
|
60
|
+
"D101",
|
|
61
|
+
"D102",
|
|
62
|
+
"D103",
|
|
63
|
+
"D104",
|
|
54
64
|
]
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# ###############
|
|
4
4
|
[project]
|
|
5
5
|
name = "interloper-google-cloud"
|
|
6
|
-
version = "0.
|
|
6
|
+
version = "0.74.0"
|
|
7
7
|
description = "Interloper Google Cloud integration: BigQuery and Cloud Storage destinations"
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
|
|
@@ -35,8 +35,12 @@ interloper-pandas = { workspace = true }
|
|
|
35
35
|
line-length = 120
|
|
36
36
|
|
|
37
37
|
[tool.ruff.lint]
|
|
38
|
-
|
|
38
|
+
preview = true
|
|
39
|
+
extend-select = ["E", "I", "UP", "ANN001", "ANN201", "ANN202", "DOC", "D"]
|
|
40
|
+
|
|
41
|
+
[tool.ruff.lint.pydocstyle]
|
|
42
|
+
convention = "google"
|
|
39
43
|
|
|
40
44
|
[tool.ruff.lint.per-file-ignores]
|
|
41
45
|
"__init__.py" = ["F401", "F403"]
|
|
42
|
-
"tests/**" = ["ANN", "F811"]
|
|
46
|
+
"tests/**" = ["ANN", "F811", "D101", "D102", "D103", "D104"]
|
|
@@ -63,6 +63,14 @@ class BigQueryDestination(DatabaseDestination):
|
|
|
63
63
|
|
|
64
64
|
@cached_property
|
|
65
65
|
def client(self) -> bigquery.Client:
|
|
66
|
+
"""The BigQuery client every write goes through.
|
|
67
|
+
|
|
68
|
+
Built from the connection's service-account key when there is one,
|
|
69
|
+
else from ambient credentials (workload identity in-cluster).
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The client, cached per destination instance.
|
|
73
|
+
"""
|
|
66
74
|
if self.connection and self.connection.service_account_key:
|
|
67
75
|
key_info = json.loads(self.connection.service_account_key)
|
|
68
76
|
credentials = service_account.Credentials.from_service_account_info(key_info)
|
|
@@ -522,6 +530,7 @@ class BigQueryDestination(DatabaseDestination):
|
|
|
522
530
|
# -- Lifecycle -------------------------------------------------------------
|
|
523
531
|
|
|
524
532
|
def dispose(self) -> None:
|
|
533
|
+
"""Close the BigQuery client, if one was ever built."""
|
|
525
534
|
if self.client:
|
|
526
535
|
self.client.close()
|
|
527
536
|
|
|
@@ -544,6 +553,9 @@ def _schema_to_bq_fields(schema: type[Schema]) -> list[bigquery.SchemaField]:
|
|
|
544
553
|
def _specs_to_bq_fields(specs: list[FieldSpec] | tuple[FieldSpec, ...]) -> list[bigquery.SchemaField]:
|
|
545
554
|
"""Map field specs to BigQuery field definitions.
|
|
546
555
|
|
|
556
|
+
Args:
|
|
557
|
+
specs: The field specs to map.
|
|
558
|
+
|
|
547
559
|
Returns:
|
|
548
560
|
One ``SchemaField`` per spec, carrying the spec's description.
|
|
549
561
|
"""
|
|
@@ -568,6 +580,9 @@ def _asset_description(asset: Any) -> str | None:
|
|
|
568
580
|
|
|
569
581
|
This mirrors how ``Component.definition()`` derives descriptions.
|
|
570
582
|
|
|
583
|
+
Args:
|
|
584
|
+
asset: The asset whose docstring is read.
|
|
585
|
+
|
|
571
586
|
Returns:
|
|
572
587
|
The cleaned docstring, or ``None`` when the asset has none.
|
|
573
588
|
"""
|
|
@@ -751,6 +766,9 @@ def _partition_param(
|
|
|
751
766
|
def _iso_string(value: Any) -> str:
|
|
752
767
|
"""Render a bound as an ISO-8601 string for STRING-typed columns.
|
|
753
768
|
|
|
769
|
+
Args:
|
|
770
|
+
value: The bound to render.
|
|
771
|
+
|
|
754
772
|
Returns:
|
|
755
773
|
The ISO rendering of *value*.
|
|
756
774
|
"""
|
|
@@ -758,7 +776,15 @@ def _iso_string(value: Any) -> str:
|
|
|
758
776
|
|
|
759
777
|
|
|
760
778
|
def _py_type_to_bq_type(py_type: Any) -> str:
|
|
761
|
-
"""Map a Python *type* (from a FieldSpec) to a BigQuery column type.
|
|
779
|
+
"""Map a Python *type* (from a FieldSpec) to a BigQuery column type.
|
|
780
|
+
|
|
781
|
+
Args:
|
|
782
|
+
py_type: The declared Python type.
|
|
783
|
+
|
|
784
|
+
Returns:
|
|
785
|
+
The BigQuery column type name.
|
|
786
|
+
|
|
787
|
+
"""
|
|
762
788
|
if not isinstance(py_type, type):
|
|
763
789
|
return "STRING" # typing.Any or unresolvable annotations
|
|
764
790
|
if issubclass(py_type, bool):
|
|
@@ -813,7 +839,15 @@ def _infer_bq_schema(rows: list[dict[str, Any]]) -> list[bigquery.SchemaField]:
|
|
|
813
839
|
|
|
814
840
|
|
|
815
841
|
def _py_to_bq_type(value: Any) -> str:
|
|
816
|
-
"""Infer a BigQuery field type from a Python value.
|
|
842
|
+
"""Infer a BigQuery field type from a Python value.
|
|
843
|
+
|
|
844
|
+
Args:
|
|
845
|
+
value: The value to inspect.
|
|
846
|
+
|
|
847
|
+
Returns:
|
|
848
|
+
The inferred BigQuery field type name.
|
|
849
|
+
|
|
850
|
+
"""
|
|
817
851
|
if isinstance(value, bool):
|
|
818
852
|
return "BOOLEAN"
|
|
819
853
|
if isinstance(value, int):
|
|
@@ -832,7 +866,15 @@ def _py_to_bq_type(value: Any) -> str:
|
|
|
832
866
|
|
|
833
867
|
|
|
834
868
|
def _bq_to_py_type(value: Any) -> str:
|
|
835
|
-
"""Map a Python value to a BigQuery query parameter type.
|
|
869
|
+
"""Map a Python value to a BigQuery query parameter type.
|
|
870
|
+
|
|
871
|
+
Args:
|
|
872
|
+
value: The parameter value.
|
|
873
|
+
|
|
874
|
+
Returns:
|
|
875
|
+
The BigQuery query-parameter type name.
|
|
876
|
+
|
|
877
|
+
"""
|
|
836
878
|
if isinstance(value, bool):
|
|
837
879
|
return "BOOL"
|
|
838
880
|
if isinstance(value, int):
|
|
@@ -46,7 +46,17 @@ def _make_assertion(key_info: dict[str, Any], scope: str) -> str:
|
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
async def _get_access_token(client: httpx.AsyncClient, key_info: dict[str, Any], scope: str) -> str:
|
|
49
|
-
"""Exchange a service account JWT assertion for an access token.
|
|
49
|
+
"""Exchange a service account JWT assertion for an access token.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
client: The HTTP client the exchange is made through.
|
|
53
|
+
key_info: The parsed service-account key.
|
|
54
|
+
scope: The OAuth scope requested.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The bearer token.
|
|
58
|
+
|
|
59
|
+
"""
|
|
50
60
|
resp = await client.post(
|
|
51
61
|
_TOKEN_URL,
|
|
52
62
|
data={
|
|
@@ -61,6 +71,10 @@ async def _get_access_token(client: httpx.AsyncClient, key_info: dict[str, Any],
|
|
|
61
71
|
async def _list_projects(client: httpx.AsyncClient, access_token: str) -> list[dict[str, str]]:
|
|
62
72
|
"""List the projects the credential has BigQuery access to, following pagination.
|
|
63
73
|
|
|
74
|
+
Args:
|
|
75
|
+
client: The HTTP client the listing is made through.
|
|
76
|
+
access_token: Bearer token for the listing.
|
|
77
|
+
|
|
64
78
|
Returns:
|
|
65
79
|
Project options with ``project_id`` and a display ``name``.
|
|
66
80
|
"""
|
|
@@ -90,6 +104,11 @@ async def _list_projects(client: httpx.AsyncClient, access_token: str) -> list[d
|
|
|
90
104
|
async def _list_buckets(client: httpx.AsyncClient, access_token: str, project: str) -> list[dict[str, str]]:
|
|
91
105
|
"""List the Cloud Storage buckets in *project*, following pagination.
|
|
92
106
|
|
|
107
|
+
Args:
|
|
108
|
+
client: The HTTP client the listing is made through.
|
|
109
|
+
access_token: Bearer token for the listing.
|
|
110
|
+
project: The project whose buckets are listed.
|
|
111
|
+
|
|
93
112
|
Returns:
|
|
94
113
|
Bucket options with ``name``.
|
|
95
114
|
"""
|
|
@@ -141,6 +160,10 @@ class GoogleCloudConnection(Connection):
|
|
|
141
160
|
JWT-bearer assertion from the service account key and exchanges it for
|
|
142
161
|
an access token over httpx (only the signing uses google-auth), then
|
|
143
162
|
pages through BigQuery's ``projects.list``.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Project options for the field's dropdown.
|
|
166
|
+
|
|
144
167
|
"""
|
|
145
168
|
key_info: dict[str, Any] = json.loads(self.service_account_key)
|
|
146
169
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
@@ -155,6 +178,10 @@ class GoogleCloudConnection(Connection):
|
|
|
155
178
|
exchange as ``projects``, with the read-only storage scope; the project
|
|
156
179
|
comes from the key itself (bucket names are global, but listing is
|
|
157
180
|
per-project).
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Bucket options for the field's dropdown.
|
|
184
|
+
|
|
158
185
|
"""
|
|
159
186
|
key_info: dict[str, Any] = json.loads(self.service_account_key)
|
|
160
187
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
@@ -69,6 +69,14 @@ class GCSDestination(PartitionedDestination):
|
|
|
69
69
|
|
|
70
70
|
@cached_property
|
|
71
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
|
+
"""
|
|
72
80
|
if self.connection and self.connection.service_account_key:
|
|
73
81
|
key_info = json.loads(self.connection.service_account_key)
|
|
74
82
|
credentials = service_account.Credentials.from_service_account_info(key_info)
|
|
@@ -80,17 +88,34 @@ class GCSDestination(PartitionedDestination):
|
|
|
80
88
|
|
|
81
89
|
@property
|
|
82
90
|
def _format(self) -> FileFormat:
|
|
83
|
-
"""The configured file format strategy.
|
|
91
|
+
"""The configured file format strategy.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The strategy for the configured format.
|
|
95
|
+
|
|
96
|
+
"""
|
|
84
97
|
return FORMATS[self.format]
|
|
85
98
|
|
|
86
99
|
def _asset_prefix(self, context: IOContext) -> str:
|
|
87
|
-
"""Return the object-name prefix for an asset (no trailing slash).
|
|
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
|
+
"""
|
|
88
109
|
parts = [self.prefix or "", context.asset.dataset or "", context.asset.table]
|
|
89
110
|
return "/".join(part.strip("/") for part in parts if part and part.strip("/"))
|
|
90
111
|
|
|
91
112
|
def _blob_name(self, context: IOContext, partition: Partition | None) -> str:
|
|
92
113
|
"""Build the object name for a scope.
|
|
93
114
|
|
|
115
|
+
Args:
|
|
116
|
+
context: The IO context naming the asset.
|
|
117
|
+
partition: The partition being addressed, when scoped.
|
|
118
|
+
|
|
94
119
|
Returns:
|
|
95
120
|
``.../data.{ext}``, inside a ``{column}={id}`` segment for
|
|
96
121
|
partition scopes.
|
|
@@ -108,6 +133,10 @@ class GCSDestination(PartitionedDestination):
|
|
|
108
133
|
Partition scopes exclude the partition column (its value lives in the
|
|
109
134
|
object path).
|
|
110
135
|
|
|
136
|
+
Args:
|
|
137
|
+
context: The IO context carrying the schema.
|
|
138
|
+
partition: The partition being addressed, when scoped.
|
|
139
|
+
|
|
111
140
|
Returns:
|
|
112
141
|
The specs, or ``None`` when the context carries no schema.
|
|
113
142
|
"""
|
|
@@ -126,6 +155,12 @@ class GCSDestination(PartitionedDestination):
|
|
|
126
155
|
|
|
127
156
|
The row count is stamped as blob metadata so introspection never has
|
|
128
157
|
to download data.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
context: The IO context naming the asset.
|
|
161
|
+
partition: The partition being written, when scoped.
|
|
162
|
+
data: The rows to write.
|
|
163
|
+
|
|
129
164
|
"""
|
|
130
165
|
rows = Representation.of(data).to_records(data)
|
|
131
166
|
if partition is not None:
|
|
@@ -145,6 +180,10 @@ class GCSDestination(PartitionedDestination):
|
|
|
145
180
|
reconciled against the context schema when one is set (restoring the
|
|
146
181
|
declared types — text formats read everything back as strings).
|
|
147
182
|
|
|
183
|
+
Args:
|
|
184
|
+
context: The IO context carrying the schema.
|
|
185
|
+
partition: The partition being read, when scoped.
|
|
186
|
+
|
|
148
187
|
Returns:
|
|
149
188
|
Rows as a list of dicts.
|
|
150
189
|
|
|
@@ -177,6 +216,9 @@ class GCSDestination(PartitionedDestination):
|
|
|
177
216
|
time; objects missing it (written by other tools) are downloaded and
|
|
178
217
|
counted.
|
|
179
218
|
|
|
219
|
+
Args:
|
|
220
|
+
context: The IO context naming the asset.
|
|
221
|
+
|
|
180
222
|
Returns:
|
|
181
223
|
Mapping from partition value (as string) to row count.
|
|
182
224
|
"""
|
|
@@ -199,5 +241,6 @@ class GCSDestination(PartitionedDestination):
|
|
|
199
241
|
# -- Lifecycle -------------------------------------------------------------
|
|
200
242
|
|
|
201
243
|
def dispose(self) -> None:
|
|
244
|
+
"""Close the storage client, if one was ever built."""
|
|
202
245
|
if self.client:
|
|
203
246
|
self.client.close()
|
|
@@ -32,11 +32,28 @@ class FileFormat(ABC):
|
|
|
32
32
|
|
|
33
33
|
@abstractmethod
|
|
34
34
|
def serialize(self, rows: list[dict[str, Any]], specs: list[FieldSpec] | None) -> bytes:
|
|
35
|
-
"""Serialize records into the format's byte payload.
|
|
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
|
+
"""
|
|
36
45
|
|
|
37
46
|
@abstractmethod
|
|
38
47
|
def deserialize(self, payload: bytes) -> list[dict[str, Any]]:
|
|
39
|
-
"""Parse a byte payload back into records.
|
|
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
|
+
"""
|
|
40
57
|
|
|
41
58
|
|
|
42
59
|
class JSONLFormat(FileFormat):
|
|
@@ -52,6 +69,10 @@ class JSONLFormat(FileFormat):
|
|
|
52
69
|
Non-finite floats become ``null`` (invalid JSON otherwise); dates and
|
|
53
70
|
decimals serialize via :func:`json_default`.
|
|
54
71
|
|
|
72
|
+
Args:
|
|
73
|
+
rows: The records to serialize.
|
|
74
|
+
specs: Accepted for the interface and unused by NDJSON.
|
|
75
|
+
|
|
55
76
|
Returns:
|
|
56
77
|
The NDJSON payload.
|
|
57
78
|
"""
|
|
@@ -61,6 +82,9 @@ class JSONLFormat(FileFormat):
|
|
|
61
82
|
def deserialize(self, payload: bytes) -> list[dict[str, Any]]:
|
|
62
83
|
"""Parse NDJSON lines into records.
|
|
63
84
|
|
|
85
|
+
Args:
|
|
86
|
+
payload: The NDJSON payload.
|
|
87
|
+
|
|
64
88
|
Returns:
|
|
65
89
|
One dict per non-empty line.
|
|
66
90
|
"""
|
|
@@ -77,6 +101,10 @@ class CSVFormat(FileFormat):
|
|
|
77
101
|
def serialize(self, rows: list[dict[str, Any]], specs: list[FieldSpec] | None) -> bytes:
|
|
78
102
|
"""Serialize records as CSV (the first row's keys are the headers).
|
|
79
103
|
|
|
104
|
+
Args:
|
|
105
|
+
rows: The records to serialize.
|
|
106
|
+
specs: Accepted for the interface and unused by CSV.
|
|
107
|
+
|
|
80
108
|
Returns:
|
|
81
109
|
The CSV payload; empty for no rows.
|
|
82
110
|
"""
|
|
@@ -91,6 +119,9 @@ class CSVFormat(FileFormat):
|
|
|
91
119
|
def deserialize(self, payload: bytes) -> list[dict[str, Any]]:
|
|
92
120
|
"""Parse CSV into records, reading empty cells as ``None``.
|
|
93
121
|
|
|
122
|
+
Args:
|
|
123
|
+
payload: The CSV payload.
|
|
124
|
+
|
|
94
125
|
Returns:
|
|
95
126
|
One dict per data row.
|
|
96
127
|
"""
|
|
@@ -112,6 +143,10 @@ class ParquetFormat(FileFormat):
|
|
|
112
143
|
shape is deterministic and stable across partitions; without, pyarrow
|
|
113
144
|
infers it from the data.
|
|
114
145
|
|
|
146
|
+
Args:
|
|
147
|
+
rows: The records to serialize.
|
|
148
|
+
specs: The field specs the Arrow schema is built from, when given.
|
|
149
|
+
|
|
115
150
|
Returns:
|
|
116
151
|
The Parquet payload.
|
|
117
152
|
"""
|
|
@@ -127,6 +162,9 @@ class ParquetFormat(FileFormat):
|
|
|
127
162
|
def deserialize(self, payload: bytes) -> list[dict[str, Any]]:
|
|
128
163
|
"""Read a Parquet payload into records (types preserved).
|
|
129
164
|
|
|
165
|
+
Args:
|
|
166
|
+
payload: The Parquet payload.
|
|
167
|
+
|
|
130
168
|
Returns:
|
|
131
169
|
One dict per row.
|
|
132
170
|
"""
|
|
@@ -139,6 +177,9 @@ FORMATS: dict[str, FileFormat] = {fmt.key: fmt for fmt in (ParquetFormat(), JSON
|
|
|
139
177
|
def _spec_to_arrow_field(spec: FieldSpec) -> pa.Field:
|
|
140
178
|
"""Map a field spec to an Arrow field (nested records as structs).
|
|
141
179
|
|
|
180
|
+
Args:
|
|
181
|
+
spec: The field spec to map.
|
|
182
|
+
|
|
142
183
|
Returns:
|
|
143
184
|
The Arrow field, wrapped in ``list_`` when the spec is repeated.
|
|
144
185
|
"""
|
|
@@ -156,6 +197,13 @@ def _is_concrete_type(py_type: Any) -> bool:
|
|
|
156
197
|
|
|
157
198
|
``typing.Any`` is excluded explicitly — on Python 3.11+ it *is* an
|
|
158
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
|
+
|
|
159
207
|
"""
|
|
160
208
|
return py_type is not Any and isinstance(py_type, type)
|
|
161
209
|
|
|
@@ -165,6 +213,13 @@ def _py_type_to_arrow_type(py_type: Any) -> pa.DataType:
|
|
|
165
213
|
|
|
166
214
|
Mirrors the BigQuery mapping (``Decimal`` → NUMERIC precision/scale) so
|
|
167
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
|
+
|
|
168
223
|
"""
|
|
169
224
|
if not _is_concrete_type(py_type):
|
|
170
225
|
return pa.string() # typing.Any or unresolvable annotations
|
|
@@ -192,6 +247,10 @@ def _stringify_untyped(rows: list[dict[str, Any]], specs: list[FieldSpec]) -> li
|
|
|
192
247
|
e.g. ints into a string column — pre-convert so the write cannot fail on
|
|
193
248
|
a column the schema could not type.
|
|
194
249
|
|
|
250
|
+
Args:
|
|
251
|
+
rows: The records to rewrite.
|
|
252
|
+
specs: The field specs deciding which fields are untyped.
|
|
253
|
+
|
|
195
254
|
Returns:
|
|
196
255
|
The rows, copied only when a conversion applies.
|
|
197
256
|
"""
|
|
@@ -16,6 +16,13 @@ def replace_non_finite(obj: Any) -> Any:
|
|
|
16
16
|
which are invalid JSON — BigQuery's load parser rejects them with "Parser
|
|
17
17
|
terminated before end of string". Neither BigQuery nor JSONL files have a
|
|
18
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
|
+
|
|
19
26
|
"""
|
|
20
27
|
if isinstance(obj, float):
|
|
21
28
|
return obj if math.isfinite(obj) else None
|
|
@@ -27,7 +34,18 @@ def replace_non_finite(obj: Any) -> Any:
|
|
|
27
34
|
|
|
28
35
|
|
|
29
36
|
def json_default(o: Any) -> Any:
|
|
30
|
-
"""JSON serializer for types not handled by the default encoder.
|
|
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
|
+
"""
|
|
31
49
|
if isinstance(o, (datetime.date, datetime.datetime)):
|
|
32
50
|
return o.isoformat()
|
|
33
51
|
if isinstance(o, Decimal):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|