pgsync 3.1.0__tar.gz → 3.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 (76) hide show
  1. {pgsync-3.1.0 → pgsync-3.2.0}/PKG-INFO +23 -22
  2. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync/__init__.py +1 -1
  3. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync/base.py +57 -43
  4. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync/constants.py +2 -1
  5. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync/helper.py +1 -0
  6. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync/node.py +7 -0
  7. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync/plugin.py +5 -1
  8. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync/querybuilder.py +23 -1
  9. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync/redisqueue.py +1 -0
  10. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync/search_client.py +14 -11
  11. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync/settings.py +2 -1
  12. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync/sync.py +20 -19
  13. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync/transform.py +1 -0
  14. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync/utils.py +1 -0
  15. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync/view.py +16 -9
  16. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync.egg-info/PKG-INFO +23 -22
  17. pgsync-3.2.0/pgsync.egg-info/requires.txt +30 -0
  18. {pgsync-3.1.0 → pgsync-3.2.0}/setup.py +3 -7
  19. {pgsync-3.1.0 → pgsync-3.2.0}/tests/conftest.py +1 -0
  20. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_base.py +26 -0
  21. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_constants.py +2 -0
  22. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_env_vars.py +1 -0
  23. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_helper.py +1 -0
  24. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_log_handlers.py +1 -0
  25. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_node.py +1 -0
  26. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_query_builder.py +1 -0
  27. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_search_client.py +1 -0
  28. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_settings.py +1 -0
  29. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_sync.py +14 -8
  30. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_sync_root.py +1 -0
  31. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_transform.py +1 -0
  32. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_trigger.py +1 -0
  33. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_urls.py +1 -0
  34. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_utils.py +1 -0
  35. pgsync-3.1.0/pgsync.egg-info/requires.txt +0 -29
  36. {pgsync-3.1.0 → pgsync-3.2.0}/AUTHORS.rst +0 -0
  37. {pgsync-3.1.0 → pgsync-3.2.0}/CONTRIBUTING.rst +0 -0
  38. {pgsync-3.1.0 → pgsync-3.2.0}/HISTORY.rst +0 -0
  39. {pgsync-3.1.0 → pgsync-3.2.0}/LICENSE +0 -0
  40. {pgsync-3.1.0 → pgsync-3.2.0}/MANIFEST.in +0 -0
  41. {pgsync-3.1.0 → pgsync-3.2.0}/README.md +0 -0
  42. {pgsync-3.1.0 → pgsync-3.2.0}/README.rst +0 -0
  43. {pgsync-3.1.0 → pgsync-3.2.0}/bin/bootstrap +0 -0
  44. {pgsync-3.1.0 → pgsync-3.2.0}/bin/parallel_sync +0 -0
  45. {pgsync-3.1.0 → pgsync-3.2.0}/bin/pgsync +0 -0
  46. {pgsync-3.1.0 → pgsync-3.2.0}/docs/Makefile +0 -0
  47. {pgsync-3.1.0 → pgsync-3.2.0}/docs/authors.rst +0 -0
  48. {pgsync-3.1.0 → pgsync-3.2.0}/docs/changelog.rst +0 -0
  49. {pgsync-3.1.0 → pgsync-3.2.0}/docs/conf.py +0 -0
  50. {pgsync-3.1.0 → pgsync-3.2.0}/docs/contributing.rst +0 -0
  51. {pgsync-3.1.0 → pgsync-3.2.0}/docs/history.rst +0 -0
  52. {pgsync-3.1.0 → pgsync-3.2.0}/docs/index.rst +0 -0
  53. {pgsync-3.1.0 → pgsync-3.2.0}/docs/installation.rst +0 -0
  54. {pgsync-3.1.0 → pgsync-3.2.0}/docs/logo.png +0 -0
  55. {pgsync-3.1.0 → pgsync-3.2.0}/docs/make.bat +0 -0
  56. {pgsync-3.1.0 → pgsync-3.2.0}/docs/readme.rst +0 -0
  57. {pgsync-3.1.0 → pgsync-3.2.0}/docs/usage.rst +0 -0
  58. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync/exc.py +0 -0
  59. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync/singleton.py +0 -0
  60. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync/trigger.py +0 -0
  61. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync/urls.py +0 -0
  62. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync.egg-info/SOURCES.txt +0 -0
  63. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync.egg-info/dependency_links.txt +0 -0
  64. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync.egg-info/not-zip-safe +0 -0
  65. {pgsync-3.1.0 → pgsync-3.2.0}/pgsync.egg-info/top_level.txt +0 -0
  66. {pgsync-3.1.0 → pgsync-3.2.0}/pyproject.toml +0 -0
  67. {pgsync-3.1.0 → pgsync-3.2.0}/setup.cfg +0 -0
  68. {pgsync-3.1.0 → pgsync-3.2.0}/tests/__init__.py +0 -0
  69. {pgsync-3.1.0 → pgsync-3.2.0}/tests/fixtures/schema.json +0 -0
  70. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_redisqueue.py +0 -0
  71. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_sync_nested_children.py +0 -0
  72. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_sync_single_child_fk_on_child.py +0 -0
  73. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_sync_single_child_fk_on_parent.py +0 -0
  74. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_unique_behaviour.py +0 -0
  75. {pgsync-3.1.0 → pgsync-3.2.0}/tests/test_view.py +0 -0
  76. {pgsync-3.1.0 → pgsync-3.2.0}/tests/testing_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pgsync
3
- Version: 3.1.0
3
+ Version: 3.2.0
4
4
  Summary: Postgres to Elasticsearch/OpenSearch sync
5
5
  Home-page: https://github.com/toluaina/pgsync
6
6
  Author: Tolu Aina
@@ -13,7 +13,7 @@ Project-URL: Funding, https://github.com/sponsors/toluaina
13
13
  Project-URL: Source, https://github.com/toluaina/pgsync
14
14
  Project-URL: Web, https://pgsync.com
