icij-common 0.1.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.
- icij_common-0.1.1/PKG-INFO +13 -0
- icij_common-0.1.1/icij_common/__init__.py +0 -0
- icij_common-0.1.1/icij_common/logging_utils.py +108 -0
- icij_common-0.1.1/icij_common/neo4j/__init__.py +0 -0
- icij_common-0.1.1/icij_common/neo4j/constants.py +38 -0
- icij_common-0.1.1/icij_common/neo4j/migrate.py +326 -0
- icij_common-0.1.1/icij_common/neo4j/projects.py +168 -0
- icij_common-0.1.1/icij_common/neo4j/test_utils.py +129 -0
- icij_common-0.1.1/icij_common/pydantic_utils.py +239 -0
- icij_common-0.1.1/icij_common/test_utils.py +75 -0
- icij_common-0.1.1/pyproject.toml +37 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: icij-common
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Common utils for ICIJ libs
|
|
5
|
+
Author: Clément Doumouro
|
|
6
|
+
Author-email: cdoumouro@icij.org
|
|
7
|
+
Requires-Python: >=3.9,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Dist: pydantic (>=1.10,<2.0)
|
|
File without changes
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from abc import ABC
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from functools import wraps
|
|
9
|
+
from typing import List, Optional, final
|
|
10
|
+
|
|
11
|
+
TRACE = 5
|
|
12
|
+
logging.TRACE = TRACE
|
|
13
|
+
logging.addLevelName(logging.TRACE, "TRACE")
|
|
14
|
+
logging.Logger.trace = lambda inst, msg, *args, **kwargs: inst.log(
|
|
15
|
+
logging.TRACE, msg, *args, **kwargs
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DifferedLoggingMessage:
|
|
20
|
+
def __init__(self, fn, *args, **kwargs):
|
|
21
|
+
self.fn = fn
|
|
22
|
+
self.args = args
|
|
23
|
+
self.kwargs = kwargs
|
|
24
|
+
|
|
25
|
+
def __str__(self):
|
|
26
|
+
return str(self.fn(*self.args, **self.kwargs))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LogWithNameMixin(ABC):
|
|
30
|
+
def __init__(self, logger: logging.Logger):
|
|
31
|
+
self._logger = logger
|
|
32
|
+
|
|
33
|
+
@final
|
|
34
|
+
def info(self, msg, *args, **kwargs):
|
|
35
|
+
self._logger.info(msg, *args, **kwargs)
|
|
36
|
+
|
|
37
|
+
@final
|
|
38
|
+
def debug(self, msg, *args, **kwargs):
|
|
39
|
+
self._logger.debug(msg, *args, **kwargs)
|
|
40
|
+
|
|
41
|
+
@final
|
|
42
|
+
def error(self, msg, *args, **kwargs):
|
|
43
|
+
self._logger.error(msg, *args, **kwargs)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class WorkerIdFilter(logging.Filter):
|
|
47
|
+
def __init__(self, worker_id: str):
|
|
48
|
+
super().__init__()
|
|
49
|
+
self._worker_id = worker_id
|
|
50
|
+
|
|
51
|
+
def filter(self, record):
|
|
52
|
+
record.workerid = self._worker_id
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def log_elapsed_time(
|
|
57
|
+
logger: logging.Logger, level: int, output_msg: Optional[str] = None
|
|
58
|
+
):
|
|
59
|
+
if output_msg is None:
|
|
60
|
+
output_msg = "Elapsed time ->:\n{elapsed_time}"
|
|
61
|
+
|
|
62
|
+
def get_wrapper(fn):
|
|
63
|
+
@wraps(fn)
|
|
64
|
+
def wrapped(*args, **kwargs):
|
|
65
|
+
start = datetime.now()
|
|
66
|
+
msg_fmt = dict()
|
|
67
|
+
res = fn(*args, **kwargs)
|
|
68
|
+
if "elapsed_time" in output_msg:
|
|
69
|
+
msg_fmt["elapsed_time"] = datetime.now() - start
|
|
70
|
+
logger.log(level, output_msg.format(**msg_fmt))
|
|
71
|
+
return res
|
|
72
|
+
|
|
73
|
+
return wrapped
|
|
74
|
+
|
|
75
|
+
return get_wrapper
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@contextlib.contextmanager
|
|
79
|
+
def log_elapsed_time_cm(
|
|
80
|
+
logger: logging.Logger, level: int, output_msg: Optional[str] = None
|
|
81
|
+
):
|
|
82
|
+
if output_msg is None:
|
|
83
|
+
output_msg = "Elapsed time ->:\n{elapsed_time}"
|
|
84
|
+
start = datetime.now()
|
|
85
|
+
yield
|
|
86
|
+
end = datetime.now() - start
|
|
87
|
+
msg_fmt = dict()
|
|
88
|
+
if "elapsed_time" in output_msg:
|
|
89
|
+
msg_fmt["elapsed_time"] = end
|
|
90
|
+
logger.log(level, output_msg.format(**msg_fmt))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
STREAM_HANDLER_FMT = "[%(levelname)s][%(asctime)s.%(msecs)03d][%(name)s]: %(message)s"
|
|
94
|
+
STREAM_HANDLER_FMT_WITH_WORKER_ID = (
|
|
95
|
+
"[%(levelname)s][%(asctime)s.%(msecs)03d][%(workerid)s][%(name)s]: %(message)s"
|
|
96
|
+
)
|
|
97
|
+
DATE_FMT = "%H:%M:%S"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def setup_loggers(loggers: List[str]):
|
|
101
|
+
level = logging.INFO
|
|
102
|
+
stream_handler = logging.StreamHandler(sys.stderr)
|
|
103
|
+
stream_handler.setFormatter(logging.Formatter(STREAM_HANDLER_FMT, DATE_FMT))
|
|
104
|
+
for logger in loggers:
|
|
105
|
+
logger = logging.getLogger(logger)
|
|
106
|
+
logger.setLevel(level)
|
|
107
|
+
logger.handlers = []
|
|
108
|
+
logger.addHandler(stream_handler)
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
PROJECT_REGISTRY_DB = "datashare-project-registry"
|
|
2
|
+
|
|
3
|
+
PROJECT_RUNS_MIGRATION = "_RUNS"
|
|
4
|
+
PROJECT_NAME = "name"
|
|
5
|
+
PROJECT_NODE = "_Project"
|
|
6
|
+
|
|
7
|
+
MIGRATION_COMPLETED = "completed"
|
|
8
|
+
MIGRATION_LABEL = "label"
|
|
9
|
+
MIGRATION_NODE = "_Migration"
|
|
10
|
+
MIGRATION_PROJECT = "project"
|
|
11
|
+
MIGRATION_STARTED = "started"
|
|
12
|
+
MIGRATION_STATUS = "status"
|
|
13
|
+
MIGRATION_VERSION = "version"
|
|
14
|
+
|
|
15
|
+
TASK_NODE = "_Task"
|
|
16
|
+
TASK_COMPLETED_AT = "completedAt"
|
|
17
|
+
TASK_CREATED_AT = "createdAt"
|
|
18
|
+
TASK_ID = "id"
|
|
19
|
+
TASK_INPUTS = "inputs"
|
|
20
|
+
TASK_PROGRESS = "progress"
|
|
21
|
+
TASK_RETRIES = "retries"
|
|
22
|
+
TASK_TYPE = "type"
|
|
23
|
+
|
|
24
|
+
TASK_LOCK_NODE = "_TaskLock"
|
|
25
|
+
TASK_LOCK_TASK_ID = "taskId"
|
|
26
|
+
TASK_LOCK_WORKER_ID = "workerId"
|
|
27
|
+
|
|
28
|
+
TASK_ERROR_NODE = "_TaskError"
|
|
29
|
+
TASK_ERROR_DETAIL = "detail"
|
|
30
|
+
TASK_ERROR_ID = "id"
|
|
31
|
+
TASK_ERROR_OCCURRED_AT = "occurredAt"
|
|
32
|
+
TASK_ERROR_TITLE = "title"
|
|
33
|
+
|
|
34
|
+
TASK_ERROR_OCCURRED_TYPE = "_OCCURRED_DURING"
|
|
35
|
+
|
|
36
|
+
TASK_RESULT_NODE = "_TaskResult"
|
|
37
|
+
TASK_HAS_RESULT_TYPE = "_HAS_RESULT"
|
|
38
|
+
TASK_RESULT_RESULT = "result"
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Coroutine
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from distutils.version import StrictVersion
|
|
9
|
+
from enum import Enum, unique
|
|
10
|
+
from inspect import signature
|
|
11
|
+
from typing import Any, Callable, List, Optional, Sequence, Union
|
|
12
|
+
|
|
13
|
+
import neo4j
|
|
14
|
+
from neo4j.exceptions import ConstraintError
|
|
15
|
+
|
|
16
|
+
from icij_common.neo4j.constants import (
|
|
17
|
+
MIGRATION_COMPLETED,
|
|
18
|
+
MIGRATION_LABEL,
|
|
19
|
+
MIGRATION_NODE,
|
|
20
|
+
MIGRATION_PROJECT,
|
|
21
|
+
MIGRATION_STARTED,
|
|
22
|
+
MIGRATION_STATUS,
|
|
23
|
+
MIGRATION_VERSION,
|
|
24
|
+
)
|
|
25
|
+
from icij_common.pydantic_utils import NoEnumModel
|
|
26
|
+
from .projects import (
|
|
27
|
+
Project,
|
|
28
|
+
create_project_db,
|
|
29
|
+
create_project_tx,
|
|
30
|
+
project_db_session,
|
|
31
|
+
projects_tx,
|
|
32
|
+
registry_db_session,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
TransactionFn = Callable[[neo4j.AsyncTransaction], Coroutine]
|
|
38
|
+
ExplicitTransactionFn = Callable[[neo4j.Session], Coroutine]
|
|
39
|
+
MigrationFn = Union[TransactionFn, ExplicitTransactionFn]
|
|
40
|
+
|
|
41
|
+
_MIGRATION_TIMEOUT_MSG = """Migration timeout expired !
|
|
42
|
+
Please check that a migration is indeed in progress. If the application is in a \
|
|
43
|
+
deadlock restart it forcing the migration index cleanup."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MigrationError(RuntimeError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@unique
|
|
51
|
+
class MigrationStatus(str, Enum):
|
|
52
|
+
IN_PROGRESS = "IN_PROGRESS"
|
|
53
|
+
DONE = "DONE"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MigrationVersion(StrictVersion):
|
|
57
|
+
@classmethod
|
|
58
|
+
def __get_validators__(cls):
|
|
59
|
+
def validator(v: Any) -> MigrationVersion:
|
|
60
|
+
if isinstance(v, (str, MigrationVersion)):
|
|
61
|
+
return MigrationVersion(v)
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"Must be a {MigrationVersion.__name__} or a {str.__name__}, "
|
|
64
|
+
f"found {type(v)}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
yield validator
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _BaseMigration(NoEnumModel):
|
|
71
|
+
version: MigrationVersion
|
|
72
|
+
label: str
|
|
73
|
+
status: MigrationStatus = MigrationStatus.IN_PROGRESS
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Neo4jMigration(_BaseMigration):
|
|
77
|
+
# It would have been cleaner to create
|
|
78
|
+
# (p:_Project)-[:_RUNS { id: p.name + m.version }]->(m:_Migration)
|
|
79
|
+
# relationships. However, neo4j < 5.7 doesn't support unique constraint on
|
|
80
|
+
# relationships properties which prevents from implementing the locking mechanism
|
|
81
|
+
# properly. We hence enforce unique constraint on
|
|
82
|
+
# (_Migration.version, _Migration.project)
|
|
83
|
+
project: str
|
|
84
|
+
started: datetime
|
|
85
|
+
completed: Optional[datetime] = None
|
|
86
|
+
status: MigrationStatus = MigrationStatus.IN_PROGRESS
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_neo4j(cls, record: neo4j.Record, key="migration") -> Neo4jMigration:
|
|
90
|
+
migration = dict(record.value(key))
|
|
91
|
+
if "started" in migration:
|
|
92
|
+
migration["started"] = migration["started"].to_native()
|
|
93
|
+
if "completed" in migration:
|
|
94
|
+
migration["completed"] = migration["completed"].to_native()
|
|
95
|
+
return Neo4jMigration(**migration)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Migration(_BaseMigration):
|
|
99
|
+
migration_fn: MigrationFn
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
MigrationRegistry: Sequence[Migration]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def _migrate_with_lock(
|
|
106
|
+
*,
|
|
107
|
+
project_session: neo4j.AsyncSession,
|
|
108
|
+
registry_session: neo4j.AsyncSession,
|
|
109
|
+
project: str,
|
|
110
|
+
migration: Migration,
|
|
111
|
+
):
|
|
112
|
+
# Note: all migrations.py should be carefully tested otherwise they will lock
|
|
113
|
+
# the DB...
|
|
114
|
+
|
|
115
|
+
# Lock the DB first, raising in case a migration already exists
|
|
116
|
+
logger.debug("Trying to run migration to %s...", migration.label)
|
|
117
|
+
await registry_session.execute_write(
|
|
118
|
+
create_migration_tx,
|
|
119
|
+
project=project,
|
|
120
|
+
migration_version=str(migration.version),
|
|
121
|
+
migration_label=migration.label,
|
|
122
|
+
)
|
|
123
|
+
# Then run to migration
|
|
124
|
+
logger.debug("Acquired write lock to %s !", migration.label)
|
|
125
|
+
sig = signature(migration.migration_fn)
|
|
126
|
+
first_param = list(sig.parameters)[0]
|
|
127
|
+
if first_param == "tx":
|
|
128
|
+
await project_session.execute_write(migration.migration_fn)
|
|
129
|
+
elif first_param == "sess":
|
|
130
|
+
await migration.migration_fn(project_session)
|
|
131
|
+
else:
|
|
132
|
+
raise ValueError(f"Invalid migration function: {migration.migration_fn}")
|
|
133
|
+
# Finally free the lock
|
|
134
|
+
await registry_session.execute_write(
|
|
135
|
+
complete_migration_tx,
|
|
136
|
+
project=project,
|
|
137
|
+
migration_version=str(migration.version),
|
|
138
|
+
)
|
|
139
|
+
logger.debug("Marked %s as complete !", migration.label)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def create_migration_tx(
|
|
143
|
+
tx: neo4j.AsyncTransaction,
|
|
144
|
+
*,
|
|
145
|
+
project: str,
|
|
146
|
+
migration_version: str,
|
|
147
|
+
migration_label: str,
|
|
148
|
+
) -> Neo4jMigration:
|
|
149
|
+
query = f"""CREATE (m:{MIGRATION_NODE} {{
|
|
150
|
+
{MIGRATION_PROJECT}: $project,
|
|
151
|
+
{MIGRATION_LABEL}: $label,
|
|
152
|
+
{MIGRATION_VERSION}: $version,
|
|
153
|
+
{MIGRATION_STATUS}: $status,
|
|
154
|
+
{MIGRATION_STARTED}: $started
|
|
155
|
+
}})
|
|
156
|
+
RETURN m as migration"""
|
|
157
|
+
res = await tx.run(
|
|
158
|
+
query,
|
|
159
|
+
label=migration_label,
|
|
160
|
+
version=migration_version,
|
|
161
|
+
project=project,
|
|
162
|
+
status=MigrationStatus.IN_PROGRESS.value,
|
|
163
|
+
started=datetime.now(),
|
|
164
|
+
)
|
|
165
|
+
migration = await res.single()
|
|
166
|
+
if migration is None:
|
|
167
|
+
raise ValueError(f"Couldn't find migration {migration_version} for {project}")
|
|
168
|
+
migration = Neo4jMigration.from_neo4j(migration)
|
|
169
|
+
return migration
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def complete_migration_tx(
|
|
173
|
+
tx: neo4j.AsyncTransaction, *, project: str, migration_version: str
|
|
174
|
+
) -> Neo4jMigration:
|
|
175
|
+
query = f"""MATCH (m:{MIGRATION_NODE} {{
|
|
176
|
+
{MIGRATION_VERSION}: $version,
|
|
177
|
+
{MIGRATION_PROJECT}: $project
|
|
178
|
+
}})
|
|
179
|
+
SET m += {{ {MIGRATION_STATUS}: $status, {MIGRATION_COMPLETED}: $completed }}
|
|
180
|
+
RETURN m as migration"""
|
|
181
|
+
res = await tx.run(
|
|
182
|
+
query,
|
|
183
|
+
version=migration_version,
|
|
184
|
+
project=project,
|
|
185
|
+
status=MigrationStatus.DONE.value,
|
|
186
|
+
completed=datetime.now(),
|
|
187
|
+
)
|
|
188
|
+
migration = await res.single()
|
|
189
|
+
migration = Neo4jMigration.from_neo4j(migration)
|
|
190
|
+
return migration
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def project_migrations_tx(
|
|
194
|
+
tx: neo4j.AsyncTransaction, project: str
|
|
195
|
+
) -> List[Neo4jMigration]:
|
|
196
|
+
query = f"""MATCH (m:{MIGRATION_NODE} {{ {MIGRATION_PROJECT}: $project }})
|
|
197
|
+
RETURN m as migration
|
|
198
|
+
"""
|
|
199
|
+
res = await tx.run(query, project=project)
|
|
200
|
+
migrations = [Neo4jMigration.from_neo4j(rec) async for rec in res]
|
|
201
|
+
return migrations
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def delete_all_migrations(driver: neo4j.AsyncDriver):
|
|
205
|
+
query = f"""MATCH (m:{MIGRATION_NODE})
|
|
206
|
+
DETACH DELETE m"""
|
|
207
|
+
async with registry_db_session(driver) as sess:
|
|
208
|
+
await sess.run(query)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def retrieve_projects(neo4j_driver: neo4j.AsyncDriver) -> List[Project]:
|
|
212
|
+
async with registry_db_session(neo4j_driver) as sess:
|
|
213
|
+
projects = await sess.execute_read(projects_tx)
|
|
214
|
+
return projects
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def migrate_db_schemas(
|
|
218
|
+
neo4j_driver: neo4j.AsyncDriver,
|
|
219
|
+
registry: MigrationRegistry,
|
|
220
|
+
*,
|
|
221
|
+
timeout_s: float,
|
|
222
|
+
throttle_s: float,
|
|
223
|
+
):
|
|
224
|
+
projects = await retrieve_projects(neo4j_driver)
|
|
225
|
+
tasks = [
|
|
226
|
+
migrate_project_db_schema(
|
|
227
|
+
neo4j_driver,
|
|
228
|
+
registry,
|
|
229
|
+
project=p.name,
|
|
230
|
+
timeout_s=timeout_s,
|
|
231
|
+
throttle_s=throttle_s,
|
|
232
|
+
)
|
|
233
|
+
for p in projects
|
|
234
|
+
]
|
|
235
|
+
await asyncio.gather(*tasks)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def migrate_project_db_schema(
|
|
239
|
+
neo4j_driver: neo4j.AsyncDriver,
|
|
240
|
+
registry: MigrationRegistry,
|
|
241
|
+
project: str,
|
|
242
|
+
*,
|
|
243
|
+
timeout_s: float,
|
|
244
|
+
throttle_s: float,
|
|
245
|
+
):
|
|
246
|
+
logger.info("Migrating project %s", project)
|
|
247
|
+
start = time.monotonic()
|
|
248
|
+
if not registry:
|
|
249
|
+
return
|
|
250
|
+
todo = sorted(registry, key=lambda m: m.version)
|
|
251
|
+
async with registry_db_session(neo4j_driver) as registry_sess:
|
|
252
|
+
async with project_db_session(neo4j_driver, project=project) as project_sess:
|
|
253
|
+
while "Waiting for DB to be migrated or for a timeout":
|
|
254
|
+
migrations = await registry_sess.execute_read(
|
|
255
|
+
project_migrations_tx, project=project
|
|
256
|
+
)
|
|
257
|
+
in_progress = [
|
|
258
|
+
m for m in migrations if m.status is MigrationStatus.IN_PROGRESS
|
|
259
|
+
]
|
|
260
|
+
if len(in_progress) > 1:
|
|
261
|
+
raise MigrationError(
|
|
262
|
+
f"Found several migration in progress: {in_progress}"
|
|
263
|
+
)
|
|
264
|
+
if in_progress:
|
|
265
|
+
logger.info(
|
|
266
|
+
"Found that %s is in progress, waiting for %s seconds...",
|
|
267
|
+
in_progress[0].label,
|
|
268
|
+
throttle_s,
|
|
269
|
+
)
|
|
270
|
+
await asyncio.sleep(throttle_s)
|
|
271
|
+
else:
|
|
272
|
+
done = [m for m in migrations if m.status is MigrationStatus.DONE]
|
|
273
|
+
if done:
|
|
274
|
+
current_version = max((m.version for m in done))
|
|
275
|
+
todo = [m for m in todo if m.version > current_version]
|
|
276
|
+
if not todo:
|
|
277
|
+
break
|
|
278
|
+
try:
|
|
279
|
+
await _migrate_with_lock(
|
|
280
|
+
project_session=project_sess,
|
|
281
|
+
registry_session=registry_sess,
|
|
282
|
+
project=project,
|
|
283
|
+
migration=todo[0],
|
|
284
|
+
)
|
|
285
|
+
todo = todo[1:]
|
|
286
|
+
continue
|
|
287
|
+
except ConstraintError:
|
|
288
|
+
logger.info(
|
|
289
|
+
"Migration %s has just started somewhere else, "
|
|
290
|
+
" waiting for %s seconds...",
|
|
291
|
+
todo[0].label,
|
|
292
|
+
throttle_s,
|
|
293
|
+
)
|
|
294
|
+
await asyncio.sleep(throttle_s)
|
|
295
|
+
elapsed = time.monotonic() - start
|
|
296
|
+
if elapsed > timeout_s:
|
|
297
|
+
logger.error(_MIGRATION_TIMEOUT_MSG)
|
|
298
|
+
raise MigrationError(_MIGRATION_TIMEOUT_MSG)
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def init_project(
|
|
303
|
+
neo4j_driver: neo4j.AsyncDriver,
|
|
304
|
+
name: str,
|
|
305
|
+
registry: MigrationRegistry,
|
|
306
|
+
*,
|
|
307
|
+
timeout_s: float,
|
|
308
|
+
throttle_s: float,
|
|
309
|
+
) -> bool:
|
|
310
|
+
# Create project DB
|
|
311
|
+
await create_project_db(neo4j_driver, project=name)
|
|
312
|
+
|
|
313
|
+
# Create project
|
|
314
|
+
async with registry_db_session(neo4j_driver) as sess:
|
|
315
|
+
project, already_exists = await sess.execute_write(create_project_tx, name=name)
|
|
316
|
+
|
|
317
|
+
# Migrate it
|
|
318
|
+
await migrate_project_db_schema(
|
|
319
|
+
neo4j_driver,
|
|
320
|
+
registry=registry,
|
|
321
|
+
project=project.name,
|
|
322
|
+
timeout_s=timeout_s,
|
|
323
|
+
throttle_s=throttle_s,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
return already_exists
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import logging
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from distutils.version import StrictVersion
|
|
5
|
+
from typing import AsyncGenerator, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import neo4j
|
|
8
|
+
|
|
9
|
+
from icij_common.neo4j.constants import (
|
|
10
|
+
MIGRATION_NODE,
|
|
11
|
+
MIGRATION_PROJECT,
|
|
12
|
+
MIGRATION_VERSION,
|
|
13
|
+
PROJECT_NODE,
|
|
14
|
+
PROJECT_REGISTRY_DB,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from icij_common.neo4j.constants import PROJECT_NAME
|
|
18
|
+
|
|
19
|
+
from icij_common.pydantic_utils import ICIJModel
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
NEO4J_COMMUNITY_DB = "neo4j"
|
|
24
|
+
_IS_ENTERPRISE: Optional[bool] = None
|
|
25
|
+
_NEO4J_VERSION: Optional[StrictVersion] = None
|
|
26
|
+
_SUPPORTS_PARALLEL: Optional[bool] = None
|
|
27
|
+
|
|
28
|
+
_COMPONENTS_QUERY = """CALL dbms.components() YIELD versions, edition
|
|
29
|
+
RETURN versions, edition"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Project(ICIJModel):
|
|
33
|
+
name: str
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_neo4j(cls, record: neo4j.Record, key="project") -> Project:
|
|
37
|
+
project = dict(record.value(key))
|
|
38
|
+
return Project(**project)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def create_project_registry_db(neo4j_driver: neo4j.AsyncDriver):
|
|
42
|
+
if await is_enterprise(neo4j_driver):
|
|
43
|
+
logger.info("Creating project registry DB...")
|
|
44
|
+
query = "CREATE DATABASE $registry_db IF NOT EXISTS"
|
|
45
|
+
await neo4j_driver.execute_query(query, registry_db=PROJECT_REGISTRY_DB)
|
|
46
|
+
else:
|
|
47
|
+
logger.info("Using default db as registry DB !")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def projects_tx(tx: neo4j.AsyncTransaction) -> List[Project]:
|
|
51
|
+
query = f"MATCH (project:{PROJECT_NODE}) RETURN project"
|
|
52
|
+
res = await tx.run(query)
|
|
53
|
+
projects = [Project.from_neo4j(p) async for p in res]
|
|
54
|
+
return projects
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def create_project_db(neo4j_driver: neo4j.AsyncDriver, project: str):
|
|
58
|
+
if await is_enterprise(neo4j_driver):
|
|
59
|
+
db_name = await project_db(neo4j_driver, project=project)
|
|
60
|
+
query = "CREATE DATABASE $db_name IF NOT EXISTS"
|
|
61
|
+
await neo4j_driver.execute_query(query, db_name=db_name)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def add_project_support_migration_tx(tx: neo4j.AsyncTransaction):
|
|
65
|
+
await create_project_unique_name_constraint_tx(tx)
|
|
66
|
+
await create_migration_unique_project_and_version_constraint_tx(tx)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def create_project_unique_name_constraint_tx(tx: neo4j.AsyncTransaction):
|
|
70
|
+
constraint_query = f"""CREATE CONSTRAINT constraint_project_unique_name
|
|
71
|
+
IF NOT EXISTS
|
|
72
|
+
FOR (p:{PROJECT_NODE})
|
|
73
|
+
REQUIRE (p.{PROJECT_NAME}) IS UNIQUE
|
|
74
|
+
"""
|
|
75
|
+
await tx.run(constraint_query)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def create_migration_unique_project_and_version_constraint_tx(
|
|
79
|
+
tx: neo4j.AsyncTransaction,
|
|
80
|
+
):
|
|
81
|
+
constraint_query = f"""CREATE CONSTRAINT
|
|
82
|
+
constraint_migration_unique_project_and_version
|
|
83
|
+
IF NOT EXISTS
|
|
84
|
+
FOR (m:{MIGRATION_NODE})
|
|
85
|
+
REQUIRE (m.{MIGRATION_VERSION}, m.{MIGRATION_PROJECT}) IS UNIQUE
|
|
86
|
+
"""
|
|
87
|
+
await tx.run(constraint_query)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def create_project_tx(
|
|
91
|
+
tx: neo4j.AsyncTransaction, name: str
|
|
92
|
+
) -> Tuple[Project, bool]:
|
|
93
|
+
if name == PROJECT_REGISTRY_DB:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f'Bad luck, name "{PROJECT_REGISTRY_DB}" is reserved for internal use.'
|
|
96
|
+
f" Can't initialize project"
|
|
97
|
+
)
|
|
98
|
+
query = f"""MERGE (project:{PROJECT_NODE} {{ {PROJECT_NAME}: $name }})
|
|
99
|
+
RETURN project"""
|
|
100
|
+
res = await tx.run(query, name=name)
|
|
101
|
+
rec = await res.single()
|
|
102
|
+
summary = await res.consume()
|
|
103
|
+
existed = summary.counters.nodes_created == 0
|
|
104
|
+
project = Project.from_neo4j(rec)
|
|
105
|
+
return project, existed
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def project_registry_db(neo4j_driver: neo4j.AsyncDriver) -> str:
|
|
109
|
+
if await is_enterprise(neo4j_driver):
|
|
110
|
+
return PROJECT_REGISTRY_DB
|
|
111
|
+
return NEO4J_COMMUNITY_DB
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def project_db(neo4j_driver: neo4j.AsyncDriver, project: str) -> str:
|
|
115
|
+
if await is_enterprise(neo4j_driver):
|
|
116
|
+
return project
|
|
117
|
+
return NEO4J_COMMUNITY_DB
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def project_index(project: str) -> str:
|
|
121
|
+
return project
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@asynccontextmanager
|
|
125
|
+
async def project_db_session(
|
|
126
|
+
neo4j_driver: neo4j.AsyncDriver, project: str
|
|
127
|
+
) -> AsyncGenerator[neo4j.AsyncSession, None]:
|
|
128
|
+
db = await project_db(neo4j_driver, project)
|
|
129
|
+
sess_ctx = neo4j_driver.session(database=db)
|
|
130
|
+
async with sess_ctx as sess:
|
|
131
|
+
yield sess
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@asynccontextmanager
|
|
135
|
+
async def registry_db_session(neo4j_driver: neo4j.AsyncDriver) -> neo4j.AsyncSession:
|
|
136
|
+
session = neo4j_driver.session(database=await project_registry_db(neo4j_driver))
|
|
137
|
+
async with session as sess:
|
|
138
|
+
yield sess
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def _get_components(neo4j_driver: neo4j.AsyncDriver):
|
|
142
|
+
async with neo4j_driver.session(database=neo4j.SYSTEM_DATABASE) as sess:
|
|
143
|
+
res = await sess.run(_COMPONENTS_QUERY)
|
|
144
|
+
res = await res.single()
|
|
145
|
+
global _IS_ENTERPRISE
|
|
146
|
+
global _NEO4J_VERSION
|
|
147
|
+
_IS_ENTERPRISE = res["edition"] != "community"
|
|
148
|
+
_NEO4J_VERSION = StrictVersion(res["versions"][0])
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def server_version(neo4j_driver: neo4j.AsyncDriver) -> StrictVersion:
|
|
152
|
+
if _NEO4J_VERSION is None:
|
|
153
|
+
await _get_components(neo4j_driver)
|
|
154
|
+
return _NEO4J_VERSION
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def supports_parallel_runtime(neo4j_driver: neo4j.AsyncDriver) -> bool:
|
|
158
|
+
global _SUPPORTS_PARALLEL
|
|
159
|
+
if _SUPPORTS_PARALLEL is None:
|
|
160
|
+
version = await server_version(neo4j_driver)
|
|
161
|
+
_SUPPORTS_PARALLEL = version >= StrictVersion("5.13")
|
|
162
|
+
return _SUPPORTS_PARALLEL
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def is_enterprise(neo4j_driver: neo4j.AsyncDriver) -> bool:
|
|
166
|
+
if _IS_ENTERPRISE is None:
|
|
167
|
+
await _get_components(neo4j_driver)
|
|
168
|
+
return _IS_ENTERPRISE
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# pylint: disable=redefined-outer-name
|
|
2
|
+
import contextlib
|
|
3
|
+
from typing import AsyncGenerator
|
|
4
|
+
|
|
5
|
+
import neo4j
|
|
6
|
+
import pytest
|
|
7
|
+
from neo4j import AsyncGraphDatabase
|
|
8
|
+
|
|
9
|
+
import icij_common
|
|
10
|
+
from icij_common.neo4j.projects import NEO4J_COMMUNITY_DB
|
|
11
|
+
|
|
12
|
+
NEO4J_TEST_PORT = 7688
|
|
13
|
+
NEO4J_TEST_USER = "neo4j"
|
|
14
|
+
NEO4J_TEST_PASSWORD = "theneo4jpassword"
|
|
15
|
+
NEO4J_TEST_AUTH = neo4j.basic_auth(NEO4J_TEST_USER, NEO4J_TEST_PASSWORD)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def wipe_db(session: neo4j.AsyncSession):
|
|
19
|
+
# Indices and constraints
|
|
20
|
+
query = "CALL apoc.schema.assert({}, {})"
|
|
21
|
+
await session.run(query)
|
|
22
|
+
# Documents
|
|
23
|
+
query = """MATCH (n)
|
|
24
|
+
DETACH DELETE n
|
|
25
|
+
"""
|
|
26
|
+
await session.run(query)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@contextlib.asynccontextmanager
|
|
30
|
+
async def _build_neo4j_driver():
|
|
31
|
+
uri = f"neo4j://127.0.0.1:{NEO4J_TEST_PORT}"
|
|
32
|
+
async with AsyncGraphDatabase.driver( # pylint: disable=not-async-context-manager
|
|
33
|
+
uri, auth=NEO4J_TEST_AUTH
|
|
34
|
+
) as driver:
|
|
35
|
+
yield driver
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture(scope="module")
|
|
39
|
+
async def neo4j_test_driver_module() -> AsyncGenerator[neo4j.AsyncDriver, None]:
|
|
40
|
+
async with _build_neo4j_driver() as driver:
|
|
41
|
+
async with driver.session(database=neo4j.DEFAULT_DATABASE) as sess:
|
|
42
|
+
await wipe_db(sess)
|
|
43
|
+
yield driver
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture(scope="session")
|
|
47
|
+
async def neo4j_test_driver_session() -> AsyncGenerator[neo4j.AsyncDriver, None]:
|
|
48
|
+
async with _build_neo4j_driver() as driver:
|
|
49
|
+
async with driver.session(database=neo4j.DEFAULT_DATABASE) as sess:
|
|
50
|
+
await wipe_db(sess)
|
|
51
|
+
yield driver
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.fixture()
|
|
55
|
+
async def neo4j_test_driver() -> AsyncGenerator[neo4j.AsyncDriver, None]:
|
|
56
|
+
async with _build_neo4j_driver() as driver:
|
|
57
|
+
async with driver.session(database="neo4j") as sess:
|
|
58
|
+
await wipe_db(sess)
|
|
59
|
+
yield driver
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.fixture(scope="session")
|
|
63
|
+
async def neo4j_test_session_session(
|
|
64
|
+
neo4j_test_driver_session: neo4j.Driver,
|
|
65
|
+
) -> AsyncGenerator[neo4j.AsyncSession, None]:
|
|
66
|
+
driver = neo4j_test_driver_session
|
|
67
|
+
async with driver.session(database="neo4j") as sess:
|
|
68
|
+
await wipe_db(sess)
|
|
69
|
+
yield sess
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.fixture(scope="module")
|
|
73
|
+
async def neo4j_test_session_module(
|
|
74
|
+
neo4j_test_session_session: neo4j.AsyncSession,
|
|
75
|
+
) -> neo4j.AsyncSession:
|
|
76
|
+
session = neo4j_test_session_session
|
|
77
|
+
await wipe_db(session)
|
|
78
|
+
return session
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.fixture()
|
|
82
|
+
async def neo4j_test_session(
|
|
83
|
+
neo4j_test_session_session: neo4j.AsyncSession,
|
|
84
|
+
) -> neo4j.AsyncSession:
|
|
85
|
+
session = neo4j_test_session_session
|
|
86
|
+
await wipe_db(session)
|
|
87
|
+
return session
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def mocked_is_enterprise(_: neo4j.AsyncDriver) -> bool:
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@contextlib.asynccontextmanager
|
|
95
|
+
async def _mocked_project_db_session(
|
|
96
|
+
neo4j_driver: neo4j.AsyncDriver, project: str # pylint: disable=unused-argument
|
|
97
|
+
) -> neo4j.AsyncSession:
|
|
98
|
+
async with neo4j_driver.session(database=NEO4J_COMMUNITY_DB) as sess:
|
|
99
|
+
yield sess
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _mocked_project_registry_db(
|
|
103
|
+
neo4j_driver: neo4j.AsyncDriver, # pylint: disable=unused-argument
|
|
104
|
+
) -> str:
|
|
105
|
+
return NEO4J_COMMUNITY_DB
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.fixture()
|
|
109
|
+
def mock_enterprise(monkeypatch):
|
|
110
|
+
mock_enterprise_(monkeypatch)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def mock_enterprise_(monkeypatch):
|
|
114
|
+
monkeypatch.setattr(
|
|
115
|
+
icij_common.neo4j.projects, "project_db_session", _mocked_project_db_session
|
|
116
|
+
)
|
|
117
|
+
monkeypatch.setattr(
|
|
118
|
+
icij_common.neo4j.migrate,
|
|
119
|
+
"project_db_session",
|
|
120
|
+
_mocked_project_db_session,
|
|
121
|
+
)
|
|
122
|
+
monkeypatch.setattr(
|
|
123
|
+
icij_common.neo4j.projects,
|
|
124
|
+
"project_registry_db",
|
|
125
|
+
_mocked_project_registry_db,
|
|
126
|
+
)
|
|
127
|
+
monkeypatch.setattr(
|
|
128
|
+
icij_common.neo4j.projects, "is_enterprise", mocked_is_enterprise
|
|
129
|
+
)
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from collections import defaultdict, deque
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from pathlib import PurePath
|
|
7
|
+
from types import GeneratorType
|
|
8
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union, cast
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
from pydantic.fields import FieldInfo
|
|
12
|
+
from pydantic.json import ENCODERS_BY_TYPE
|
|
13
|
+
|
|
14
|
+
SetIntStr = Set[Union[int, str]]
|
|
15
|
+
DictIntStrAny = Dict[Union[int, str], Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def to_lower_camel(field: str) -> str:
|
|
19
|
+
return "".join(
|
|
20
|
+
w.capitalize() if i > 0 else w for i, w in enumerate(field.split("_"))
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_FIELD_ARGS = ["include", "exclude", "update"]
|
|
25
|
+
|
|
26
|
+
_SCHEMAS = dict()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# TODO: remove this one when migrating to pydantic 2.0
|
|
30
|
+
def safe_copy(model: BaseModel, **kwargs):
|
|
31
|
+
if model.__class__ not in _SCHEMAS:
|
|
32
|
+
_SCHEMAS[model.__class__] = dict()
|
|
33
|
+
# Model.copy is always without alias
|
|
34
|
+
_SCHEMAS[model.__class__] = model.__class__.schema(by_alias=False)
|
|
35
|
+
schema = _SCHEMAS[model.__class__]
|
|
36
|
+
for k in _FIELD_ARGS:
|
|
37
|
+
if k not in kwargs:
|
|
38
|
+
continue
|
|
39
|
+
for field in kwargs[k]:
|
|
40
|
+
if field not in schema["properties"]:
|
|
41
|
+
msg = f'Unknown attribute "{field}" for {model.__class__}'
|
|
42
|
+
raise AttributeError(msg)
|
|
43
|
+
|
|
44
|
+
return cast(model.__class__, model.copy(**kwargs))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ICIJModel(BaseModel):
|
|
48
|
+
class Config:
|
|
49
|
+
allow_mutation = False
|
|
50
|
+
extra = "forbid"
|
|
51
|
+
allow_population_by_field_name = True
|
|
52
|
+
keep_untouched = (cached_property,)
|
|
53
|
+
use_enum_values = True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_field_default_value(attr: FieldInfo):
|
|
57
|
+
# Ugly work around pydantic v1 limitations on Field and default values
|
|
58
|
+
if isinstance(attr, FieldInfo):
|
|
59
|
+
if attr.default_factory is not None:
|
|
60
|
+
return attr.default_factory()
|
|
61
|
+
return attr.default
|
|
62
|
+
return attr
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class LowerCamelCaseModel(ICIJModel):
|
|
66
|
+
class Config:
|
|
67
|
+
alias_generator = to_lower_camel
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class IgnoreExtraModel(ICIJModel):
|
|
71
|
+
class Config:
|
|
72
|
+
extra = "ignore"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class NoEnumModel(ICIJModel):
|
|
76
|
+
class Config:
|
|
77
|
+
use_enum_values = False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ISODatetime(ICIJModel):
|
|
81
|
+
class Config:
|
|
82
|
+
json_encoders = {datetime: lambda x: x.isoformat()}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def generate_encoders_by_class_tuples(
|
|
86
|
+
type_encoder_map: Dict[Any, Callable[[Any], Any]]
|
|
87
|
+
) -> Dict[Callable[[Any], Any], Tuple[Any, ...]]:
|
|
88
|
+
encoders_by_class_tuples: Dict[Callable[[Any], Any], Tuple[Any, ...]] = defaultdict(
|
|
89
|
+
tuple
|
|
90
|
+
)
|
|
91
|
+
for type_, encoder in type_encoder_map.items():
|
|
92
|
+
encoders_by_class_tuples[encoder] += (type_,)
|
|
93
|
+
return encoders_by_class_tuples
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
encoders_by_class_tuples_ = generate_encoders_by_class_tuples(ENCODERS_BY_TYPE)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def jsonable_encoder(
|
|
100
|
+
obj: Any,
|
|
101
|
+
include: Optional[Union[SetIntStr, DictIntStrAny]] = None,
|
|
102
|
+
exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None,
|
|
103
|
+
by_alias: bool = True,
|
|
104
|
+
exclude_unset: bool = False,
|
|
105
|
+
exclude_defaults: bool = False,
|
|
106
|
+
exclude_none: bool = False,
|
|
107
|
+
custom_encoder: Optional[Dict[Any, Callable[[Any], Any]]] = None,
|
|
108
|
+
sqlalchemy_safe: bool = True,
|
|
109
|
+
) -> Any:
|
|
110
|
+
custom_encoder = custom_encoder or {}
|
|
111
|
+
if custom_encoder:
|
|
112
|
+
if type(obj) in custom_encoder:
|
|
113
|
+
return custom_encoder[type(obj)](obj)
|
|
114
|
+
for encoder_type, encoder_instance in custom_encoder.items():
|
|
115
|
+
if isinstance(obj, encoder_type):
|
|
116
|
+
return encoder_instance(obj)
|
|
117
|
+
if include is not None and not isinstance(include, (set, dict)):
|
|
118
|
+
include = set(include)
|
|
119
|
+
if exclude is not None and not isinstance(exclude, (set, dict)):
|
|
120
|
+
exclude = set(exclude)
|
|
121
|
+
if isinstance(obj, BaseModel):
|
|
122
|
+
encoder = getattr(obj.__config__, "json_encoders", {})
|
|
123
|
+
if custom_encoder:
|
|
124
|
+
encoder.update(custom_encoder)
|
|
125
|
+
obj_dict = obj.dict(
|
|
126
|
+
include=include,
|
|
127
|
+
exclude=exclude,
|
|
128
|
+
by_alias=by_alias,
|
|
129
|
+
exclude_unset=exclude_unset,
|
|
130
|
+
exclude_none=exclude_none,
|
|
131
|
+
exclude_defaults=exclude_defaults,
|
|
132
|
+
)
|
|
133
|
+
if "__root__" in obj_dict:
|
|
134
|
+
obj_dict = obj_dict["__root__"]
|
|
135
|
+
return jsonable_encoder(
|
|
136
|
+
obj_dict,
|
|
137
|
+
exclude_none=exclude_none,
|
|
138
|
+
exclude_defaults=exclude_defaults,
|
|
139
|
+
custom_encoder=encoder,
|
|
140
|
+
sqlalchemy_safe=sqlalchemy_safe,
|
|
141
|
+
)
|
|
142
|
+
if dataclasses.is_dataclass(obj):
|
|
143
|
+
obj_dict = dataclasses.asdict(obj)
|
|
144
|
+
return jsonable_encoder(
|
|
145
|
+
obj_dict,
|
|
146
|
+
include=include,
|
|
147
|
+
exclude=exclude,
|
|
148
|
+
by_alias=by_alias,
|
|
149
|
+
exclude_unset=exclude_unset,
|
|
150
|
+
exclude_defaults=exclude_defaults,
|
|
151
|
+
exclude_none=exclude_none,
|
|
152
|
+
custom_encoder=custom_encoder,
|
|
153
|
+
sqlalchemy_safe=sqlalchemy_safe,
|
|
154
|
+
)
|
|
155
|
+
if isinstance(obj, Enum):
|
|
156
|
+
return obj.value
|
|
157
|
+
if isinstance(obj, PurePath):
|
|
158
|
+
return str(obj)
|
|
159
|
+
if isinstance(obj, (str, int, float, type(None))):
|
|
160
|
+
return obj
|
|
161
|
+
if isinstance(obj, dict):
|
|
162
|
+
encoded_dict = {}
|
|
163
|
+
allowed_keys = set(obj.keys())
|
|
164
|
+
if include is not None:
|
|
165
|
+
allowed_keys &= set(include)
|
|
166
|
+
if exclude is not None:
|
|
167
|
+
allowed_keys -= set(exclude)
|
|
168
|
+
for key, value in obj.items():
|
|
169
|
+
if (
|
|
170
|
+
(
|
|
171
|
+
not sqlalchemy_safe
|
|
172
|
+
or (not isinstance(key, str))
|
|
173
|
+
or (not key.startswith("_sa"))
|
|
174
|
+
)
|
|
175
|
+
and (value is not None or not exclude_none)
|
|
176
|
+
and key in allowed_keys
|
|
177
|
+
):
|
|
178
|
+
encoded_key = jsonable_encoder(
|
|
179
|
+
key,
|
|
180
|
+
by_alias=by_alias,
|
|
181
|
+
exclude_unset=exclude_unset,
|
|
182
|
+
exclude_none=exclude_none,
|
|
183
|
+
custom_encoder=custom_encoder,
|
|
184
|
+
sqlalchemy_safe=sqlalchemy_safe,
|
|
185
|
+
)
|
|
186
|
+
encoded_value = jsonable_encoder(
|
|
187
|
+
value,
|
|
188
|
+
by_alias=by_alias,
|
|
189
|
+
exclude_unset=exclude_unset,
|
|
190
|
+
exclude_none=exclude_none,
|
|
191
|
+
custom_encoder=custom_encoder,
|
|
192
|
+
sqlalchemy_safe=sqlalchemy_safe,
|
|
193
|
+
)
|
|
194
|
+
encoded_dict[encoded_key] = encoded_value
|
|
195
|
+
return encoded_dict
|
|
196
|
+
if isinstance(obj, (list, set, frozenset, GeneratorType, tuple, deque)):
|
|
197
|
+
encoded_list = []
|
|
198
|
+
for item in obj:
|
|
199
|
+
encoded_list.append(
|
|
200
|
+
jsonable_encoder(
|
|
201
|
+
item,
|
|
202
|
+
include=include,
|
|
203
|
+
exclude=exclude,
|
|
204
|
+
by_alias=by_alias,
|
|
205
|
+
exclude_unset=exclude_unset,
|
|
206
|
+
exclude_defaults=exclude_defaults,
|
|
207
|
+
exclude_none=exclude_none,
|
|
208
|
+
custom_encoder=custom_encoder,
|
|
209
|
+
sqlalchemy_safe=sqlalchemy_safe,
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
return encoded_list
|
|
213
|
+
|
|
214
|
+
if type(obj) in ENCODERS_BY_TYPE:
|
|
215
|
+
return ENCODERS_BY_TYPE[type(obj)](obj)
|
|
216
|
+
for encoder, classes_tuple in encoders_by_class_tuples_.items():
|
|
217
|
+
if isinstance(obj, classes_tuple):
|
|
218
|
+
return encoder(obj)
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
data = dict(obj)
|
|
222
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
223
|
+
errors: List[Exception] = [e]
|
|
224
|
+
try:
|
|
225
|
+
data = vars(obj)
|
|
226
|
+
except Exception as other_e:
|
|
227
|
+
errors.append(other_e)
|
|
228
|
+
raise ValueError(errors) from other_e
|
|
229
|
+
return jsonable_encoder(
|
|
230
|
+
data,
|
|
231
|
+
include=include,
|
|
232
|
+
exclude=exclude,
|
|
233
|
+
by_alias=by_alias,
|
|
234
|
+
exclude_unset=exclude_unset,
|
|
235
|
+
exclude_defaults=exclude_defaults,
|
|
236
|
+
exclude_none=exclude_none,
|
|
237
|
+
custom_encoder=custom_encoder,
|
|
238
|
+
sqlalchemy_safe=sqlalchemy_safe,
|
|
239
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import os
|
|
4
|
+
import traceback
|
|
5
|
+
from copy import copy
|
|
6
|
+
from time import monotonic, sleep
|
|
7
|
+
from typing import Awaitable, Callable, Optional
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
TEST_PROJECT = "test_project"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def true_after(
|
|
15
|
+
state_statement: Callable, *, after_s: float, sleep_s: float = 0.01
|
|
16
|
+
) -> bool:
|
|
17
|
+
start = monotonic()
|
|
18
|
+
while "waiting for the statement to be True":
|
|
19
|
+
try:
|
|
20
|
+
assert state_statement()
|
|
21
|
+
return True
|
|
22
|
+
except AssertionError:
|
|
23
|
+
if monotonic() - start < after_s:
|
|
24
|
+
sleep(sleep_s)
|
|
25
|
+
continue
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def async_true_after(
|
|
30
|
+
state_statement: Callable[[], Awaitable[bool]],
|
|
31
|
+
*,
|
|
32
|
+
after_s: float,
|
|
33
|
+
sleep_s: float = 0.01,
|
|
34
|
+
) -> bool:
|
|
35
|
+
start = monotonic()
|
|
36
|
+
while "waiting for the statement to be True":
|
|
37
|
+
try:
|
|
38
|
+
assert await state_statement()
|
|
39
|
+
return True
|
|
40
|
+
except AssertionError:
|
|
41
|
+
if monotonic() - start < after_s:
|
|
42
|
+
await asyncio.sleep(sleep_s)
|
|
43
|
+
continue
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@contextlib.contextmanager
|
|
48
|
+
def fail_if_exception(msg: Optional[str] = None):
|
|
49
|
+
try:
|
|
50
|
+
yield
|
|
51
|
+
except Exception as e: # pylint: disable=W0703
|
|
52
|
+
trace = "".join(traceback.format_exception(None, e, e.__traceback__))
|
|
53
|
+
if msg is None:
|
|
54
|
+
msg = "Test failed due to the following error"
|
|
55
|
+
pytest.fail(f"{msg}\n{trace}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.fixture()
|
|
59
|
+
def reset_env():
|
|
60
|
+
old_env = copy(dict(os.environ))
|
|
61
|
+
try:
|
|
62
|
+
yield
|
|
63
|
+
finally:
|
|
64
|
+
os.environ.clear()
|
|
65
|
+
os.environ.update(old_env)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Define a session level even_loop fixture to overcome limitation explained here:
|
|
69
|
+
# https://github.com/tortoise/tortoise-orm/issues/638#issuecomment-830124562
|
|
70
|
+
@pytest.fixture(scope="session")
|
|
71
|
+
def event_loop():
|
|
72
|
+
policy = asyncio.get_event_loop_policy()
|
|
73
|
+
loop = policy.new_event_loop()
|
|
74
|
+
yield loop
|
|
75
|
+
loop.close()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "icij-common"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Common utils for ICIJ libs"
|
|
5
|
+
authors = [
|
|
6
|
+
"Clément Doumouro <cdoumouro@icij.org>",
|
|
7
|
+
"ICIJ <engineering@icij.org>",
|
|
8
|
+
]
|
|
9
|
+
# TODO: add readme
|
|
10
|
+
packages = [
|
|
11
|
+
{ include = "icij_common" }
|
|
12
|
+
]
|
|
13
|
+
exclude = ["icij_common/tests"]
|
|
14
|
+
|
|
15
|
+
[tool.black]
|
|
16
|
+
line-length = 88
|
|
17
|
+
target-version = ["py39"]
|
|
18
|
+
|
|
19
|
+
[tool.pytest.ini_options]
|
|
20
|
+
asyncio_mode = "auto"
|
|
21
|
+
|
|
22
|
+
[tool.poetry.dependencies]
|
|
23
|
+
python = "^3.9"
|
|
24
|
+
pydantic = "^1.10" # Sadly needed for now because of Pyinstaller
|
|
25
|
+
|
|
26
|
+
[tool.poetry.group.dev.dependencies]
|
|
27
|
+
neo4j = "^5.0.0"
|
|
28
|
+
pylint = "^3.1.0"
|
|
29
|
+
pytest = "^7.2.1"
|
|
30
|
+
pytest-asyncio = "^0.20.3"
|
|
31
|
+
|
|
32
|
+
[tool.poetry.group.neo4j.dependencies]
|
|
33
|
+
neo4j = "^5.0.0"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["poetry-core"]
|
|
37
|
+
build-backend = "poetry.core.masonry.api"
|