crate 2.1.2__tar.gz → 2.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {crate-2.1.2 → crate-2.2.1}/CHANGES.rst +24 -1
  2. {crate-2.1.2 → crate-2.2.1}/PKG-INFO +1 -1
  3. {crate-2.1.2 → crate-2.2.1}/docs/by-example/cursor.rst +51 -2
  4. {crate-2.1.2 → crate-2.2.1}/docs/connect.rst +26 -0
  5. {crate-2.1.2 → crate-2.2.1}/docs/query.rst +76 -2
  6. {crate-2.1.2 → crate-2.2.1}/pyproject.toml +1 -1
  7. {crate-2.1.2 → crate-2.2.1}/tests/client/test_cursor.py +241 -0
  8. {crate-2.1.2 → crate-2.2.1}/tests/client/test_http.py +123 -0
  9. {crate-2.1.2 → crate-2.2.1}/tests/client/test_serialization.py +38 -0
  10. {crate-2.1.2 → crate-2.2.1}/tests/testing/test_layer.py +5 -15
  11. {crate-2.1.2 → crate-2.2.1}/.gitignore +0 -0
  12. {crate-2.1.2 → crate-2.2.1}/CONTRIBUTING.rst +0 -0
  13. {crate-2.1.2 → crate-2.2.1}/DEVELOP.rst +0 -0
  14. {crate-2.1.2 → crate-2.2.1}/LICENSE +0 -0
  15. {crate-2.1.2 → crate-2.2.1}/NOTICE +0 -0
  16. {crate-2.1.2 → crate-2.2.1}/README.rst +0 -0
  17. {crate-2.1.2 → crate-2.2.1}/docs/.gitignore +0 -0
  18. {crate-2.1.2 → crate-2.2.1}/docs/Makefile +0 -0
  19. {crate-2.1.2 → crate-2.2.1}/docs/_extra/robots.txt +0 -0
  20. {crate-2.1.2 → crate-2.2.1}/docs/blobs.rst +0 -0
  21. {crate-2.1.2 → crate-2.2.1}/docs/build.json +0 -0
  22. {crate-2.1.2 → crate-2.2.1}/docs/by-example/blob.rst +0 -0
  23. {crate-2.1.2 → crate-2.2.1}/docs/by-example/client.rst +0 -0
  24. {crate-2.1.2 → crate-2.2.1}/docs/by-example/connection.rst +0 -0
  25. {crate-2.1.2 → crate-2.2.1}/docs/by-example/http.rst +0 -0
  26. {crate-2.1.2 → crate-2.2.1}/docs/by-example/https.rst +0 -0
  27. {crate-2.1.2 → crate-2.2.1}/docs/by-example/index.rst +0 -0
  28. {crate-2.1.2 → crate-2.2.1}/docs/conf.py +0 -0
  29. {crate-2.1.2 → crate-2.2.1}/docs/data-types.rst +0 -0
  30. {crate-2.1.2 → crate-2.2.1}/docs/docutils.conf +0 -0
  31. {crate-2.1.2 → crate-2.2.1}/docs/getting-started.rst +0 -0
  32. {crate-2.1.2 → crate-2.2.1}/docs/index-all.rst +0 -0
  33. {crate-2.1.2 → crate-2.2.1}/docs/index.rst +0 -0
  34. {crate-2.1.2 → crate-2.2.1}/docs/other-options.rst +0 -0
  35. {crate-2.1.2 → crate-2.2.1}/docs/requirements.txt +0 -0
  36. {crate-2.1.2 → crate-2.2.1}/examples/README.rst +0 -0
  37. {crate-2.1.2 → crate-2.2.1}/tests/__init__.py +0 -0
  38. {crate-2.1.2 → crate-2.2.1}/tests/assets/import/test_a.json +0 -0
  39. {crate-2.1.2 → crate-2.2.1}/tests/assets/mappings/locations.sql +0 -0
  40. {crate-2.1.2 → crate-2.2.1}/tests/assets/pki/cacert_invalid.pem +0 -0
  41. {crate-2.1.2 → crate-2.2.1}/tests/assets/pki/cacert_valid.pem +0 -0
  42. {crate-2.1.2 → crate-2.2.1}/tests/assets/pki/client_invalid.pem +0 -0
  43. {crate-2.1.2 → crate-2.2.1}/tests/assets/pki/client_valid.pem +0 -0
  44. {crate-2.1.2 → crate-2.2.1}/tests/assets/pki/readme.rst +0 -0
  45. {crate-2.1.2 → crate-2.2.1}/tests/assets/pki/server_valid.pem +0 -0
  46. {crate-2.1.2 → crate-2.2.1}/tests/assets/settings/test_a.json +0 -0
  47. {crate-2.1.2 → crate-2.2.1}/tests/client/__init__.py +0 -0
  48. {crate-2.1.2 → crate-2.2.1}/tests/client/settings.py +0 -0
  49. {crate-2.1.2 → crate-2.2.1}/tests/client/test_blob.py +0 -0
  50. {crate-2.1.2 → crate-2.2.1}/tests/client/test_connection.py +0 -0
  51. {crate-2.1.2 → crate-2.2.1}/tests/client/test_exceptions.py +0 -0
  52. {crate-2.1.2 → crate-2.2.1}/tests/client/test_utils.py +0 -0
  53. {crate-2.1.2 → crate-2.2.1}/tests/conftest.py +0 -0
  54. {crate-2.1.2 → crate-2.2.1}/tests/test_docs.py +0 -0
  55. {crate-2.1.2 → crate-2.2.1}/tests/testing/__init__.py +0 -0