15
15
  Project-URL: Documentation, https://pgsync.com
16
- Keywords: pgsync,elasticsearch,opensearch,postgres,change data capture
16
+ Keywords: change data capture,elasticsearch,opensearch,pgsync,postgres
17
17
  Classifier: Development Status :: 5 - Production/Stable
18
18
  Classifier: Intended Audience :: Developers
19
19
  Classifier: Natural Language :: English
@@ -31,34 +31,35 @@ Description-Content-Type: text/markdown
31
31
  License-File: LICENSE
32
32
  License-File: AUTHORS.rst
33
33
  Requires-Dist: async-timeout==4.0.3
34
- Requires-Dist: boto3==1.34.11
35
- Requires-Dist: botocore==1.34.11
36
- Requires-Dist: certifi==2023.11.17
34
+ Requires-Dist: boto3==1.34.142
35
+ Requires-Dist: botocore==1.34.142
36
+ Requires-Dist: certifi==2024.7.4
37
37
  Requires-Dist: charset-normalizer==3.3.2
38
38
  Requires-Dist: click==8.1.7
39
- Requires-Dist: elastic-transport==8.11.0
40
- Requires-Dist: elasticsearch==8.11.1
41
- Requires-Dist: elasticsearch-dsl==8.11.0
42
- Requires-Dist: environs==10.0.0
39
+ Requires-Dist: elastic-transport==8.13.1
40
+ Requires-Dist: elasticsearch==8.14.0
41
+ Requires-Dist: elasticsearch-dsl==8.14.0
42
+ Requires-Dist: environs==11.0.0
43
+ Requires-Dist: events==0.5
43
44
  Requires-Dist: greenlet==3.0.3
44
- Requires-Dist: idna==3.6
45
+ Requires-Dist: idna==3.7
45
46
  Requires-Dist: jmespath==1.0.1
46
- Requires-Dist: marshmallow==3.20.1
47
+ Requires-Dist: marshmallow==3.21.3
47
48
  Requires-Dist: opensearch-dsl==2.1.0
48
- Requires-Dist: opensearch-py==2.4.2
49
- Requires-Dist: packaging==23.2
49
+ Requires-Dist: opensearch-py==2.6.0
50
+ Requires-Dist: packaging==24.1
50
51
  Requires-Dist: psycopg2-binary==2.9.9
51
- Requires-Dist: python-dateutil==2.8.2
52
- Requires-Dist: python-dotenv==1.0.0
53
- Requires-Dist: redis==5.0.1
54
- Requires-Dist: requests==2.31.0
52
+ Requires-Dist: python-dateutil==2.9.0.post0
53
+ Requires-Dist: python-dotenv==1.0.1
54
+ Requires-Dist: redis==5.0.7
55
+ Requires-Dist: requests==2.32.3
55
56
  Requires-Dist: requests-aws4auth==1.2.3
56
- Requires-Dist: s3transfer==0.10.0
57
+ Requires-Dist: s3transfer==0.10.2
57
58
  Requires-Dist: six==1.16.0
58
- Requires-Dist: sqlalchemy==2.0.25
59
- Requires-Dist: sqlparse==0.4.4
60
- Requires-Dist: typing-extensions==4.9.0
61
- Requires-Dist: urllib3==1.26.18
59
+ Requires-Dist: sqlalchemy==2.0.31
60
+ Requires-Dist: sqlparse==0.5.0
61
+ Requires-Dist: typing-extensions==4.12.2
62
+ Requires-Dist: urllib3==1.26.19
62
63
 
63
64
  # PostgreSQL to Elasticsearch/OpenSearch sync
64
65
 
@@ -2,4 +2,4 @@
2
2
 
3
3
  __author__ = "Tolu Aina"
4
4
  __email__ = "tolu@pgsync.com"
5
- __version__ = "3.1.0"
5
+ __version__ = "3.2.0"
@@ -1,4 +1,5 @@
1
1
  """PGSync Base."""
2
+
2
3
  import logging
3
4
  import os
4
5
  import typing as t
@@ -48,6 +49,15 @@ except ImportError:
48
49
 
49
50
  logger = logging.getLogger(__name__)
50
51
 
52
+ SSL_MODES = (
53
+ "allow",
54
+ "disable",
55
+ "prefer",
56
+ "require",
57
+ "verify-ca",
58
+ "verify-full",
59
+ )
60
+
51
61
 
52
62
  class Payload(object):
