polypheny-polynom 0.1.0__py3-none-any.whl

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.
polynom/__init__.py ADDED
File without changes
polynom/application.py ADDED
@@ -0,0 +1,285 @@
1
+ import polypheny
2
+ import docker
3
+ import logging
4
+ import socket
5
+ import time
6
+ import polynom.config as cfg
7
+ from polynom.schema.migration import Migrator
8
+ from polynom.session import Session
9
+ from docker.errors import DockerException, NotFound, ImageNotFound
10
+ from polynom.schema.schema_registry import _get_ordered_schemas, _to_json
11
+ from polynom.schema.field import PrimaryKeyField, ForeignKeyField
12
+ from polynom.reflection import SchemaSnapshot, SchemaSnapshotSchema
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ class Application:
17
+ def __init__(
18
+ self,
19
+ app_uuid: str,
20
+ address,
21
+ user: str = cfg.get(cfg.DEFAULT_USER),
22
+ password: str = cfg.get(cfg.DEFAULT_PASS),
23
+ transport: str = cfg.get(cfg.DEFAULT_TRANSPORT),
24
+ use_docker: bool = True,
25
+ migrate: bool = False,
26
+ stop_container: bool = False,
27
+ remove_container: bool = False
28
+ ):
29
+ cfg.lock()
30
+
31
+ self._app_uuid = app_uuid
32
+ self._address = address
33
+ self._user = user
34
+ self._password = password
35
+ self._transport = transport
36
+ self._use_docker = use_docker
37
+ self._migrate = migrate
38
+ self._stop_container = stop_container
39
+ self._remove_container = remove_container
40
+
41
+ self._conn = None
42
+ self._cursor = None
43
+ self._initialized = False
44
+
45
+ def __enter__(self):
46
+ if self._initialized:
47
+ raise ValueError("Application must only be initialized once.")
48
+ self._initialized = True
49
+
50
+ if self._use_docker:
51
+ self._deploy_polypheny()
52
+
53
+ self._conn = polypheny.connect(
54
+ self._address,
55
+ username=self._user,
56
+ password=self._password,
57
+ transport=self._transport
58
+ )
59
+ self._cursor = self._conn.cursor()
60
+
61
+ self._verify_schema()
62
+ self._create_schema()
63
+ return self
64
+
65
+ def __exit__(self, exc_type, exc_val, exc_tb):
66
+ if self._cursor:
67
+ self._cursor.close()
68
+ if self._conn:
69
+ self._conn.close()
70
+
71
+ if not self._use_docker:
72
+ return
73
+ if self._stop_container:
74
+ self._stop_container_by_name(cfg.get(cfg.POLYPHENY_CONTAINER_NAME))
75
+ if self._remove_container:
76
+ self._remove_container_by_name(cfg.get(cfg.POLYPHENY_CONTAINER_NAME))
77
+ cfg.unlock()
78
+
79
+ def _wait_for_prism(self, timeout=60):
80
+ start_time = time.time()
81
+ while time.time() - start_time < timeout:
82
+ try:
83
+ conn = polypheny.connect(
84
+ self._address,
85
+ username=self._user,
86
+ password=self._password,
87
+ transport=self._transport
88
+ )
89
+ conn.close()
90
+ return
91
+ except EOFError:
92
+ time.sleep(1)
93
+ except Exception as e:
94
+ raise RuntimeError(f"Unexpected error while connecting to Polypheny: {e}") from e
95
+ raise TimeoutError("Timed out waiting for Polypheny to become available.")
96
+
97
+ def _deploy_polypheny(self):
98
+ logger.info("Establishing connection to Docker...")
99
+ try:
100
+ client = docker.from_env()
101
+ client.ping()
102
+ except DockerException as e:
103
+ logger.error("Docker is not running or not accessible.")
104
+ raise RuntimeError("Docker is not running or not accessible.") from e
105
+
106
+ container_name = cfg.get(cfg.POLYPHENY_CONTAINER_NAME)
107
+ image_name = cfg.get(cfg.POLYPHENY_IMAGE_NAME)
108
+ ports = cfg.get(cfg.POLYPHENY_PORTS)
109
+
110
+ try:
111
+ logger.info(f"Checking for presence of Polypheny container '{container_name}'...")
112
+ container = client.containers.get(container_name)
113
+ container.start()
114
+ logger.info(f"Container '{container_name}' found and started.")
115
+ except NotFound:
116
+ logger.info("Polypheny container not found. Deploying a new container. This may take a moment...")
117
+ try:
118
+ client.images.pull(image_name)
119
+ container = client.containers.run(
120
+ image_name,
121
+ name=container_name,
122
+ ports=ports,
123
+ detach=True
124
+ )
125
+ logger.info(f"New Polypheny container '{container_name}' deployed and started.")
126
+ except DockerException as e:
127
+ logger.error(f"Failed to create or run the Polypheny container: {e}")
128
+ raise RuntimeError("Failed to create or run the Polypheny container.") from e
129
+
130
+ logger.info(f"Waiting for Polypheny Prism to become available at {self._address,}...")
131
+ try:
132
+ self._wait_for_prism()
133
+ logger.info("Polypheny Prism is now responsive.")
134
+ except TimeoutError as e:
135
+ logger.error(str(e))
136
+ raise RuntimeError("Polypheny container did not become ready in time.") from e
137
+
138
+ def _verify_schema(self):
139
+ self._process_schema(SchemaSnapshotSchema)
140
+
141
+ session = Session(self)
142
+ with session:
143
+ logger.debug(f"Reading schema snapshot from database for application {self._app_uuid}.")
144
+ previous = SchemaSnapshot.query(session).get(self._app_uuid)
145
+ current_snapshot = _to_json()
146
+
147
+ if not previous:
148
+ logger.debug(f"No schema snapshot found for application {self._app_uuid}. Creating a first one.")
149
+ previous = SchemaSnapshot(current_snapshot, _entry_id=self._app_uuid)
150
+ session.add(previous)
151
+ session.commit()
152
+ return
153
+
154
+ logger.debug(f"Checking for schema changes for application {self._app_uuid}.")
155
+ diff = self._compare_snapshots(previous.snapshot, current_snapshot)
156
+
157
+ if diff and self._migrate:
158
+ logger.debug(f"Schema changes for application {self._app_uuid} found.")
159
+ migrator = Migrator()
160
+ migrator.run(session, diff)
161
+
162
+ previous.snapshot = current_snapshot
163
+ session.commit()
164
+
165
+ def _create_schema(self):
166
+ for schema in _get_ordered_schemas():
167
+ self._process_schema(schema)
168
+
169
+ def _compare_snapshots(self, previous, current):
170
+ diff = {}
171
+
172
+ current_schemas = {s['entity_name']: s for s in current['schemas']}
173
+ previous_schemas = {s['entity_name']: s for s in previous['schemas']}
174
+
175
+ for entity_name, prev_entity in previous_schemas.items():
176
+ curr_entity = current_schemas.get(entity_name)
177
+
178
+ entity_diff = {
179
+ 'namespace_name': prev_entity.get('namespace_name'),
180
+ 'changes': {}
181
+ }
182
+
183
+ if not curr_entity:
184
+ diff[entity_name] = entity_diff
185
+ continue
186
+
187
+ prev_fields = {f['name']: f for f in prev_entity.get('fields', [])}
188
+ curr_fields = {f['name']: f for f in curr_entity.get('fields', [])}
189
+ handled_prev_fields = set()
190
+
191
+ for curr_name, curr_field in curr_fields.items():
192
+ prev_name = curr_field.get('previous_name')
193
+ if prev_name and prev_name in prev_fields:
194
+ entity_diff['changes'][curr_name] = [prev_fields[prev_name], curr_field]
195
+ handled_prev_fields.add(prev_name)
196
+ continue
197
+ if curr_name not in prev_fields:
198
+ entity_diff['changes'][curr_name] = [None, curr_field]
199
+
200
+ for prev_name, prev_field in prev_fields.items():
201
+ if prev_name in handled_prev_fields:
202
+ continue
203
+ if prev_name not in curr_fields:
204
+ entity_diff['changes'][prev_name] = [prev_field, None]
205
+ elif prev_fields[prev_name] != curr_fields[prev_name]:
206
+ entity_diff['changes'][prev_name] = [prev_field, curr_fields[prev_name]]
207
+
208
+ if entity_diff['changes']:
209
+ diff[entity_name] = entity_diff
210
+
211
+ return diff
212
+
213
+ def _process_schema(self, schema_class):
214
+ entity = schema_class.entity_name
215
+ namespace = schema_class.namespace_name
216
+ fields = schema_class._get_fields()
217
+
218
+ logger.info(f"Initializing entity {entity} in namespace {namespace}.")
219
+
220
+ self._cursor.execute(f'CREATE RELATIONAL NAMESPACE IF NOT EXISTS "{namespace}"')
221
+ logger.debug(f"Created namespace {namespace} if absent.")
222
+
223
+ column_defs = []
224
+ foreign_keys = []
225
+ unique_columns = []
226
+ primary_key_columns = []
227
+
228
+ for field in fields:
229
+ col_def = f'"{field._db_field_name}" {field._polytype._type_string}'
230
+ if not getattr(field, 'nullable', False):
231
+ col_def += " NOT NULL"
232
+ default_value = getattr(field, 'default', None)
233
+ if default_value is not None:
234
+ col_def += f" DEFAULT {field._polytype._to_sql_expression(default_value)}"
235
+ if getattr(field, 'unique', False):
236
+ unique_columns.append(field._db_field_name)
237
+ if isinstance(field, PrimaryKeyField):
238
+ primary_key_columns.append(field._db_field_name)
239
+ column_defs.append(col_def)
240
+
241
+ if isinstance(field, ForeignKeyField):
242
+ fk = (
243
+ f'FOREIGN KEY ("{field._db_field_name}") '
244
+ f'REFERENCES "{field.referenced_entity_name}"("{field.referenced_db_field_name}")'
245
+ )
246
+ foreign_keys.append(fk)
247
+
248
+ constraints = foreign_keys[:]
249
+ if primary_key_columns:
250
+ constraints.append(f"PRIMARY KEY ({', '.join(primary_key_columns)})")
251
+ constraints += [f'UNIQUE ("{col}")' for col in unique_columns]
252
+
253
+ create_stmt = f'CREATE TABLE IF NOT EXISTS "{namespace}"."{entity}" ({", ".join(column_defs + constraints)});'
254
+ self._cursor.executeany('sql', create_stmt, namespace=namespace)
255
+ self._conn.commit()
256
+
257
+ logger.debug(f"Created entity {entity} if absent.")
258
+
259
+ def _stop_container_by_name(self, container_name):
260
+ try:
261
+ client = docker.from_env()
262
+ client.ping()
263
+ container = client.containers.get(container_name)
264
+ if container.status == 'running':
265
+ container.stop()
266
+ logger.info(f"Container '{container_name}' stopped.")
267
+ except NotFound:
268
+ logger.warning(f"No container named '{container_name}' found.")
269
+ except DockerException as e:
270
+ logger.error(f"Failed to stop the container '{container_name}': {e}")
271
+ raise RuntimeError(f"Failed to stop the container '{container_name}'") from e
272
+
273
+ def _remove_container_by_name(self, container_name):
274
+ try:
275
+ client = docker.from_env()
276
+ client.ping()
277
+ container = client.containers.get(container_name)
278
+ container.remove()
279
+ logger.info(f"Container '{container_name}' removed.")
280
+ except NotFound:
281
+ logger.warning(f"No container named '{container_name}' found.")
282
+ except DockerException as e:
283
+ logger.error(f"Failed to remove the container '{container_name}': {e}")
284
+ raise RuntimeError(f"Failed to remove the container '{container_name}'") from e
285
+
polynom/config.py ADDED
@@ -0,0 +1,89 @@
1
+ # keys
2
+ INTERNAL_NAMESPACE = 'INTERNAL_NAMESPACE'
3
+ CHANGE_LOG_TABLE = 'CHANGE_LOG_TABLE'
4
+ SYSTEM_USER_NAME = 'SYSTEM_USER_NAME'
5
+ SNAPSHOT_TABLE = 'SNAPSHOT_TABLE'
6
+
7
+ DEFAULT_NAMESPACE = 'DEFAULT_NAMESPACE'
8
+ POLYPHENY_CONTAINER_NAME = 'POLYPHENY_CONTAINER_NAME'
9
+ POLYPHENY_IMAGE_NAME = 'POLYPHENY_IMAGE_NAME'
10
+ POLYPHENY_PORTS = 'POLYPHENY_PORTS'
11
+ DEFAULT_TRANSPORT = 'DEFAULT_TRANSPORT'
12
+ DEFAULT_USER = 'DEFAULT_USER'
13
+ DEFAULT_PASS = 'DEFAULT_PASS'
14
+
15
+ CHANGE_LOG_IDENTIFIER = 'CHANGE_LOG_IDENTIFIER'
16
+
17
+ # constants
18
+ _internals = {
19
+ INTERNAL_NAMESPACE: 'internal',
20
+ CHANGE_LOG_TABLE: 'change_log',
21
+ SYSTEM_USER_NAME: 'SYSTEM',
22
+ SNAPSHOT_TABLE: 'snapshot',
23
+ }
24
+
25
+ # user configurables
26
+ _config = {
27
+ DEFAULT_NAMESPACE: 'public',
28
+ POLYPHENY_CONTAINER_NAME: 'polypheny',
29
+ POLYPHENY_IMAGE_NAME: 'vogti/polypheny',
30
+ POLYPHENY_PORTS: {
31
+ "20590/tcp": 20590,
32
+ "7659/tcp": 7659,
33
+ "80/tcp": 80,
34
+ "8081/tcp": 8081,
35
+ "8082/tcp": 8082,
36
+ },
37
+ DEFAULT_TRANSPORT: 'plain',
38
+ DEFAULT_USER: 'pa',
39
+ DEFAULT_PASS: '',
40
+ }
41
+
42
+ # derived options
43
+ _derived = {}
44
+
45
+ def _refresh_derived():
46
+ _derived[CHANGE_LOG_IDENTIFIER] = f"{_internals[INTERNAL_NAMESPACE]}.{_internals[CHANGE_LOG_TABLE]}"
47
+
48
+ _refresh_derived()
49
+
50
+ USER_CONFIGURABLE_KEYS = set(_config.keys())
51
+
52
+ _lock_count = 0
53
+
54
+ def lock():
55
+ global _lock_count
56
+ _lock_count += 1
57
+
58
+ def unlock():
59
+ global _lock_count
60
+ if _lock_count == 0:
61
+ raise RuntimeError("Unlock called more times than lock")
62
+ _lock_count -= 1
63
+
64
+ def get(key: str):
65
+ if key in _config:
66
+ return _config[key]
67
+ elif key in _internals:
68
+ return _internals[key]
69
+ elif key in _derived:
70
+ return _derived[key]
71
+ else:
72
+ raise KeyError(f"Unknown config key: {key}")
73
+
74
+ def set(key: str, value):
75
+ if key not in USER_CONFIGURABLE_KEYS:
76
+ raise KeyError(f"Cannot set config key: {key}")
77
+ if _lock_count > 0:
78
+ raise RuntimeError("Configuration is locked. No updates are allowed.")
79
+ _config[key] = value
80
+ _refresh_derived()
81
+
82
+ def set_config(overrides: dict):
83
+ if _lock_count > 0:
84
+ raise RuntimeError("Configuration is locked. No updates are allowed.")
85
+ for key, value in overrides.items():
86
+ set(key, value)
87
+
88
+ def all_config():
89
+ return {**_internals, **_config, **_derived}
polynom/joined_load.py ADDED
@@ -0,0 +1,15 @@
1
+ from typing import Any, Tuple
2
+ from polynom.query import Query
3
+
4
+ """
5
+ Encapsulates content to eagerly load a relationship
6
+ """
7
+ class JoinedLoad:
8
+ def __init__(self, relationship_attr: Any):
9
+ self.attr = relationship_attr # e.g. Bike.user
10
+
11
+ def apply(self, query: Query):
12
+ related_model = self.attr.get_target_model()
13
+ query.join(related_model)
14
+ alias = f"{related_model.schema.entity_name}_{len(query._joins)}"
15
+ query._eager_loads.append((self.attr._key, related_model, alias))
polynom/model.py ADDED
@@ -0,0 +1,88 @@
1
+ import logging
2
+ import uuid as uuidlib
3
+ from typing import Any, Type, TypeVar
4
+ from copy import deepcopy
5
+ from polynom.query import Query
6
+
7
+ T = TypeVar("T")
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class BaseModel:
11
+ schema: Type[Any]
12
+
13
+ def __init__(self, _entry_id: str = None):
14
+ if _entry_id is None:
15
+ _entry_id = str(uuidlib.uuid4())
16
+ self._entry_id = _entry_id
17
+ self._is_active = True
18
+ self._update_snapshot()
19
+
20
+ def __setattr__(self, name, value):
21
+ if name == '_is_active':
22
+ object.__setattr__(self, name, value)
23
+ return
24
+
25
+ if getattr(self, "_is_active", True):
26
+ object.__setattr__(self, name, value)
27
+ return
28
+
29
+ self._throw_invalid()
30
+
31
+ def _throw_invalid(self):
32
+ message = f"This instance of entry {self._entry_id} is no longer mapped: it is either outside its session, deleted within its session, or replaced by a query result"
33
+ logger.debug(message)
34
+ raise AttributeError(message)
35
+
36
+ def _update_snapshot(self):
37
+ self._snapshot = {
38
+ field._python_field_name: deepcopy(self.__dict__.get(field._python_field_name))
39
+ for field in self.schema._get_fields()
40
+ }
41
+
42
+ def _diff(self) -> dict[str, tuple]:
43
+ if not self._is_active:
44
+ self._throw_invalid()
45
+
46
+ changelog = {}
47
+ for field in self.schema._get_fields():
48
+ key = field._python_field_name
49
+ old = self._snapshot.get(key)
50
+ new = self.__dict__.get(key)
51
+ if old != new:
52
+ changelog[key] = (old, new)
53
+ return changelog
54
+
55
+ def __repr__(self):
56
+ field_map = self.schema._get_field_map()
57
+ field_values = {
58
+ name: getattr(self, name, None)
59
+ for name in field_map.keys()
60
+ }
61
+ return f"<{self.__class__.__name__} {field_values}>"
62
+
63
+ @classmethod
64
+ def query(cls, session):
65
+ return Query(cls, session)
66
+
67
+ @classmethod
68
+ def _from_row(cls: Type[T], row: dict[str, Any]) -> T:
69
+ obj_data = {
70
+ field._python_field_name: field._polytype._from_prism_serializable(row[field._db_field_name])
71
+ for field in cls.schema._get_fields()
72
+ }
73
+ return cls(**obj_data)
74
+
75
+ def _to_update_dict(self) -> dict[str, Any]:
76
+ return {
77
+ field._db_field_name: field._polytype._to_prism_serializable(getattr(self, field._python_field_name))
78
+ for field in self.schema._get_fields()
79
+ if hasattr(self, field._python_field_name)
80
+ }
81
+
82
+ def _to_insert_dict(self) -> dict[str, Any]:
83
+ return {
84
+ field._db_field_name: field._polytype._to_prism_serializable(getattr(self, field._python_field_name))
85
+ for field in self.schema._get_fields()
86
+ if hasattr(self, field._python_field_name)
87
+ }
88
+