@@ -2,8 +2,31 @@
2
2
  Changes for crate
3
3
  =================
4
4
 
5
- Unreleased
5
+ 2026/06/17 2.2.1
6
+ ================
7
+
8
+ - Fixed ``cursor.execute()`` with ``bulk_parameters`` and pyformat SQL: when
9
+ rows are dicts, both the SQL template and the rows are now converted to
10
+ positional format before sending to CrateDB. Positional-list rows
11
+ continue to work as before.
12
+
13
+ 2026/06/04 2.2.0
6
14
  ==========
15
+ - Added JSON serialization support for Python's ``datetime.time`` type,
16
+ encoding it as an ISO 8601 string compatible with CrateDB's ``TIMETZ``
17
+ column type.
18
+
19
+ - Added gzip compression for outgoing request bodies via the ``compress``
20
+ parameter (default: ``8192`` bytes).
21
+ Pass ``True`` to always compress, ``False`` to disable, or an integer
22
+ as a byte threshold. The driver always sends ``Accept-Encoding: gzip,
23
+ deflate`` to negotiate compressed responses from the server when
24
+ compression is enabled.
25
+
26
+ - Added named parameter support (``pyformat`` paramstyle). Passing a
27
+ :class:`py:dict` as ``parameters`` to ``cursor.execute()`` now accepts
28
+ ``%(name)s`` placeholders and converts them to positional ``?`` markers
29
+ client-side. Positional parameters using ``?`` continue to work unchanged.
7
30
 
8
31
  2026/03/09 2.1.2
9
32
  ================
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crate
3
- Version: 2.1.2
3
+ Version: 2.2.1
4
4
  Summary: CrateDB Python Client
5
5
  Author-email: "Crate.io" <office@crate.io>
6
6
  License-Expression: Apache-2.0
@@ -266,6 +266,37 @@ For completeness' sake the cursor description is updated nonetheless:
266
266
  >>> [ desc[0] for desc in cursor.description ]
267
267
  ['name', 'position']
268
268
 