53
63
  """
@@ -141,6 +151,36 @@ class TupleIdentifierType(sa.types.UserDefinedType):
141
151
 
142
152
 
143
153
  class Base(object):
154
+ INT_TYPES = (
155
+ "bigint",
156
+ "bigserial",
157
+ "int",
158
+ "int2",
159
+ "int4",
160
+ "int8",
161
+ "integer",
162
+ "serial",
163
+ "serial2",
164
+ "serial4",
165
+ "serial8",
166
+ "smallint",
167
+ "smallserial",
168
+ )
169
+ FLOAT_TYPES = (
170
+ "double precision",
171
+ "float4",
172
+ "float8",
173
+ "real",
174
+ )
175
+ CHAR_TYPES = (
176
+ "char",
177
+ "character",
178
+ "character varying",
179
+ "text",
180
+ "uuid",
181
+ "varchar",
182
+ )
183
+
144
184
  def __init__(
145
185
  self, database: str, verbose: bool = False, *args, **kwargs
146
186
  ) -> None:
@@ -433,7 +473,7 @@ class Base(object):
433
473
  func: sa.sql.functions._FunctionGenerator,
434
474
  txmin: t.Optional[int] = None,
435
475
  txmax: t.Optional[int] = None,
436
- upto_lsn: t.Optional[int] = None,
476
+ upto_lsn: t.Optional[str] = None,
437
477
  upto_nchanges: t.Optional[int] = None,
438
478
  limit: t.Optional[int] = None,
439
479
  offset: t.Optional[int] = None,
@@ -446,7 +486,7 @@ class Base(object):
446
486
  func (sa.sql.functions._FunctionGenerator): The function to use to read from the slot.
447
487
  txmin (Optional[int], optional): The minimum transaction ID to read from. Defaults to None.
448
488
  txmax (Optional[int], optional): The maximum transaction ID to read from. Defaults to None.
449
- upto_lsn (Optional[int], optional): The maximum LSN to read up to. Defaults to None.
489
+ upto_lsn (Optional[str], optional): The maximum LSN to read up to. Defaults to None.
450
490
  upto_nchanges (Optional[int], optional): The maximum number of changes to read. Defaults to None.
451
491
  limit (Optional[int], optional): The maximum number of rows to return. Defaults to None.
452
492
  offset (Optional[int], optional): The number of rows to skip before returning. Defaults to None.
@@ -489,12 +529,20 @@ class Base(object):
489
529
  statement = statement.offset(offset)
490
530
  return statement
491
531
 
532
+ @property
533
+ def current_wal_lsn(self) -> str:
534
+ return self.fetchone(
535
+ sa.select(sa.func.MAX(sa.text("pg_current_wal_lsn"))).select_from(
536
+ sa.func.PG_CURRENT_WAL_LSN()
537
+ )
538
+ )[0]
539
+
492
540
  def logical_slot_get_changes(
493
541
  self,
494
542
  slot_name: str,
495
543
  txmin: t.Optional[int] = None,
496
544
  txmax: t.Optional[int] = None,
497
- upto_lsn: t.Optional[int] = None,
545
+ upto_lsn: t.Optional[str] = None,
498
546
  upto_nchanges: t.Optional[int] = None,
499
547
  limit: t.Optional[int] = None,
500
548
  offset: t.Optional[int] = None,
@@ -524,7 +572,7 @@ class Base(object):
524
572
  slot_name: str,
525
573
  txmin: t.Optional[int] = None,
526
574
  txmax: t.Optional[int] = None,
527
- upto_lsn: t.Optional[int] = None,
575
+ upto_lsn: t.Optional[str] = None,
528
576
  upto_nchanges: t.Optional[int] = None,
529
577
  limit: t.Optional[int] = None,
530
578
  offset: t.Optional[int] = None,
@@ -550,7 +598,7 @@ class Base(object):
550
598
  slot_name: str,
551
599
  txmin: t.Optional[int] = None,
552
600
  txmax: t.Optional[int] = None,
553
- upto_lsn: t.Optional[int] = None,
601
+ upto_lsn: t.Optional[str] = None,
554
602
  upto_nchanges: t.Optional[int] = None,
555
603
  ) -> int:
556
604
  statement: sa.sql.Select = self._logical_slot_changes(
@@ -732,43 +780,16 @@ class Base(object):
732
780
  """
733
781
  if value.lower() == "null":
734
782
  return None
735
-
736
- if type_.lower() in (
737
- "bigint",
738
- "bigserial",
739
- "int",
740
- "int2",
741
- "int4",
742
- "int8",
743
- "integer",
744
- "serial",
745
- "serial2",
746
- "serial4",
747
- "serial8",
748
- "smallint",
749
- "smallserial",
750
- ):
783
+ if type_.lower() in self.INT_TYPES:
751
784
  try:
752
785
  value = int(value)
753
786
  except ValueError:
754
787
  raise
755
- if type_.lower() in (
756
- "char",
757
- "character",
758
- "character varying",
759
- "text",
760
- "uuid",
761
- "varchar",
762
- ):
788
+ if type_.lower() in self.CHAR_TYPES:
763
789
  value = value.lstrip("'").rstrip("'")
764
790
  if type_.lower() == "boolean":
765
791
  value = bool(value)
766
- if type_.lower() in (
767
- "double precision",
768
- "float4",
769
- "float8",
770
- "real",
771
- ):
792
+ if type_.lower() in self.FLOAT_TYPES:
772
793
  try:
773
794
  value = float(value)
774
795
  except ValueError:
