crate 2.1.2__tar.gz → 2.2.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.
Files changed (55) hide show
  1. {crate-2.1.2 → crate-2.2.0}/CHANGES.rst +16 -1
  2. {crate-2.1.2 → crate-2.2.0}/PKG-INFO +1 -1
  3. {crate-2.1.2 → crate-2.2.0}/docs/by-example/cursor.rst +20 -2
  4. {crate-2.1.2 → crate-2.2.0}/docs/connect.rst +26 -0
  5. {crate-2.1.2 → crate-2.2.0}/docs/query.rst +27 -0
  6. {crate-2.1.2 → crate-2.2.0}/pyproject.toml +1 -1
  7. {crate-2.1.2 → crate-2.2.0}/tests/client/test_cursor.py +73 -0
  8. {crate-2.1.2 → crate-2.2.0}/tests/client/test_http.py +123 -0
  9. {crate-2.1.2 → crate-2.2.0}/tests/client/test_serialization.py +38 -0
  10. {crate-2.1.2 → crate-2.2.0}/tests/testing/test_layer.py +5 -15
  11. {crate-2.1.2 → crate-2.2.0}/.gitignore +0 -0
  12. {crate-2.1.2 → crate-2.2.0}/CONTRIBUTING.rst +0 -0
  13. {crate-2.1.2 → crate-2.2.0}/DEVELOP.rst +0 -0
  14. {crate-2.1.2 → crate-2.2.0}/LICENSE +0 -0
  15. {crate-2.1.2 → crate-2.2.0}/NOTICE +0 -0
  16. {crate-2.1.2 → crate-2.2.0}/README.rst +0 -0
  17. {crate-2.1.2 → crate-2.2.0}/docs/.gitignore +0 -0
  18. {crate-2.1.2 → crate-2.2.0}/docs/Makefile +0 -0
  19. {crate-2.1.2 → crate-2.2.0}/docs/_extra/robots.txt +0 -0
  20. {crate-2.1.2 → crate-2.2.0}/docs/blobs.rst +0 -0
  21. {crate-2.1.2 → crate-2.2.0}/docs/build.json +0 -0
  22. {crate-2.1.2 → crate-2.2.0}/docs/by-example/blob.rst +0 -0
  23. {crate-2.1.2 → crate-2.2.0}/docs/by-example/client.rst +0 -0
  24. {crate-2.1.2 → crate-2.2.0}/docs/by-example/connection.rst +0 -0
  25. {crate-2.1.2 → crate-2.2.0}/docs/by-example/http.rst +0 -0
  26. {crate-2.1.2 → crate-2.2.0}/docs/by-example/https.rst +0 -0
  27. {crate-2.1.2 → crate-2.2.0}/docs/by-example/index.rst +0 -0
  28. {crate-2.1.2 → crate-2.2.0}/docs/conf.py +0 -0
  29. {crate-2.1.2 → crate-2.2.0}/docs/data-types.rst +0 -0
  30. {crate-2.1.2 → crate-2.2.0}/docs/docutils.conf +0 -0
  31. {crate-2.1.2 → crate-2.2.0}/docs/getting-started.rst +0 -0
  32. {crate-2.1.2 → crate-2.2.0}/docs/index-all.rst +0 -0
  33. {crate-2.1.2 → crate-2.2.0}/docs/index.rst +0 -0
  34. {crate-2.1.2 → crate-2.2.0}/docs/other-options.rst +0 -0
  35. {crate-2.1.2 → crate-2.2.0}/docs/requirements.txt +0 -0
  36. {crate-2.1.2 → crate-2.2.0}/examples/README.rst +0 -0
  37. {crate-2.1.2 → crate-2.2.0}/tests/__init__.py +0 -0
  38. {crate-2.1.2 → crate-2.2.0}/tests/assets/import/test_a.json +0 -0
  39. {crate-2.1.2 → crate-2.2.0}/tests/assets/mappings/locations.sql +0 -0
  40. {crate-2.1.2 → crate-2.2.0}/tests/assets/pki/cacert_invalid.pem +0 -0
  41. {crate-2.1.2 → crate-2.2.0}/tests/assets/pki/cacert_valid.pem +0 -0
  42. {crate-2.1.2 → crate-2.2.0}/tests/assets/pki/client_invalid.pem +0 -0
  43. {crate-2.1.2 → crate-2.2.0}/tests/assets/pki/client_valid.pem +0 -0
  44. {crate-2.1.2 → crate-2.2.0}/tests/assets/pki/readme.rst +0 -0
  45. {crate-2.1.2 → crate-2.2.0}/tests/assets/pki/server_valid.pem +0 -0
  46. {crate-2.1.2 → crate-2.2.0}/tests/assets/settings/test_a.json +0 -0
  47. {crate-2.1.2 → crate-2.2.0}/tests/client/__init__.py +0 -0
  48. {crate-2.1.2 → crate-2.2.0}/tests/client/settings.py +0 -0
  49. {crate-2.1.2 → crate-2.2.0}/tests/client/test_blob.py +0 -0
  50. {crate-2.1.2 → crate-2.2.0}/tests/client/test_connection.py +0 -0
  51. {crate-2.1.2 → crate-2.2.0}/tests/client/test_exceptions.py +0 -0
  52. {crate-2.1.2 → crate-2.2.0}/tests/client/test_utils.py +0 -0
  53. {crate-2.1.2 → crate-2.2.0}/tests/conftest.py +0 -0
  54. {crate-2.1.2 → crate-2.2.0}/tests/test_docs.py +0 -0
  55. {crate-2.1.2 → crate-2.2.0}/tests/testing/__init__.py +0 -0
