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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-google-cloud
3
- Version: 0.72.0
3
+ Version: 0.74.0
4
4
  Summary: Interloper Google Cloud integration: BigQuery and Cloud Storage destinations
5
5
  Author: Guillaume Onfroy
6
6
  Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "interloper-google-cloud"
3
- version = "0.72.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.72.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
- extend-select = ["E", "I", "UP", "ANN001", "ANN201", "ANN202"]
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):