@@ -999,14 +1020,7 @@ def _pg_engine(
999
1020
  sslrootcert = sslrootcert or PG_SSLROOTCERT
1000
1021
 
1001
1022
  if sslmode:
1002
- if sslmode not in (
1003
- "allow",
1004
- "disable",
1005
- "prefer",
1006
- "require",
1007
- "verify-ca",
1008
- "verify-full",
1009
- ):
1023
+ if sslmode not in SSL_MODES:
1010
1024
  raise ValueError(f'Invalid sslmode: "{sslmode}"')
1011
1025
  connect_args["sslmode"] = sslmode
1012
1026
 
@@ -89,6 +89,7 @@ ELASTICSEARCH_TYPES = [
89
89
  "constant_keyword",
90
90
  "date",
91
91
  "date_range",
92
+ "dense_vector",
92
93
  "double",
93
94
  "double_range",
94
95
  "flattened",
@@ -207,5 +208,5 @@ LOGICAL_SLOT_PREFIX = re.compile(
207
208
  r"table\s\"?(?P<schema>[\w-]+)\"?.\"?(?P<table>[\w-]+)\"?:\s(?P<tg_op>[A-Z]+):" # noqa E501
208
209
  )
209
210
  LOGICAL_SLOT_SUFFIX = re.compile(
210
- '\s(?P<key>"?\w+"?)\[(?P<type>[\w\s]+)\]:(?P<value>[\w\'"\-]+)'
211
+ '\s(?P<key>"?\w+"?)\[(?P<type>[\w\s]+)\]:(?P<value>[\w\'"\-\\.\\+]+)'
211
212
  )
@@ -1,4 +1,5 @@
1
1
  """PGSync helpers."""
2
+
2
3
  import logging
3
4
  import os
4
5
  import typing as t
@@ -1,4 +1,5 @@
1
1
  """PGSync Node class representation."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import re
@@ -68,6 +69,9 @@ class ForeignKey:
68
69
  def __str__(self):
69
70
  return f"foreign_key: {self.parent}:{self.child}"
70
71
 
72
+ def __repr__(self):
73
+ return self.__str__()
74
+
71
75
 
72
76
  @dataclass
73
77
  class Relationship:
@@ -113,6 +117,9 @@ class Relationship:
113
117
  def __str__(self):
114
118
  return f"relationship: {self.variant}.{self.type}:{self.tables}"
115
119
 
120
+ def __repr__(self):
121
+ return self.__str__()
122
+
116
123
 
117
124
  @dataclass
118
125
  class Node(object):
@@ -1,6 +1,8 @@
1
1
  """PGSync Plugin."""
2
+
2
3
  import logging
3
4
  import os
5
+ import sys
4
6
  import typing as t
5
7
  from abc import ABC, abstractmethod
6
8
  from importlib import import_module
@@ -42,7 +44,9 @@ class Plugins(object):
42
44
  self.plugins: list = []
43
45
  self._paths: list = []
44
46
  logger.debug(f"Reloading plugins from package: {self.package}")
45
- self.walk(self.package)
47
+ # skip in test
48
+ if "test" not in sys.argv[0]:
49
+ self.walk(self.package)
46
50
 
47
51
  def walk(self, package: str) -> None:
48
52
  """Recursively walk the supplied package and fetch all plugins."""
@@ -1,4 +1,5 @@
1
1
  """PGSync QueryBuilder."""
2
+
2
3
  import threading
3
4
  import typing as t
4
5
  from collections import defaultdict
@@ -20,6 +21,23 @@ class QueryBuilder(threading.local):
20
21
  self.isouter: bool = True
21
22
  self._cache: dict = {}
22
23
 
24
+ def _eval_expression(
25
+ self, expression: sa.sql.elements.BinaryExpression
26
+ ) -> sa.sql.elements.BinaryExpression:
27
+ if isinstance(
28
+ expression.left.type, sa.dialects.postgresql.UUID
29
+ ) or isinstance(expression.right.type, sa.dialects.postgresql.UUID):
30
+ if not isinstance(
31
+ expression.left.type, sa.dialects.postgresql.UUID
32
+ ) or not isinstance(
33
+ expression.right.type, sa.dialects.postgresql.UUID
34
+ ):
35
+ # handle UUID typed expressions:
36
+ # psycopg2.errors.UndefinedFunction: operator does not exist: uuid = integer
37
+ return expression.left is None
38
+
39
+ return expression
40
+
23
41
  def _build_filters(
24
42
  self, filters: t.Dict[str, t.List[dict]], node: Node
25
43
  ) -> t.Optional[sa.sql.elements.BooleanClauseList]:
@@ -45,7 +63,11 @@ class QueryBuilder(threading.local):
45
63
  for values in filters.get(node.table):
46
64
  where: t.List = []
47
65
  for column, value in values.items():
48
- where.append(node.model.c[column] == value)
66
+ where.append(
67
+ self._eval_expression(
68
+ node.model.c[column] == value
69
+ )
70
+ )
49
71
  # and clause is applied for composite primary keys
50
72
  clause.append(sa.and_(*where))
51
73
  return sa.or_(*clause)
@@ -1,4 +1,5 @@
1
1
  """PGSync RedisQueue."""
2
+
2
3
  import json
3
4
  import logging
4
5
  import typing as t
@@ -1,4 +1,5 @@
1
1
  """PGSync SearchClient helper."""
2
+
2
3
  import logging
3
4
  import typing as t
4
5
  from collections import defaultdict
@@ -245,7 +246,7 @@ class SearchClient(object):
245
246
  if "is out of range for a long" not in str(e):
246
247
  raise
247
248
 
248
- def search(self, index: str, body: dict):
249
+ def search(self, index: str, body: dict) -> t.Any:
249
250
  """
250
251
  Search in Elasticsearch/OpenSearch.
251
252
 
@@ -259,6 +260,7 @@ class SearchClient(object):
259
260
  tree: Tree,
260
261
  setting: t.Optional[dict] = None,
261
262
  mapping: t.Optional[dict] = None,
263
+ mappings: t.Optional[dict] = None,
262
264
  routing: t.Optional[str] = None,
263
265
  ) -> None:
264
266
  """Create Elasticsearch/OpenSearch setting and mapping if required."""
@@ -267,7 +269,8 @@ class SearchClient(object):
267
269
  if not self.__client.indices.exists(index=index):
268
270
  if setting:
269
271
  body.update(**{"settings": {"index": setting}})
270
-
272
+ if mappings:
273
+ body.update(**{"mappings": {"index": mappings}})
271
274
  if mapping:
272
275
  if "dynamic_templates" in mapping:
273
276
  body.update(**{"mappings": mapping})
@@ -404,9 +407,9 @@ def get_search_client(
404
407
  # API
405
408
  cloud_id: t.Optional[str] = settings.ELASTICSEARCH_CLOUD_ID
406
409
  api_key: t.Optional[t.Union[str, t.Tuple[str, str]]] = None
407
- http_auth: t.Optional[
408
- t.Union[str, t.Tuple[str, str]]
409
- ] = settings.ELASTICSEARCH_HTTP_AUTH
410
+ http_auth: t.Optional[t.Union[str, t.Tuple[str, str]]] = (
411
+ settings.ELASTICSEARCH_HTTP_AUTH
412
+ )
410
413
  if (
411
414
  settings.ELASTICSEARCH_API_KEY_ID
412
415
  and settings.ELASTICSEARCH_API_KEY
@@ -424,12 +427,12 @@ def get_search_client(
424
427
  ca_certs: t.Optional[str] = settings.ELASTICSEARCH_CA_CERTS
425
428
  client_cert: t.Optional[str] = settings.ELASTICSEARCH_CLIENT_CERT
426
429
  client_key: t.Optional[str] = settings.ELASTICSEARCH_CLIENT_KEY
427
- ssl_assert_hostname: t.Optional[
428
- str
429
- ] = settings.ELASTICSEARCH_SSL_ASSERT_HOSTNAME
430
- ssl_assert_fingerprint: t.Optional[
431
- str
432
- ] = settings.ELASTICSEARCH_SSL_ASSERT_FINGERPRINT
430
+ ssl_assert_hostname: t.Optional[str] = (
431
+ settings.ELASTICSEARCH_SSL_ASSERT_HOSTNAME
432
+ )
433
+ ssl_assert_fingerprint: t.Optional[str] = (
434
+ settings.ELASTICSEARCH_SSL_ASSERT_FINGERPRINT
435
+ )
433
436
  ssl_version: t.Optional[int] = settings.ELASTICSEARCH_SSL_VERSION
434
437
  ssl_context: t.Optional[t.Any] = settings.ELASTICSEARCH_SSL_CONTEXT
435
438
  ssl_show_warn: bool = settings.ELASTICSEARCH_SSL_SHOW_WARN
@@ -4,6 +4,7 @@ This module contains the settings for PGSync.
4
4
  It reads environment variables from a .env file and sets default values for each variable.
5
5
  The variables are used to configure various parameters such as block size, checkpoint path, polling interval, etc.
6
6
  """
7
+
7
8
  import logging
8
9
  import logging.config
9
10
  import os
@@ -148,7 +149,7 @@ elif ELASTICSEARCH:
148
149
  OPENSEARCH_AWS_HOSTED = env.bool("OPENSEARCH_AWS_HOSTED", default=False)
149
150
  OPENSEARCH_AWS_SERVERLESS = env.bool(
150
151
  "OPENSEARCH_AWS_SERVERLESS", default=False
151
- ) # noqa E501
152
+ )
152
153
 
153
154
  # Postgres:
154
155
  PG_HOST = env.str("PG_HOST", default="localhost")
@@ -1,4 +1,5 @@
1
1
  """Sync module."""
2
+
2
3
  import asyncio
3
4
  import json
4
5
  import logging
@@ -81,6 +82,7 @@ class Sync(Base, metaclass=Singleton):
81
82
  self.nodes: dict = doc.get("nodes", {})
82
83
  self.setting: dict = doc.get("setting")
83
84
  self.mapping: dict = doc.get("mapping")
85
+ self.mappings: dict = doc.get("mappings")
84
86
  self.routing: str = doc.get("routing")
85
87
  super().__init__(
86
88
  doc.get("database", self.index), verbose=verbose, **kwargs
@@ -254,6 +256,7 @@ class Sync(Base, metaclass=Singleton):
254
256
  self.tree,
255
257
  setting=self.setting,
256
258
  mapping=self.mapping,
259
+ mappings=self.mappings,
257
260
  routing=self.routing,
258
261
  )
259
262
 
@@ -347,6 +350,7 @@ class Sync(Base, metaclass=Singleton):
347
350
  txmin: t.Optional[int] = None,
348
351
  txmax: t.Optional[int] = None,
349
352
  upto_nchanges: t.Optional[int] = None,
353
+ upto_lsn: t.Optional[str] = None,
350
354
  ) -> None:
351
355
  """
352
356
  Process changes from the db logical replication logs.
@@ -374,25 +378,15 @@ class Sync(Base, metaclass=Singleton):
374
378
  # minimize the tmp file disk usage when calling
375
379
  # PG_LOGICAL_SLOT_PEEK_CHANGES and PG_LOGICAL_SLOT_GET_CHANGES
376
380
  # by limiting to a smaller batch size.
377
- offset: int = 0
378
- total: int = 0
379
- limit: int = settings.LOGICAL_SLOT_CHUNK_SIZE
380
- count: int = self.logical_slot_count_changes(
381
- self.__name,
382
- txmin=txmin,
383
- txmax=txmax,
384
- upto_nchanges=upto_nchanges,
385
- )
386
381
  while True:
387
382
  changes: int = self.logical_slot_peek_changes(
388
383
  self.__name,
389
384
  txmin=txmin,
390
385
  txmax=txmax,
391
386
  upto_nchanges=upto_nchanges,
392
- limit=limit,
393
- offset=offset,
387
+ upto_lsn=upto_lsn,
394
388
  )
395
- if not changes or total > count:
389
+ if not changes:
396
390
  break
397
391
 
398
392
  rows: list = []
@@ -447,11 +441,8 @@ class Sync(Base, metaclass=Singleton):
447
441
  txmin=txmin,
448
442
  txmax=txmax,
449
443
  upto_nchanges=upto_nchanges,
450
- limit=limit,
451
- offset=offset,
444
+ upto_lsn=upto_lsn,
452
445
  )
453
- offset += limit
454
- total += len(changes)
455
446
  self.count["xlog"] += len(rows)
456
447
 
457
448
  def _root_primary_key_resolver(
@@ -1120,7 +1111,7 @@ class Sync(Base, metaclass=Singleton):
1120
1111
  notification: t.AnyStr = conn.notifies.pop(0)
1121
1112
  if notification.channel == self.database:
1122
1113
  payload = json.loads(notification.payload)
1123
- if self.index in payload["indices"]:
1114
+ if payload["indices"] and self.index in payload["indices"]:
1124
1115
  payloads.append(payload)
1125
1116
  logger.debug(f"poll_db: {payload}")
1126
1117
  self.count["db"] += 1
@@ -1142,7 +1133,7 @@ class Sync(Base, metaclass=Singleton):
1142
1133
  notification: t.AnyStr = self.conn.notifies.pop(0)
1143
1134
  if notification.channel == self.database:
1144
1135
  payload = json.loads(notification.payload)
1145
- if self.index in payload["indices"]:
1136
+ if payload["indices"] and self.index in payload["indices"]:
1146
1137
  self.redis.push([payload])
1147
1138
  logger.debug(f"async_poll: {payload}")
1148
1139
  self.count["db"] += 1
@@ -1224,13 +1215,22 @@ class Sync(Base, metaclass=Singleton):
1224
1215
  """Pull data from db."""
1225
1216
  txmin: int = self.checkpoint
1226
1217
  txmax: int = self.txid_current
1218
+ # this is the max lsn we should go upto
1219
+ upto_lsn: str = self.current_wal_lsn
1220
+ upto_nchanges: int = settings.LOGICAL_SLOT_CHUNK_SIZE
1221
+
1227
1222
  logger.debug(f"pull txmin: {txmin} - txmax: {txmax}")
1228
1223
  # forward pass sync
1229
1224
  self.search_client.bulk(
1230
1225
  self.index, self.sync(txmin=txmin, txmax=txmax)
1231
1226
  )
1232
1227
  # now sync up to txmax to capture everything we may have missed
1233
- self.logical_slot_changes(txmin=txmin, txmax=txmax, upto_nchanges=None)
1228
+ self.logical_slot_changes(
1229
+ txmin=txmin,
1230
+ txmax=txmax,
1231
+ upto_nchanges=upto_nchanges,
1232
+ upto_lsn=upto_lsn,
1233
+ )
1234
1234
  self.checkpoint: int = txmax or self.txid_current
1235
1235
  self._truncate = True
1236
1236
 
@@ -1267,6 +1267,7 @@ class Sync(Base, metaclass=Singleton):
1267
1267
  await asyncio.sleep(settings.LOG_INTERVAL)
1268
1268
 
1269
1269
  def _status(self, label: str) -> None:
1270
+ # TODO: indicate if we are processing logical logs or not
1270
1271
  sys.stdout.write(
1271
1272
  f"{label} {self.database}:{self.index} "
1272
1273
  f"Xlog: [{self.count['xlog']:,}] => "
@@ -1,4 +1,5 @@
1
1
  """PGSync Transform."""
2
+
2
3
  import logging
3
4
  import typing as t
4
5
 
@@ -1,4 +1,5 @@
1
1
  """PGSync utils."""
2
+
2
3
  import json
3
4
  import logging
4
5
  import os
@@ -1,4 +1,5 @@
1
1
  """PGSync views."""
2
+
2
3
  import logging
3
4
  import typing as t
4
5
  import warnings
@@ -457,15 +458,21 @@ def create_view(
457
458
  [
458
459
  (
459
460
  table_name,
460
- array(fields["primary_keys"])
461
- if fields.get("primary_keys")
462
- else None,
463
- array(fields.get("foreign_keys"))
464
- if fields.get("foreign_keys")
465
- else None,
466
- array(fields.get("indices"))
467
- if fields.get("indices")
468
- else None,
461
+ (
462
+ array(fields["primary_keys"])
463
+ if fields.get("primary_keys")
464
+ else None
465
+ ),
466
+ (
467
+ array(fields.get("foreign_keys"))
468
+ if fields.get("foreign_keys")
469
+ else None
470
+ ),
471
+ (
472
+ array(fields.get("indices"))
473
+ if fields.get("indices")
474
+ else None
475
+ ),
469
476
  )
470
477
  for table_name, fields in rows.items()
471
478
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pgsync
3
- Version: 3.1.0
3
+ Version: 3.2.0
4
4
  Summary: Postgres to Elasticsearch/OpenSearch sync
5
5
  Home-page: https://github.com/toluaina/pgsync
6
6
  Author: Tolu Aina
@@ -13,7 +13,7 @@ Project-URL: Funding, https://github.com/sponsors/toluaina
13
13
  Project-URL: Source, https://github.com/toluaina/pgsync
14
14
  Project-URL: Web, https://pgsync.com
15
15
  Project-URL: Documentation, https://pgsync.com
16
- Keywords: pgsync,elasticsearch,opensearch,postgres,change data capture
16
+ Keywords: change data capture,elasticsearch,opensearch,pgsync,postgres
17
17
  Classifier: Development Status :: 5 - Production/Stable
18
18
  Classifier: Intended Audience :: Developers
19
19
  Classifier: Natural Language :: English
@@ -31,34 +31,35 @@ Description-Content-Type: text/markdown
31
31
  License-File: LICENSE
32
32
  License-File: AUTHORS.rst
33
33
  Requires-Dist: async-timeout==4.0.3
34
- Requires-Dist: boto3==1.34.11
35
- Requires-Dist: botocore==1.34.11
36
- Requires-Dist: certifi==2023.11.17
34
+ Requires-Dist: boto3==1.34.142
35
+ Requires-Dist: botocore==1.34.142
36
+ Requires-Dist: certifi==2024.7.4
37
37
  Requires-Dist: charset-normalizer==3.3.2
38
38
  Requires-Dist: click==8.1.7
39
- Requires-Dist: elastic-transport==8.11.0
40
- Requires-Dist: elasticsearch==8.11.1
41
- Requires-Dist: elasticsearch-dsl==8.11.0
42
- Requires-Dist: environs==10.0.0
39
+ Requires-Dist: elastic-transport==8.13.1
40
+ Requires-Dist: elasticsearch==8.14.0
41
+ Requires-Dist: elasticsearch-dsl==8.14.0
42
+ Requires-Dist: environs==11.0.0
43
+ Requires-Dist: events==0.5
43
44
  Requires-Dist: greenlet==3.0.3
44
- Requires-Dist: idna==3.6
45
+ Requires-Dist: idna==3.7
45
46
  Requires-Dist: jmespath==1.0.1
46
- Requires-Dist: marshmallow==3.20.1
47
+ Requires-Dist: marshmallow==3.21.3
47
48
  Requires-Dist: opensearch-dsl==2.1.0
48
- Requires-Dist: opensearch-py==2.4.2
49
- Requires-Dist: packaging==23.2
49
+ Requires-Dist: opensearch-py==2.6.0
50
+ Requires-Dist: packaging==24.1
50
51
  Requires-Dist: psycopg2-binary==2.9.9
51
- Requires-Dist: python-dateutil==2.8.2
52
- Requires-Dist: python-dotenv==1.0.0
53
- Requires-Dist: redis==5.0.1
54
- Requires-Dist: requests==2.31.0
52
+ Requires-Dist: python-dateutil==2.9.0.post0
53
+ Requires-Dist: python-dotenv==1.0.1
54
+ Requires-Dist: redis==5.0.7
55
+ Requires-Dist: requests==2.32.3
55
56
  Requires-Dist: requests-aws4auth==1.2.3
56
- Requires-Dist: s3transfer==0.10.0
57
+ Requires-Dist: s3transfer==0.10.2
57
58
  Requires-Dist: six==1.16.0
58
- Requires-Dist: sqlalchemy==2.0.25
59
- Requires-Dist: sqlparse==0.4.4
60
- Requires-Dist: typing-extensions==4.9.0
61
- Requires-Dist: urllib3==1.26.18
59
+ Requires-Dist: sqlalchemy==2.0.31
60
+ Requires-Dist: sqlparse==0.5.0
61
+ Requires-Dist: typing-extensions==4.12.2
62
+ Requires-Dist: urllib3==1.26.19
62
63
 
63
64
  # PostgreSQL to Elasticsearch/OpenSearch sync
64
65
 
@@ -0,0 +1,30 @@
1
+ async-timeout==4.0.3
2
+ boto3==1.34.142
3
+ botocore==1.34.142
4
+ certifi==2024.7.4
5
+ charset-normalizer==3.3.2
6
+ click==8.1.7
7
+ elastic-transport==8.13.1
8
+ elasticsearch==8.14.0
9
+ elasticsearch-dsl==8.14.0
10
+ environs==11.0.0
11
+ events==0.5
12
+ greenlet==3.0.3
13
+ idna==3.7
14
+ jmespath==1.0.1
15
+ marshmallow==3.21.3
16
+ opensearch-dsl==2.1.0
17
+ opensearch-py==2.6.0
18
+ packaging==24.1
19
+ psycopg2-binary==2.9.9
20
+ python-dateutil==2.9.0.post0
21
+ python-dotenv==1.0.1
22
+ redis==5.0.7
23
+ requests==2.32.3
24
+ requests-aws4auth==1.2.3
25
+ s3transfer==0.10.2
26
+ six==1.16.0
27
+ sqlalchemy==2.0.31
28
+ sqlparse==0.5.0
29
+ typing-extensions==4.12.2
30
+ urllib3==1.26.19
@@ -28,11 +28,11 @@ PYTHON_REQUIRES = ">=3.8.0"
28
28
  VERSION = get_version()
29
29
  INSTALL_REQUIRES = []
30
30
  KEYWORDS = [
31
- "pgsync",
31
+ "change data capture",
32
32
  "elasticsearch",
33
33
  "opensearch",
34
+ "pgsync",
34
35
  "postgres",
35
- "change data capture",
36
36
  ]
37
37
  CLASSIFIERS = [
38
38
  "Development Status :: 5 - Production/Stable",
@@ -48,11 +48,7 @@ CLASSIFIERS = [
48
48
  "License :: OSI Approved :: MIT License",
49
49
  "Operating System :: OS Independent",
50
50
  ]
51
- SCRIPTS = [
52
- "bin/pgsync",
53
- "bin/bootstrap",
54
- "bin/parallel_sync",
55
- ]
51
+ SCRIPTS = ["bin/bootstrap", "bin/parallel_sync", "bin/pgsync"]
56
52
  SETUP_REQUIRES = ["pytest-runner"]
57
53
  TESTS_REQUIRE = ["pytest"]
58
54
 
@@ -1,4 +1,5 @@
1
1
  """Generic fixtures for PGSync tests."""
2
+
2
3
  import logging
3
4
  import os
4
5
 
@@ -462,6 +462,32 @@ class TestBase(object):
462
462
  pg_base.parse_logical_slot(row)
463
463
  assert '"Unknown UNKNOWN operation for row:' in str(excinfo.value)
464
464
 
465
+ def test_parse_logical_slot_with_double_precision(
466
+ self,
467
+ connection,
468
+ ):
469
+ pg_base = Base(connection.engine.url.database)
470
+ row = """
471
+ table public.book: UPDATE: id[integer]:1 isbn[character varying]:'001' title[character varying]:'It' description[character varying]:'Stephens Kings It' copyright[character varying]:null tags[jsonb]:'["a", "b", "c"]' doc[jsonb]:'{"a": {"b": {"c": [0, 1, 2, 3, 4]}}, "i": 73, "x": [{"y": 0, "z": 5}, {"y": 1, "z": 6}], "bool": true, "lastname": "Judye", "firstname": "Glenda", "generation": {"name": "X"}, "nick_names": ["Beatriz", "Jean", "Carilyn", "Carol-Jean", "Sara-Ann"], "coordinates": {"lat": 21.1, "lon": 32.9}}' publisher_id[integer]:1 publish_date[timestamp without time zone]:'1980-01-01 00:00:00' quad[double precision]:2e+58
472
+ """ # noqa E501
473
+ payload = pg_base.parse_logical_slot(row)
474
+ assert payload.data == {
475
+ "id": 1,
476
+ "isbn": "001",
477
+ "title": "It",
478
+ "description": "Stephens",
479
+ "copyright": None,
480
+ "tags": "'",
481
+ "doc": "'",
482
+ "publisher_id": 1,
483
+ "publish_date": "'1980-01-01",
484
+ "quad": 2e58,
485
+ }
486
+ assert payload.old == {}
487
+ assert payload.schema == "public"
488
+ assert payload.table == "book"
489
+ assert payload.tg_op == "UPDATE"
490
+
465
491
  def test_fetchone(self, connection):
466
492
  pg_base = Base(connection.engine.url.database, verbose=True)
467
493
  with patch("pgsync.base.compiled_query") as mock_compiled_query:
@@ -1,4 +1,5 @@
1
1
  """Constants tests."""
2
+
2
3
  from pgsync.constants import (
3
4
  ELASTICSEARCH_MAPPING_PARAMETERS,
4
5
  ELASTICSEARCH_TYPES,
@@ -98,6 +99,7 @@ class TestConstants(object):
98
99
  "constant_keyword",
99
100
  "date",
100
101
  "double",
102
+ "dense_vector",
101
103
  "float",
102
104
  "geo_point",
103
105
  "geo_shape",
@@ -1,4 +1,5 @@
1
1
  """Env vars tests."""
2
+
2
3
  import pytest
3
4
 
4
5
 
@@ -1,4 +1,5 @@
1
1
  """Helper tests."""
2
+
2
3
  import pytest
3
4
  import sqlalchemy as sa
4
5
  from mock import ANY, call, patch
@@ -1,4 +1,5 @@
1
1
  """Loghandler tests."""
2
+
2
3
  import pytest
3
4
 
4
5
 
@@ -1,4 +1,5 @@
1
1
  """Node tests."""
2
+
2
3
  import pytest
3
4
 
4
5
  from pgsync.base import Base
@@ -1,4 +1,5 @@
1
1
  """QueryBuilder tests."""
2
+
2
3
  import pytest
3
4
 
4
5
  from pgsync.base import Base
@@ -1,4 +1,5 @@
1
1
  """SearchClient tests."""
2
+
2
3
  import importlib
3
4
 
4
5
  import elastic_transport
@@ -1,4 +1,5 @@
1
1
  """Tests for the settings module."""
2
+
2
3
  import importlib
3
4
 
4
5
  from pgsync import settings
@@ -1,4 +1,5 @@
1
1
  """Sync tests."""
2
+
2
3
  import importlib
3
4
  import os
4
5
  import typing as t
@@ -81,8 +82,7 @@ class TestSync(object):
81
82
  txmin=None,
82
83
  txmax=None,
83
84
  upto_nchanges=None,
84
- limit=settings.LOGICAL_SLOT_CHUNK_SIZE,
85
- offset=0,
85
+ upto_lsn=None,
86
86
  )
87
87
  mock_sync.assert_not_called()
88
88
 
@@ -98,8 +98,7 @@ class TestSync(object):
98
98
  txmin=None,
99
99
  txmax=None,
100
100
  upto_nchanges=None,
101
- limit=settings.LOGICAL_SLOT_CHUNK_SIZE,
102
- offset=0,
101
+ upto_lsn=None,
103
102
  )
104
103
  mock_sync.assert_not_called()
105
104
 
@@ -127,8 +126,7 @@ class TestSync(object):
127
126
  txmin=None,
128
127
  txmax=None,
129
128
  upto_nchanges=None,
130
- limit=settings.LOGICAL_SLOT_CHUNK_SIZE,
131
- offset=0,
129
+ upto_lsn=None,
132
130
  )
133
131
  mock_get.assert_called_once()
134
132
  mock_sync.assert_called_once()
@@ -355,7 +353,10 @@ class TestSync(object):
355
353
  txmin = None
356
354
  txmax = sync.txid_current - 1
357
355
  mock_get.assert_called_once_with(
358
- txmin=txmin, txmax=txmax, upto_nchanges=None
356
+ txmin=txmin,
357
+ txmax=txmax,
358
+ upto_nchanges=settings.LOGICAL_SLOT_CHUNK_SIZE,
359
+ upto_lsn=ANY,
359
360
  )
360
361
  mock_logger.debug.assert_called_once_with(
361
362
  f"pull txmin: {txmin} - txmax: {txmax}"
@@ -762,7 +763,12 @@ class TestSync(object):
762
763
  def test_create_setting(self, mock_es, sync):
763
764
  sync.create_setting()
764
765
  mock_es.assert_called_once_with(
765
- "testdb", ANY, setting=None, mapping=None, routing=None
766
+ "testdb",
767
+ ANY,
768
+ setting=None,
769
+ mapping=None,
770
+ mappings=None,
771
+ routing=None,
766
772
  )
767
773
 
768
774
  @patch("pgsync.sync.Sync.teardown")
@@ -1,4 +1,5 @@
1
1
  """Tests for `pgsync` package."""
2
+
2
3
  import mock
3
4
  import pytest
4
5
 
@@ -1,4 +1,5 @@
1
1
  """Transform tests."""
2
+
2
3
  import pytest
3
4
 
4
5
  from pgsync.constants import CONCAT_TRANSFORM, RENAME_TRANSFORM
@@ -1,4 +1,5 @@
1
1
  """Trigger tests."""
2
+
2
3
  import pytest
3
4
  import sqlalchemy as sa
4
5
 
@@ -1,4 +1,5 @@
1
1
  """URLS tests."""
2
+
2
3
  import pytest
3
4
  from mock import MagicMock, patch
4
5
 
@@ -1,4 +1,5 @@
1
1
  """Utils tests."""
2
+
2
3
  import os
3
4
  from urllib.parse import ParseResult, urlparse
4
5
 
@@ -1,29 +0,0 @@
1
- async-timeout==4.0.3
2
- boto3==1.34.11
3
- botocore==1.34.11
4
- certifi==2023.11.17
5
- charset-normalizer==3.3.2
6
- click==8.1.7
7
- elastic-transport==8.11.0
8
- elasticsearch==8.11.1
9
- elasticsearch-dsl==8.11.0
10
- environs==10.0.0
11
- greenlet==3.0.3
12
- idna==3.6
13
- jmespath==1.0.1
14
- marshmallow==3.20.1
15
- opensearch-dsl==2.1.0
16
- opensearch-py==2.4.2
17
- packaging==23.2
18
- psycopg2-binary==2.9.9
19
- python-dateutil==2.8.2
20
- python-dotenv==1.0.0
21
- redis==5.0.1
22
- requests==2.31.0
23
- requests-aws4auth==1.2.3
24
- s3transfer==0.10.0
25
- six==1.16.0
26
- sqlalchemy==2.0.25
27
- sqlparse==0.4.4
28
- typing-extensions==4.9.0
29
- urllib3==1.26.18
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