@@ -2,8 +2,23 @@
2
2
  Changes for crate
3
3
  =================
4
4
 
5
- Unreleased
5
+ 2026/06/04 2.2.0
6
6
  ==========
7
+ - Added JSON serialization support for Python's ``datetime.time`` type,
8
+ encoding it as an ISO 8601 string compatible with CrateDB's ``TIMETZ``
9
+ column type.
10
+
11
+ - Added gzip compression for outgoing request bodies via the ``compress``
12
+ parameter (default: ``8192`` bytes).
13
+ Pass ``True`` to always compress, ``False`` to disable, or an integer
14
+ as a byte threshold. The driver always sends ``Accept-Encoding: gzip,
15
+ deflate`` to negotiate compressed responses from the server when
16
+ compression is enabled.
17
+
18
+ - Added named parameter support (``pyformat`` paramstyle). Passing a
19
+ :class:`py:dict` as ``parameters`` to ``cursor.execute()`` now accepts
20
+ ``%(name)s`` placeholders and converts them to positional ``?`` markers
21
+ client-side. Positional parameters using ``?`` continue to work unchanged.
7
22
 
8
23
  2026/03/09 2.1.2
9
24
  ================
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crate
3
- Version: 2.1.2
3
+ Version: 2.2.0
4
4
  Summary: CrateDB Python Client
5
5
  Author-email: "Crate.io" <office@crate.io>
6
6
  License-Expression: Apache-2.0
@@ -311,8 +311,8 @@ Python data type conversion
311
311
  ===========================
312
312
 
313
313
  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``.
314
+ types. Currently, this is implemented for the CrateDB data types ``IP``,
315
+ ``TIMESTAMP``, and ``TIMETZ`` on behalf of the ``DefaultTypeConverter``.
316
316
 
317
317
  >>> cursor = connection.cursor(converter=DefaultTypeConverter())
318
318
 
@@ -329,6 +329,24 @@ types. Currently, this is implemented for the CrateDB data types ``IP`` and
329
329
  >>> cursor.fetchone()
330
330
  ['foo', IPv4Address('10.10.10.1'), datetime.datetime(2022, 7, 18, 18, 10, 36, 758000, tzinfo=datetime.timezone.utc)]
331
331
 
332
+ CrateDB's ``TIMETZ`` type is returned over HTTP as ``[microseconds_since_midnight, tz_offset_seconds]``
333
+ and decoded to a ``datetime.time`` object with the appropriate timezone:
334
+
335
+ >>> cursor = connection.cursor(converter=DefaultTypeConverter())
336
+
337
+ >>> connection.client.set_next_response({
338
+ ... "col_types": [20],
339
+ ... "rows":[ [ [45045000000, 0] ] ],
340
+ ... "cols":[ "t" ],
341
+ ... "rowcount":1,
342
+ ... "duration":1
343
+ ... })
344
+
345
+ >>> cursor.execute('')
346
+
347
+ >>> cursor.fetchone()
348
+ [datetime.time(12, 30, 45, tzinfo=datetime.timezone.utc)]
349
+
332
350
 
