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.
@@ -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"