crate 2.2.0__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.2.0 → crate-2.2.1}/CHANGES.rst +8 -0
  2. {crate-2.2.0 → crate-2.2.1}/PKG-INFO +1 -1
  3. {crate-2.2.0 → crate-2.2.1}/docs/by-example/cursor.rst +31 -0
  4. {crate-2.2.0 → crate-2.2.1}/docs/query.rst +57 -10
  5. {crate-2.2.0 → crate-2.2.1}/tests/client/test_cursor.py +168 -0
  6. {crate-2.2.0 → crate-2.2.1}/.gitignore +0 -0
  7. {crate-2.2.0 → crate-2.2.1}/CONTRIBUTING.rst +0 -0
  8. {crate-2.2.0 → crate-2.2.1}/DEVELOP.rst +0 -0
  9. {crate-2.2.0 → crate-2.2.1}/LICENSE +0 -0
  10. {crate-2.2.0 → crate-2.2.1}/NOTICE +0 -0
  11. {crate-2.2.0 → crate-2.2.1}/README.rst +0 -0
  12. {crate-2.2.0 → crate-2.2.1}/docs/.gitignore +0 -0
  13. {crate-2.2.0 → crate-2.2.1}/docs/Makefile +0 -0
  14. {crate-2.2.0 → crate-2.2.1}/docs/_extra/robots.txt +0 -0
  15. {crate-2.2.0 → crate-2.2.1}/docs/blobs.rst +0 -0
  16. {crate-2.2.0 → crate-2.2.1}/docs/build.json +0 -0
  17. {crate-2.2.0 → crate-2.2.1}/docs/by-example/blob.rst +0 -0
  18. {crate-2.2.0 → crate-2.2.1}/docs/by-example/client.rst +0 -0
  19. {crate-2.2.0 → crate-2.2.1}/docs/by-example/connection.rst +0 -0
  20. {crate-2.2.0 → crate-2.2.1}/docs/by-example/http.rst +0 -0
  21. {crate-2.2.0 → crate-2.2.1}/docs/by-example/https.rst +0 -0
  22. {crate-2.2.0 → crate-2.2.1}/docs/by-example/index.rst +0 -0
  23. {crate-2.2.0 → crate-2.2.1}/docs/conf.py +0 -0
  24. {crate-2.2.0 → crate-2.2.1}/docs/connect.rst +0 -0
  25. {crate-2.2.0 → crate-2.2.1}/docs/data-types.rst +0 -0
  26. {crate-2.2.0 → crate-2.2.1}/docs/docutils.conf +0 -0
  27. {crate-2.2.0 → crate-2.2.1}/docs/getting-started.rst +0 -0
  28. {crate-2.2.0 → crate-2.2.1}/docs/index-all.rst +0 -0
  29. {crate-2.2.0 → crate-2.2.1}/docs/index.rst +0 -0
  30. {crate-2.2.0 → crate-2.2.1}/docs/other-options.rst +0 -0
  31. {crate-2.2.0 → crate-2.2.1}/docs/requirements.txt +0 -0
  32. {crate-2.2.0 → crate-2.2.1}/examples/README.rst +0 -0
  33. {crate-2.2.0 → crate-2.2.1}/pyproject.toml +0 -0
  34. {crate-2.2.0 → crate-2.2.1}/tests/__init__.py +0 -0
  35. {crate-2.2.0 → crate-2.2.1}/tests/assets/import/test_a.json +0 -0
  36. {crate-2.2.0 → crate-2.2.1}/tests/assets/mappings/locations.sql +0 -0
  37. {crate-2.2.0 → crate-2.2.1}/tests/assets/pki/cacert_invalid.pem +0 -0
  38. {crate-2.2.0 → crate-2.2.1}/tests/assets/pki/cacert_valid.pem +0 -0
  39. {crate-2.2.0 → crate-2.2.1}/tests/assets/pki/client_invalid.pem +0 -0
  40. {crate-2.2.0 → crate-2.2.1}/tests/assets/pki/client_valid.pem +0 -0
  41. {crate-2.2.0 → crate-2.2.1}/tests/assets/pki/readme.rst +0 -0
  42. {crate-2.2.0 → crate-2.2.1}/tests/assets/pki/server_valid.pem +0 -0
  43. {crate-2.2.0 → crate-2.2.1}/tests/assets/settings/test_a.json +0 -0
  44. {crate-2.2.0 → crate-2.2.1}/tests/client/__init__.py +0 -0
  45. {crate-2.2.0 → crate-2.2.1}/tests/client/settings.py +0 -0
  46. {crate-2.2.0 → crate-2.2.1}/tests/client/test_blob.py +0 -0
  47. {crate-2.2.0 → crate-2.2.1}/tests/client/test_connection.py +0 -0
  48. {crate-2.2.0 → crate-2.2.1}/tests/client/test_exceptions.py +0 -0
  49. {crate-2.2.0 → crate-2.2.1}/tests/client/test_http.py +0 -0
  50. {crate-2.2.0 → crate-2.2.1}/tests/client/test_serialization.py +0 -0
  51. {crate-2.2.0 → crate-2.2.1}/tests/client/test_utils.py +0 -0
  52. {crate-2.2.0 → crate-2.2.1}/tests/conftest.py +0 -0
  53. {crate-2.2.0 → crate-2.2.1}/tests/test_docs.py +0 -0
  54. {crate-2.2.0 → crate-2.2.1}/tests/testing/__init__.py +0 -0
  55. {crate-2.2.0 → crate-2.2.1}/tests/testing/test_layer.py +0 -0
@@ -2,6 +2,14 @@
2
2
  Changes for crate
3
3
  =================
4
4
 
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
+
5
13
  2026/06/04 2.2.0
6
14
  ==========
7
15
  - Added JSON serialization support for Python's ``datetime.time`` type,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crate
3
- Version: 2.2.0
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" ],
@@ -72,20 +72,19 @@ The same parameter name may appear multiple times in the query:
72
72
  ... "SELECT * FROM locations WHERE name = %(q)s OR kind = %(q)s",
73
73
  ... {"q": "Quasar"})
74
74
 
75
- The client converts the ``%(name)s`` placeholders to positional ``?`` markers
76
- before sending the query to CrateDB, so no server-side changes are required.
77
-
78
- .. NOTE::
79
-
80
- Named parameters are not yet supported by ``executemany()``. Use
81
- positional ``?`` placeholders with a :class:`py:list` of tuples for bulk
82
- operations.
75
+ The client converts the ``%(name)s`` placeholders to ``$N`` positional
76
+ markers before sending the query to CrateDB.
83
77
 
84
78
  Bulk inserts
85
79
  ------------
86
80
 
87
81
  :ref:`Bulk inserts <crate-reference:http-bulk-ops>` are possible with the
88
- ``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:
89
88
 
90
89
  >>> cursor.executemany(
91
90
  ... "INSERT INTO locations (name, date, kind, position) VALUES (?, ?, ?, ?)",
@@ -94,10 +93,58 @@ Bulk inserts
94
93
  [{'rowcount': 1}, {'rowcount': 1}]
95
94
 
96
95
  The ``executemany()`` method returns a result :class:`dictionary <py:dict>`
97
- for every tuple. This dictionary always has a ``rowcount`` key, indicating
96
+ for every row. This dictionary always has a ``rowcount`` key, indicating
98
97
  how many rows were inserted. If an error occurs, the ``rowcount`` value is
99
98
  ``-2``, and the dictionary may additionally have an ``error_message`` key.
100
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
+
101
148
  .. _selects:
102
149
 
103
150
  Selecting data
@@ -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.
@@ -565,6 +715,24 @@ def test_execute_with_named_params_missing(mocked_connection):
565
715
  mocked_connection.client.sql.assert_not_called()
566
716
 
567
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
+
568
736
  def test_cursor_close(mocked_connection):
569
737
  """
570
738
  Verify that a cursor is not closed if not specifically closed.
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
File without changes
File without changes
File without changes
File without changes