333
351
  Custom data type conversion
334
352
  ===========================
@@ -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,6 +54,33 @@ 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 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.
83
+
57
84
  Bulk inserts
58
85
  ------------
59
86
 
@@ -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",
@@ -293,6 +293,41 @@ def test_execute_custom_converter(mocked_connection):
293
293
  ]
294
294
 
295
295
 
296
+ def test_execute_time_converter(mocked_connection):
297
+ """
298
+ Verify that CrateDB's TIMETZ wire format
299
+ ``[microseconds, tz_offset_seconds]`` is decoded to a ``datetime.time``
300
+ object by ``DefaultTypeConverter``.
301
+ """
302
+ converter = DefaultTypeConverter()
303
+ cursor = mocked_connection.cursor(converter=converter)
304
+ response = {
305
+ "col_types": [20],
306
+ "cols": ["t"],
307
+ "rows": [
308
+ [[45045000000, 0]], # 12:30:45 UTC
309
+ [[45045123456, 7200]], # 12:30:45.123456 +02:00
310
+ [None],
311
+ ],
312
+ "rowcount": 3,
313
+ "duration": 1,
314
+ }
315
+
316
+ with mock.patch.object(
317
+ mocked_connection.client, "sql", return_value=response
318
+ ):
319
+ cursor.execute("")
320
+ result = cursor.fetchall()
321
+
322
+ assert result == [
323
+ [datetime.time(12, 30, 45, 0,
324
+ tzinfo=datetime.timezone.utc)],
325
+ [datetime.time(12, 30, 45, 123456,
326
+ tzinfo=datetime.timezone(datetime.timedelta(hours=2)))],
327
+ [None],
328
+ ]
329
+
330
+
296
331
  def test_execute_with_converter_and_invalid_data_type(mocked_connection):
297
332
  converter = DefaultTypeConverter()
298
333
 
@@ -492,6 +527,44 @@ def test_execute_with_timezone(mocked_connection):
492
527
  assert result[0][1].tzname() == "UTC"
493
528
 
494
529
 
530
+ def test_execute_with_named_params(mocked_connection):
531
+ """
532
+ Verify that named %(name)s parameters are converted to positional ? markers
533
+ and the values are passed as an ordered list.
534
+ """
535
+ cursor = mocked_connection.cursor()
536
+ cursor.execute(
537
+ "SELECT * FROM t WHERE a = %(a)s AND b = %(b)s",
538
+ {"a": 1, "b": 2},
539
+ )
540
+ mocked_connection.client.sql.assert_called_once_with(
541
+ "SELECT * FROM t WHERE a = $1 AND b = $2", [1, 2], None
542
+ )
543
+
544
+
545
+ def test_execute_with_named_params_repeated(mocked_connection):
546
+ """
547
+ Verify that a parameter name used multiple times in the SQL is resolved
548
+ correctly each time it appears.
549
+ """
550
+ cursor = mocked_connection.cursor()
551
+ cursor.execute("SELECT %(x)s, %(x)s", {"x": 42})
552
+ mocked_connection.client.sql.assert_called_once_with(
553
+ "SELECT $1, $1", [42], None
554
+ )
555
+
556
+
557
+ def test_execute_with_named_params_missing(mocked_connection):
558
+ """
559
+ Verify that a ProgrammingError is raised when a placeholder name is absent
560
+ from the parameters dict, and that the client is never called.
561
+ """
562
+ cursor = mocked_connection.cursor()
563
+ with pytest.raises(ProgrammingError, match="Named parameter 'z' not found"):
564
+ cursor.execute("SELECT %(z)s", {"a": 1})
565
+ mocked_connection.client.sql.assert_not_called()
566
+
567
+
495
568
  def test_cursor_close(mocked_connection):
496
569
  """
497
570
  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