269
+ executemany() with named parameters
270
+ ====================================
271
+
272
+ ``executemany()`` also accepts a :class:`py:list` of :class:`py:dict` when
273
+ the SQL statement contains ``%(name)s`` placeholders. The client converts both the SQL
274
+ template and all rows to positional format before sending them to CrateDB:
275
+
276
+ .. Hidden: set up mocked response
277
+
278
+ >>> connection.client.set_next_response({
279
+ ... "results": [
280
+ ... {"rowcount": 1},
281
+ ... {"rowcount": 1}
282
+ ... ],
283
+ ... "duration": 123,
284
+ ... "cols": [],
285
+ ... })
286
+
287
+ >>> cursor = connection.cursor()
288
+
289
+ >>> cursor.executemany(
290
+ ... "INSERT INTO t (id, val) VALUES (%(id)s, %(val)s)",
291
+ ... [{"id": 1, "val": "foo"}, {"id": 2, "val": "bar"}])
292
+ [{'rowcount': 1}, {'rowcount': 1}]
293
+
294
+ >>> cursor.rowcount
295
+ 2
296
+
297
+ >>> cursor.duration
298
+ 123
299
+
269
300
  >>> connection.client.set_next_response({
270
301
  ... "rows":[ [ "North West Ripple", 1 ], [ "Arkintoofle Minor", 3 ], [ "Alpha Centauri", 3 ] ],
271
302
  ... "cols":[ "name", "position" ],
@@ -311,8 +342,8 @@ Python data type conversion
311
342
  ===========================
312
343
 
313
344
  The cursor object can optionally convert database types to native Python data
314
- types. Currently, this is implemented for the CrateDB data types ``IP`` and
315
- ``TIMESTAMP`` on behalf of the ``DefaultTypeConverter``.
345
+ types. Currently, this is implemented for the CrateDB data types ``IP``,
346
+ ``TIMESTAMP``, and ``TIMETZ`` on behalf of the ``DefaultTypeConverter``.
316
347
 
317
348
  >>> cursor = connection.cursor(converter=DefaultTypeConverter())
318
349
 
@@ -329,6 +360,24 @@ types. Currently, this is implemented for the CrateDB data types ``IP`` and
329
360
  >>> cursor.fetchone()
330
361
  ['foo', IPv4Address('10.10.10.1'), datetime.datetime(2022, 7, 18, 18, 10, 36, 758000, tzinfo=datetime.timezone.utc)]
331
362
 
363
+ CrateDB's ``TIMETZ`` type is returned over HTTP as ``[microseconds_since_midnight, tz_offset_seconds]``
364
+ and decoded to a ``datetime.time`` object with the appropriate timezone:
365
+
366
+ >>> cursor = connection.cursor(converter=DefaultTypeConverter())
367
+
368
+ >>> connection.client.set_next_response({
369
+ ... "col_types": [20],
370
+ ... "rows":[ [ [45045000000, 0] ] ],
371
+ ... "cols":[ "t" ],
372
+ ... "rowcount":1,
373
+ ... "duration":1
374
+ ... })
375
+
376
+ >>> cursor.execute('')
377
+
378
+ >>> cursor.fetchone()
379
+ [datetime.time(12, 30, 45, tzinfo=datetime.timezone.utc)]
380
+
332
381
 
333
382
  Custom data type conversion
334
383
  ===========================
@@ -266,6 +266,32 @@ with the rest of your arguments.
266
266
 
267
267
  However, you can query any schema you like by specifying it in the query.
268
268
 
269
+ .. _compression:
270
+
271
+ Request and response compression
272
+ =================================
273
+
274
+ The ``compress`` parameter controls gzip compression of outgoing request
275
+ bodies. The default ``8192`` compresses payloads larger than 8 KB::
276
+
277
+ >>> connection = client.connect('localhost:4200')
278
+ # compress=8192 is the default — payloads > 8 KB are gzip-compressed
279
+
280
+ To always compress, regardless of payload size::
281
+
282
+ >>> connection = client.connect('localhost:4200', compress=True)
283
+
284
+ To disable compression entirely::
285
+
286
+ >>> connection = client.connect('localhost:4200', compress=False)
287
+
288
+ To use a custom threshold (bytes)::
289
+
290
+ >>> connection = client.connect('localhost:4200', compress=4096)
291
+
292
+ The driver always sends ``Accept-Encoding: gzip, deflate`` so the server
293
+ may return compressed responses if compression is enabled.
294
+
269
295
  Next steps
270
296
  ==========
271
297
 
@@ -54,11 +54,37 @@ characters appear, in the order they appear.
54
54
  Always use the parameter interpolation feature of the client library to
55
55
  guard against malicious input, as demonstrated in the example above.
56
56
 
57
+ Named parameters
58
+ ----------------
59
+
60
+ For queries with many parameters or repeated values, named parameters improve
61
+ readability. Pass a :class:`py:dict` as the second argument using
62
+ ``%(name)s`` placeholders:
63
+
64
+ >>> cursor.execute(
65
+ ... "INSERT INTO locations (name, date, kind, position) "
66
+ ... "VALUES (%(name)s, %(date)s, %(kind)s, %(pos)s)",
67
+ ... {"name": "Einstein Cross", "date": "2007-03-11", "kind": "Quasar", "pos": 7})
68
+
69
+ The same parameter name may appear multiple times in the query:
70
+
71
+ >>> cursor.execute(
72
+ ... "SELECT * FROM locations WHERE name = %(q)s OR kind = %(q)s",
73
+ ... {"q": "Quasar"})
74
+
75
+ The client converts the ``%(name)s`` placeholders to ``$N`` positional
76
+ markers before sending the query to CrateDB.
77
+
57
78
  Bulk inserts
58
79
  ------------
59
80
 
60
81
  :ref:`Bulk inserts <crate-reference:http-bulk-ops>` are possible with the
61
- ``executemany()`` method, which takes a :class:`py:list` of tuples to insert:
82
+ ``executemany()`` method.
83
+
84
+ Positional parameters
85
+ .....................
86
+
87
+ Pass a :class:`py:list` of sequences using ``?`` placeholders:
62
88
 
63
89
  >>> cursor.executemany(
64
90
  ... "INSERT INTO locations (name, date, kind, position) VALUES (?, ?, ?, ?)",
@@ -67,10 +93,58 @@ Bulk inserts
67
93
  [{'rowcount': 1}, {'rowcount': 1}]
68
94
 
69
95
  The ``executemany()`` method returns a result :class:`dictionary <py:dict>`
70
- for every tuple. This dictionary always has a ``rowcount`` key, indicating
96
+ for every row. This dictionary always has a ``rowcount`` key, indicating
71
97
  how many rows were inserted. If an error occurs, the ``rowcount`` value is
72
98
  ``-2``, and the dictionary may additionally have an ``error_message`` key.
73
99
 
100
+ Named parameters
101
+ ................
102
+
103
+ ``executemany()`` also accepts a :class:`py:list` of :class:`py:dict` using
104
+ ``%(name)s`` placeholders. The client converts both the SQL template and all
105
+ rows to positional format before sending to CrateDB:
106
+
107
+ >>> cursor.executemany(
108
+ ... "INSERT INTO locations (name, date, kind, position) "
109
+ ... "VALUES (%(name)s, %(date)s, %(kind)s, %(pos)s)",
110
+ ... [{"name": "Cloverleaf", "date": "2007-03-11", "kind": "Quasar", "pos": 7},
111
+ ... {"name": "Old Faithful", "date": "2007-03-11", "kind": "Quasar", "pos": 8}])
112
+ [{'rowcount': 1}, {'rowcount': 1}]
113
+
114
+ Using ``bulk_parameters`` directly
115
+ ...................................
116
+
117
+ ``execute()`` accepts a ``bulk_parameters`` keyword argument directly:
118
+
119
+ .. NOTE::
120
+ Please prefer ``executemany()`` for bulk inserts, it is the standard DB-API 2.0
121
+ interface. The ``bulk_parameters`` argument is a lower-level alternative.
122
+
123
+ >>> cursor.execute(
124
+ ... "INSERT INTO locations (name, kind, position) VALUES (?, ?, ?)",
125
+ ... bulk_parameters=[('Cloverleaf', 'Quasar', 7),
126
+ ... ('Old Faithful', 'Quasar', 8)])
127
+
128
+ Named ``%(name)s`` placeholders are also supported. When the rows are
129
+ :class:`py:dict` objects the SQL template and rows are fully converted,
130
+ identical to the ``executemany()`` path:
131
+
132
+ >>> cursor.execute(
133
+ ... "INSERT INTO locations (name, kind, position) "
134
+ ... "VALUES (%(name)s, %(kind)s, %(pos)s)",
135
+ ... bulk_parameters=[{"name": "Cloverleaf", "kind": "Quasar", "pos": 7},
136
+ ... {"name": "Old Faithful", "kind": "Quasar", "pos": 8}])
137
+
138
+ When the rows are already positional lists (e.g. data coming from a
139
+ DataFrame), only the SQL template is rewritten. In this case the caller must
140
+ ensure the value order in each row matches the placeholder order in the SQL:
141
+
142
+ >>> cursor.execute(
143
+ ... "INSERT INTO locations (name, kind, position) "
144
+ ... "VALUES (%(name)s, %(kind)s, %(pos)s)",
145
+ ... bulk_parameters=[['Cloverleaf', 'Quasar', 7],
146
+ ... ['Old Faithful', 'Quasar', 8]])
147
+
74
148
  .. _selects:
75
149
 
76
150
  Selecting data
@@ -58,7 +58,7 @@ dependencies = [
58
58
  dev = [
59
59
  "certifi",
60
60
  "coverage",
61
- "mypy<1.20",
61
+ "mypy<2.2",
62
62
  "pytest<10",
63
63
  "pytz",
64
64
  "ruff<0.16",
@@ -125,6 +125,94 @@ def test_cursor_executemany(mocked_connection):
125
125
  assert response["results"] == result
126
126
 
127
127
 
128
+ def test_executemany_with_named_params(mocked_connection):
129
+ """
130
+ Verify that executemany() translates pyformat %(name)s placeholders to
131
+ positional $N markers and converts each dict row to a positional list.
132
+
133
+ """
134
+ response = {
135
+ "col_types": [],
136
+ "cols": [],
137
+ "duration": 123,
138
+ "results": [{"rowcount": 1}, {"rowcount": 1}],
139
+ }
140
+ with mock.patch.object(
141
+ mocked_connection.client, "sql", return_value=response
142
+ ):
143
+ cursor = mocked_connection.cursor()
144
+ cursor.executemany(
145
+ "INSERT INTO characters (name, age) VALUES (%(name)s, %(age)s)",
146
+ [
147
+ {"name": "Arthur", "age": 42},
148
+ {"name": "Bill", "age": 35},
149
+ ],
150
+ )
151
+ sql, _params, bulk_args = mocked_connection.client.sql.call_args[0]
152
+ assert sql == "INSERT INTO characters (name, age) VALUES ($1, $2)"
153
+ assert bulk_args == [["Arthur", 42], ["Bill", 35]]
154
+
155
+
156
+ def test_executemany_with_named_params_missing_key(mocked_connection):
157
+ """
158
+ Verify that executemany() raises ProgrammingError when a row is missing a
159
+ key that appears as a placeholder in the SQL.
160
+ """
161
+ cursor = mocked_connection.cursor()
162
+ with pytest.raises(
163
+ ProgrammingError, match="Named parameter 'age' not found"
164
+ ):
165
+ cursor.executemany(
166
+ "INSERT INTO characters (name, age) VALUES (%(name)s, %(age)s)",
167
+ [
168
+ {"name": "Arthur", "age": 42},
169
+ {"name": "Bill"}, # missing 'age'
170
+ ],
171
+ )
172
+ mocked_connection.client.sql.assert_not_called()
173
+
174
+
175
+ def test_executemany_with_named_params_repeated(mocked_connection):
176
+ """
177
+ Verify that a placeholder name used multiple times in the SQL maps to the
178
+ same $N position in every occurrence, and the value appears only once in
179
+ each row's positional list.
180
+ """
181
+ response = {
182
+ "col_types": [],
183
+ "cols": [],
184
+ "duration": 123,
185
+ "results": [{"rowcount": 1}, {"rowcount": 1}],
186
+ }
187
+ with mock.patch.object(
188
+ mocked_connection.client, "sql", return_value=response
189
+ ):
190
+ cursor = mocked_connection.cursor()
191
+ cursor.executemany(
192
+ "INSERT INTO t (a, b) VALUES (%(x)s, %(x)s)",
193
+ [{"x": 1}, {"x": 2}],
194
+ )
195
+ sql, _params, bulk_args = mocked_connection.client.sql.call_args[0]
196
+ assert sql == "INSERT INTO t (a, b) VALUES ($1, $1)"
197
+ assert bulk_args == [[1], [2]]
198
+
199
+
200
+ def test_executemany_with_mixed_param_types(mocked_connection):
201
+ """
202
+ Verify that executemany() raises a clear ProgrammingError when the
203
+ parameter sequence mixes dicts and non-dicts while the SQL uses pyformat.
204
+ """
205
+ cursor = mocked_connection.cursor()
206
+ with pytest.raises(
207
+ ProgrammingError, match="All bulk parameter rows must be dicts"
208
+ ):
209
+ cursor.executemany(
210
+ "INSERT INTO characters (name) VALUES (%(name)s)",
211
+ [{"name": "Arthur"}, ["Trillian"]], # second row is a list
212
+ )
213
+ mocked_connection.client.sql.assert_not_called()
214
+
215
+
128
216
  def test_create_with_timezone_as_datetime_object(mocked_connection):
129
217
  """
130
218
  The cursor can return timezone-aware `datetime` objects when requested.
@@ -243,6 +331,68 @@ def test_execute_with_bulk_args(mocked_connection):
243
331
  mocked_connection.client.sql.assert_called_once_with(statement, None, [[1]])
244
332
 
245
333
 
334
+ def test_execute_with_pyformat_sql_and_bulk_parameters(mocked_connection):
335
+ """
336
+ cursor.execute() converts %(name)s SQL to $N when bulk_parameters is
337
+ provided. Rows are already positional; only the SQL needs conversion.
338
+ """
339
+ cursor = mocked_connection.cursor()
340
+ sql = "INSERT INTO t (id, val) VALUES (%(id)s, %(val)s)"
341
+ bulk = [[1, "hello"], [2, "world"]]
342
+ cursor.execute(sql, bulk_parameters=bulk)
343
+ mocked_connection.client.sql.assert_called_once_with(
344
+ "INSERT INTO t (id, val) VALUES ($1, $2)", None, bulk
345
+ )
346
+
347
+
348
+ def test_execute_with_pyformat_sql_and_dict_bulk_parameters(mocked_connection):
349
+ """
350
+ cursor.execute() with pyformat SQL and dict-format bulk_parameters converts
351
+ both the SQL template (%(x)s → $N) and the rows (dicts → positional lists).
352
+ """
353
+ cursor = mocked_connection.cursor()
354
+ sql = "INSERT INTO t (id, val) VALUES (%(id)s, %(val)s)"
355
+ bulk = [{"id": 1, "val": "hello"}, {"id": 2, "val": "world"}]
356
+ cursor.execute(sql, bulk_parameters=bulk)
357
+ mocked_connection.client.sql.assert_called_once_with(
358
+ "INSERT INTO t (id, val) VALUES ($1, $2)",
359
+ None,
360
+ [[1, "hello"], [2, "world"]],
361
+ )
362
+
363
+
364
+ def test_execute_with_dict_bulk_parameters_mixed_types_raises(
365
+ mocked_connection,
366
+ ):
367
+ """
368
+ cursor.execute() raises ProgrammingError when bulk_parameters mixes
369
+ dict and non-dict rows with pyformat SQL.
370
+ """
371
+ cursor = mocked_connection.cursor()
372
+ with pytest.raises(
373
+ ProgrammingError, match="All bulk parameter rows must be dicts"
374
+ ):
375
+ cursor.execute(
376
+ "INSERT INTO t (id) VALUES (%(id)s)",
377
+ bulk_parameters=[{"id": 1}, [2]],
378
+ )
379
+ mocked_connection.client.sql.assert_not_called()
380
+
381
+
382
+ def test_execute_with_pyformat_sql_and_bulk_parameters_no_placeholders(
383
+ mocked_connection,
384
+ ):
385
+ """
386
+ SQL without %(name)s placeholders is passed through unchanged
387
+ even when bulk_parameters is provided.
388
+ """
389
+ cursor = mocked_connection.cursor()
390
+ sql = "INSERT INTO t (id, val) VALUES (?, ?)"
391
+ bulk = [[1, "hello"], [2, "world"]]
392
+ cursor.execute(sql, bulk_parameters=bulk)
393
+ mocked_connection.client.sql.assert_called_once_with(sql, None, bulk)
394
+
395
+
246
396
  def test_execute_custom_converter(mocked_connection):
247
397
  """
248
398
  Verify that a custom converter is correctly applied when passed to a cursor.
@@ -293,6 +443,41 @@ def test_execute_custom_converter(mocked_connection):
293
443
  ]
294
444
 
295
445
 
446
+ def test_execute_time_converter(mocked_connection):
447
+ """
448
+ Verify that CrateDB's TIMETZ wire format
449
+ ``[microseconds, tz_offset_seconds]`` is decoded to a ``datetime.time``
450
+ object by ``DefaultTypeConverter``.
451
+ """
452
+ converter = DefaultTypeConverter()
453
+ cursor = mocked_connection.cursor(converter=converter)
454
+ response = {
455
+ "col_types": [20],
456
+ "cols": ["t"],
457
+ "rows": [
458
+ [[45045000000, 0]], # 12:30:45 UTC
459
+ [[45045123456, 7200]], # 12:30:45.123456 +02:00
460
+ [None],
461
+ ],
462
+ "rowcount": 3,
463
+ "duration": 1,
464
+ }
465
+
466
+ with mock.patch.object(
467
+ mocked_connection.client, "sql", return_value=response
468
+ ):
469
+ cursor.execute("")
470
+ result = cursor.fetchall()
471
+
472
+ assert result == [
473
+ [datetime.time(12, 30, 45, 0,
474
+ tzinfo=datetime.timezone.utc)],
475
+ [datetime.time(12, 30, 45, 123456,
476
+ tzinfo=datetime.timezone(datetime.timedelta(hours=2)))],
477
+ [None],
478
+ ]
479
+
480
+
296
481
  def test_execute_with_converter_and_invalid_data_type(mocked_connection):
297
482
  converter = DefaultTypeConverter()
298
483
 
@@ -492,6 +677,62 @@ def test_execute_with_timezone(mocked_connection):
492
677
  assert result[0][1].tzname() == "UTC"
493
678
 
494
679
 
680
+ def test_execute_with_named_params(mocked_connection):
681
+ """
682
+ Verify that named %(name)s parameters are converted to positional ? markers
683
+ and the values are passed as an ordered list.
684
+ """
685
+ cursor = mocked_connection.cursor()
686
+ cursor.execute(
687
+ "SELECT * FROM t WHERE a = %(a)s AND b = %(b)s",
688
+ {"a": 1, "b": 2},
689
+ )
690
+ mocked_connection.client.sql.assert_called_once_with(
691
+ "SELECT * FROM t WHERE a = $1 AND b = $2", [1, 2], None
692
+ )
693
+
694
+
695
+ def test_execute_with_named_params_repeated(mocked_connection):
696
+ """
697
+ Verify that a parameter name used multiple times in the SQL is resolved
698
+ correctly each time it appears.
699
+ """
700
+ cursor = mocked_connection.cursor()
701
+ cursor.execute("SELECT %(x)s, %(x)s", {"x": 42})
702
+ mocked_connection.client.sql.assert_called_once_with(
703
+ "SELECT $1, $1", [42], None
704
+ )
705
+
706
+
707
+ def test_execute_with_named_params_missing(mocked_connection):
708
+ """
709
+ Verify that a ProgrammingError is raised when a placeholder name is absent
710
+ from the parameters dict, and that the client is never called.
711
+ """
712
+ cursor = mocked_connection.cursor()
713
+ with pytest.raises(ProgrammingError, match="Named parameter 'z' not found"):
714
+ cursor.execute("SELECT %(z)s", {"a": 1})
715
+ mocked_connection.client.sql.assert_not_called()
716
+
717
+
718
+ def test_execute_with_named_params_non_identifier_keys(mocked_connection):
719
+ """
720
+ Verify that %(name)s placeholders whose name contains characters outside
721
+ [a-zA-Z0-9_] are still converted to positional $N markers.
722
+
723
+ """
724
+ cursor = mocked_connection.cursor()
725
+
726
+ cursor.execute(
727
+ "UPDATE characters SET data['x'] = %(data['x'])s WHERE name = %(name)s",
728
+ {"data['x']": 42, "name": "Berlin"},
729
+ )
730
+ sql, args, _ = mocked_connection.client.sql.call_args[0]
731
+ assert "%" not in sql
732
+ assert sql == "UPDATE characters SET data['x'] = $1 WHERE name = $2"
733
+ assert args == [42, "Berlin"]
734
+
735
+
495
736
  def test_cursor_close(mocked_connection):
496
737
  """
497
738
  Verify that a cursor is not closed if not specifically closed.
@@ -19,6 +19,7 @@
19
19
  # with Crate these terms will supersede the license and you may use the
20
20
  # software solely pursuant to the terms of the relevant commercial agreement.
21
21
 
22
+ import gzip
22
23
  import json
23
24
  import os
24
25
  import queue
@@ -200,6 +201,48 @@ def test_redirect_handling():
200
201
  assert conn_kw == {"socket_options": _get_socket_opts(keepalive=True)}
201
202
 
202
203
 
204
+ @pytest.mark.parametrize("method,args,success_status", [
205
+ ("blob_exists", ("blobs", "fake_digest"), 200),
206
+ ("blob_put", ("blobs", "fake_digest", b"data"), 201),
207
+ ("blob_del", ("blobs", "fake_digest"), 204),
208
+ ("blob_get", ("blobs", "fake_digest"), 200),
209
+ ])
210
+ def test_redirect_blob_preserves_basic_auth(method, args, success_status):
211
+ """
212
+ Verify Basic HTTP auth credentials are forwarded when following blob
213
+ endpoint redirects.
214
+ """
215
+ redirect = fake_redirect("http://localhost:4201/_blobs/blobs/fake_digest")
216
+ success = fake_response(success_status)
217
+
218
+ with patch(REQUEST_PATH, side_effect=[redirect, success]) as mock_req:
219
+ client = Client(
220
+ servers="localhost:4200", username="admin", password="secret"
221
+ )
222
+ getattr(client, method)(*args)
223
+
224
+ assert mock_req.call_count == 2
225
+ for call in mock_req.call_args_list:
226
+ assert call.kwargs.get("username") == "admin"
227
+ assert call.kwargs.get("password") == "secret"
228
+
229
+
230
+ def test_redirect_blob_preserves_jwt_auth():
231
+ """
232
+ Verify JWT bearer token is forwarded when following blob endpoint redirects.
233
+ """
234
+ redirect = fake_redirect("http://localhost:4201/_blobs/blobs/fake_digest")
235
+ success = fake_response(200)
236
+
237
+ with patch(REQUEST_PATH, side_effect=[redirect, success]) as mock_req:
238
+ client = Client(servers="localhost:4200", jwt_token="my.jwt.token")
239
+ client.blob_exists("blobs", "fake_digest")
240
+
241
+ assert mock_req.call_count == 2
242
+ for call in mock_req.call_args_list:
243
+ assert call.kwargs.get("jwt_token") == "my.jwt.token"
244
+
245
+
203
246
  def test_server_infos():
204
247
  """
205
248
  Verify that when a `MaxRetryError` is raised, a `ConnectionError` is raised.
@@ -735,3 +778,83 @@ def test_credentials_and_token(serve_http):
735
778
  assert excinfo.match(
736
779
  "Either JWT tokens are accepted, or user credentials, but not both"
737
780
  )
781
+
782
+ def test_compress_accept_encoding_always_sent():
783
+ """Accept-Encoding is sent even when compression is disabled."""
784
+ captured = {}
785
+
786
+ def capturing(*_, **kwargs):
787
+ captured["headers"] = kwargs.get("headers") or {}
788
+ return fake_response(200)
789
+
790
+ with patch(REQUEST_PATH, side_effect=capturing):
791
+ Client(servers="localhost:4200", compress=False).sql("SELECT 1")
792
+ assert captured["headers"].get("Accept-Encoding") == "gzip, deflate"
793
+
794
+
795
+ def test_compress_false_no_content_encoding():
796
+ """No Content-Encoding header when compress=False."""
797
+ captured = {}
798
+
799
+ def capturing(*_, **kwargs):
800
+ captured["headers"] = kwargs.get("headers") or {}
801
+ return fake_response(200)
802
+
803
+ with patch(REQUEST_PATH, side_effect=capturing):
804
+ Client(servers="localhost:4200", compress=False).sql("SELECT 1")
805
+ assert "Content-Encoding" not in captured["headers"]
806
+
807
+
808
+ def test_compress_true_always_compresses():
809
+ """compress=True compresses regardless of payload size."""
810
+ captured = {}
811
+
812
+ def capturing(*_, **kwargs):
813
+ captured["data"] = kwargs.get("data", b"")
814
+ captured["headers"] = kwargs.get("headers") or {}
815
+ return fake_response(200)
816
+
817
+ with patch(REQUEST_PATH, side_effect=capturing):
818
+ Client(servers="localhost:4200", compress=True).sql("SELECT 1")
819
+ assert captured["headers"].get("Content-Encoding") == "gzip"
820
+ assert b'"stmt"' in gzip.decompress(captured["data"])
821
+
822
+
823
+ def test_compress_threshold_above():
824
+ """Payload above threshold is compressed."""
825
+ captured = {}
826
+
827
+ def capturing(*_, **kwargs):
828
+ captured["headers"] = kwargs.get("headers") or {}
829
+ return fake_response(200)
830
+
831
+ with patch(REQUEST_PATH, side_effect=capturing):
832
+ Client(servers="localhost:4200", compress=0).sql("SELECT 1")
833
+ assert captured["headers"].get("Content-Encoding") == "gzip"
834
+
835
+
836
+ def test_compress_threshold_below():
837
+ """Payload below threshold is not compressed."""
838
+ captured = {}
839
+
840
+ def capturing(*_, **kwargs):
841
+ captured["headers"] = kwargs.get("headers") or {}
842
+ return fake_response(200)
843
+
844
+ with patch(REQUEST_PATH, side_effect=capturing):
845
+ Client(servers="localhost:4200", compress=999_999).sql("SELECT 1")
846
+ assert "Content-Encoding" not in captured["headers"]
847
+
848
+
849
+ def test_compress_default():
850
+ """Default args: Accept-Encoding sent, small payload not compressed."""
851
+ captured = {}
852
+
853
+ def capturing(*_, **kwargs):
854
+ captured["headers"] = kwargs.get("headers") or {}
855
+ return fake_response(200)
856
+
857
+ with patch(REQUEST_PATH, side_effect=capturing):
858
+ Client(servers="localhost:4200").sql("SELECT 1")
859
+ assert captured["headers"].get("Accept-Encoding") == "gzip, deflate"
860
+ assert "Content-Encoding" not in captured["headers"]
@@ -125,6 +125,44 @@ def test_date_serialization():
125
125
  assert result == b"1461196800000"
126
126
 
127
127
 
128
+ def test_naive_time_serialization():
129
+ """
130
+ Verify that a naive `datetime.time` serializes to an ISO 8601 string.
131
+ """
132
+ data = dt.time(12, 30, 45)
133
+ result = json_dumps(data)
134
+ assert result == b'"12:30:45"'
135
+
136
+
137
+ def test_time_with_microseconds_serialization():
138
+ """
139
+ Verify that `datetime.time` with microseconds serializes correctly.
140
+ """
141
+ data = dt.time(12, 30, 45, 123456)
142
+ result = json_dumps(data)
143
+ assert result == b'"12:30:45.123456"'
144
+
145
+
146
+ def test_aware_time_serialization():
147
+ """
148
+ Verify that a timezone-aware `datetime.time` serializes to ISO 8601 format,
149
+ including the UTC offset.
150
+ """
151
+ data = dt.time(12, 30, 45, tzinfo=dt.timezone.utc)
152
+ result = json_dumps(data)
153
+ assert result == b'"12:30:45+00:00"'
154
+
155
+
156
+ def test_aware_time_with_offset_serialization():
157
+ """
158
+ Verify that a `datetime.time` with a non-UTC offset serializes correctly.
159
+ """
160
+ tz = dt.timezone(dt.timedelta(hours=2))
161
+ data = dt.time(12, 30, 45, tzinfo=tz)
162
+ result = json_dumps(data)
163
+ assert result == b'"12:30:45+02:00"'
164
+
165
+
128
166
  def test_uuid_serialization():
129
167
  """
130
168
  Verify that a `uuid.UUID` can be serialized.
@@ -22,13 +22,11 @@
22
22
  import json
23
23
  import os
24
24
  import tempfile
25
- import urllib
26
25
  from io import BytesIO
27
26
  from pathlib import Path
28
27
  from unittest import TestCase, mock
29
28
 
30
29
  import urllib3
31
- from verlib2 import Version
32
30
 
33
31
  import crate
34
32
  from crate.testing.layer import (
@@ -38,7 +36,7 @@ from crate.testing.layer import (
38
36
  wait_for_http_url,
39
37
  )
40
38
  from tests.client.settings import crate_path
41
- from tests.conftest import download_cratedb
39
+ from tests.conftest import download_cratedb, get_crate_url
42
40
 
43
41
 
44
42
  class LayerUtilsTest(TestCase):
@@ -86,18 +84,10 @@ class LayerUtilsTest(TestCase):
86
84
  The CrateLayer can also be created by providing an URI that points to
87
85
  a CrateDB tarball.
88
86
  """
89
- with urllib.request.urlopen(
90
- "https://crate.io/versions.json"
91
- ) as response:
92
- versions = json.loads(response.read().decode())
93
- version = versions["crate_testing"]
94
-
95
- self.assertGreaterEqual(Version(version), Version("4.5.0"))
96
-
97
- uri = "https://cdn.crate.io/downloads/releases/crate-{}.tar.gz".format(
98
- version
99
- )
100
- layer = CrateLayer.from_uri(uri, name="crate-by-uri", http_port=42203)
87
+ layer = CrateLayer.from_uri(get_crate_url(),
88
+ name="crate-by-uri",
89
+ http_port=42203
90
+ )
101
91
  self.assertIsInstance(layer, CrateLayer)
102
92
 
103
93
  @mock.patch.dict("os.environ", {}, clear=True)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes