ekbatan 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.
- ekbatan/__init__.py +262 -0
- ekbatan/_builder.py +41 -0
- ekbatan/_credentials.py +162 -0
- ekbatan/_guards.py +64 -0
- ekbatan/_json.py +839 -0
- ekbatan/_logtext.py +36 -0
- ekbatan/_metrics.py +56 -0
- ekbatan/_redact.py +77 -0
- ekbatan/_tracing.py +80 -0
- ekbatan/_yoyo.py +617 -0
- ekbatan/accounting.py +103 -0
- ekbatan/action/__init__.py +40 -0
- ekbatan/action/action.py +129 -0
- ekbatan/action/executor.py +714 -0
- ekbatan/action/outbox.py +446 -0
- ekbatan/action/persister.py +141 -0
- ekbatan/action/plan.py +143 -0
- ekbatan/action/registry.py +127 -0
- ekbatan/aio/__init__.py +73 -0
- ekbatan/aio/action.py +42 -0
- ekbatan/aio/executor.py +166 -0
- ekbatan/aio/locks/__init__.py +30 -0
- ekbatan/aio/locks/base.py +198 -0
- ekbatan/aio/locks/redis.py +244 -0
- ekbatan/aio/locks/sql.py +280 -0
- ekbatan/aio/outbox.py +120 -0
- ekbatan/aio/persistence.py +460 -0
- ekbatan/aio/persister.py +64 -0
- ekbatan/aio/repository.py +443 -0
- ekbatan/aio/testing.py +93 -0
- ekbatan/clock.py +151 -0
- ekbatan/codegen.py +343 -0
- ekbatan/config.py +622 -0
- ekbatan/config_files.py +683 -0
- ekbatan/distributedjobs/__init__.py +696 -0
- ekbatan/distributedjobs/_shutdown.py +111 -0
- ekbatan/distributedjobs/config.py +103 -0
- ekbatan/domain.py +881 -0
- ekbatan/errors.py +151 -0
- ekbatan/events/__init__.py +43 -0
- ekbatan/events/_across.py +75 -0
- ekbatan/events/config.py +133 -0
- ekbatan/events/dispatch.py +386 -0
- ekbatan/events/fanout.py +149 -0
- ekbatan/events/handler.py +352 -0
- ekbatan/events/jobs.py +331 -0
- ekbatan/events/loop.py +159 -0
- ekbatan/events/notification.py +237 -0
- ekbatan/events/repository.py +226 -0
- ekbatan/fetching.py +275 -0
- ekbatan/ids.py +236 -0
- ekbatan/locks/__init__.py +26 -0
- ekbatan/locks/_deadlines.py +122 -0
- ekbatan/locks/_holder.py +225 -0
- ekbatan/locks/base.py +176 -0
- ekbatan/locks/redis.py +334 -0
- ekbatan/locks/sql.py +385 -0
- ekbatan/migrations.py +1849 -0
- ekbatan/persistence.py +772 -0
- ekbatan/py.typed +0 -0
- ekbatan/repository.py +1774 -0
- ekbatan/schema.py +459 -0
- ekbatan/schemagen/__init__.py +423 -0
- ekbatan/schemagen/__main__.py +6 -0
- ekbatan/schemagen/_config.py +400 -0
- ekbatan/schemagen/_records.py +598 -0
- ekbatan/schemagen/_reflect.py +1307 -0
- ekbatan/schemagen/_render.py +1637 -0
- ekbatan/schemagen/_types.py +596 -0
- ekbatan/sharding.py +257 -0
- ekbatan/streaming.py +201 -0
- ekbatan/testing.py +411 -0
- ekbatan/time.py +44 -0
- ekbatan-0.1.0.dist-info/METADATA +348 -0
- ekbatan-0.1.0.dist-info/RECORD +77 -0
- ekbatan-0.1.0.dist-info/WHEEL +4 -0
- ekbatan-0.1.0.dist-info/licenses/LICENSE +201 -0
ekbatan/__init__.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Ekbatan: a persistence framework for event-driven systems.
|
|
2
|
+
|
|
3
|
+
A service that publishes domain events usually writes twice - once to its database, once to
|
|
4
|
+
its broker - and the two disagree whenever the second write fails. The transactional outbox is
|
|
5
|
+
the established answer, normally bolted on as a separate library or assembled by hand.
|
|
6
|
+
|
|
7
|
+
This folds it into the persistence layer, with three pieces:
|
|
8
|
+
|
|
9
|
+
- a **model** emits an event when it changes;
|
|
10
|
+
- an **action** stages changes on a **plan** instead of writing them;
|
|
11
|
+
- the **executor** opens one transaction, writes the rows and their events, and commits.
|
|
12
|
+
|
|
13
|
+
State and events land together or neither does, and no line of application code mentions an
|
|
14
|
+
outbox.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .accounting import StatementCount, count_statements
|
|
18
|
+
from .action import (
|
|
19
|
+
DEFAULT_RETRIES,
|
|
20
|
+
Action,
|
|
21
|
+
ActionExecutor,
|
|
22
|
+
ActionExecutorBuilder,
|
|
23
|
+
ActionPlan,
|
|
24
|
+
ActionRegistry,
|
|
25
|
+
ActionRegistryBuilder,
|
|
26
|
+
ChangePersister,
|
|
27
|
+
EventEntity,
|
|
28
|
+
EventEntityBuilder,
|
|
29
|
+
EventPersister,
|
|
30
|
+
ExecutionConfiguration,
|
|
31
|
+
ExecutionConfigurationBuilder,
|
|
32
|
+
RetryConfig,
|
|
33
|
+
SingleTableJsonEventPersister,
|
|
34
|
+
current_plan,
|
|
35
|
+
)
|
|
36
|
+
from .clock import Clock, SystemClock, VirtualClock
|
|
37
|
+
from .config import (
|
|
38
|
+
DataSourceConfig,
|
|
39
|
+
DataSourceConfigBuilder,
|
|
40
|
+
ShardGroupConfig,
|
|
41
|
+
ShardGroupConfigBuilder,
|
|
42
|
+
ShardingConfig,
|
|
43
|
+
ShardingConfigBuilder,
|
|
44
|
+
ShardMemberConfig,
|
|
45
|
+
ShardMemberConfigBuilder,
|
|
46
|
+
)
|
|
47
|
+
from .distributedjobs import (
|
|
48
|
+
DistributedJob,
|
|
49
|
+
ExecutionContext,
|
|
50
|
+
JobRegistry,
|
|
51
|
+
JobRegistryBuilder,
|
|
52
|
+
JobsConfig,
|
|
53
|
+
Schedule,
|
|
54
|
+
job_registry,
|
|
55
|
+
)
|
|
56
|
+
from .domain import (
|
|
57
|
+
Entity,
|
|
58
|
+
EntityBuilder,
|
|
59
|
+
GenericState,
|
|
60
|
+
Model,
|
|
61
|
+
ModelBuilder,
|
|
62
|
+
ModelEvent,
|
|
63
|
+
Persistable,
|
|
64
|
+
)
|
|
65
|
+
from .errors import (
|
|
66
|
+
BlockingCallError,
|
|
67
|
+
ConnectivityError,
|
|
68
|
+
CrossShardError,
|
|
69
|
+
EkbatanError,
|
|
70
|
+
EntityNotFoundError,
|
|
71
|
+
ForkedProcessError,
|
|
72
|
+
LockAcquisitionError,
|
|
73
|
+
StaleRecordError,
|
|
74
|
+
TransactionContextError,
|
|
75
|
+
TransactionThreadError,
|
|
76
|
+
UnsupportedError,
|
|
77
|
+
ValidationError,
|
|
78
|
+
)
|
|
79
|
+
from .events import (
|
|
80
|
+
EventDispatch,
|
|
81
|
+
EventEnvelope,
|
|
82
|
+
EventEnvelopeBuilder,
|
|
83
|
+
EventFanout,
|
|
84
|
+
EventFanoutJob,
|
|
85
|
+
EventHandler,
|
|
86
|
+
EventHandlerRegistry,
|
|
87
|
+
EventHandlerRegistryBuilder,
|
|
88
|
+
EventHandlingJob,
|
|
89
|
+
EventNotificationState,
|
|
90
|
+
HandlingConfig,
|
|
91
|
+
HandlingConfigBuilder,
|
|
92
|
+
LocalEventHandlerConfig,
|
|
93
|
+
LocalEventHandlerConfigBuilder,
|
|
94
|
+
)
|
|
95
|
+
from .fetching import Db, FetchResult
|
|
96
|
+
from .ids import Id, ShardedId, TypedValue, unwrap
|
|
97
|
+
from .locks import (
|
|
98
|
+
KeyedLockProvider,
|
|
99
|
+
Lease,
|
|
100
|
+
MariaDBKeyedLockProvider,
|
|
101
|
+
MySQLKeyedLockProvider,
|
|
102
|
+
PostgresKeyedLockProvider,
|
|
103
|
+
SqlKeyedLockProviderBuilder,
|
|
104
|
+
)
|
|
105
|
+
from .migrations import (
|
|
106
|
+
MigrationError,
|
|
107
|
+
YoyoMigrator,
|
|
108
|
+
YoyoMigratorBuilder,
|
|
109
|
+
emit_ddl,
|
|
110
|
+
migrate,
|
|
111
|
+
migrate_registry,
|
|
112
|
+
validate_scripts,
|
|
113
|
+
)
|
|
114
|
+
from .persistence import (
|
|
115
|
+
DatabaseRegistry,
|
|
116
|
+
DatabaseRegistryBuilder,
|
|
117
|
+
DataSource,
|
|
118
|
+
Transaction,
|
|
119
|
+
TransactionManager,
|
|
120
|
+
)
|
|
121
|
+
from .repository import (
|
|
122
|
+
AbstractRepository,
|
|
123
|
+
EntityRepository,
|
|
124
|
+
ModelRepository,
|
|
125
|
+
Repository,
|
|
126
|
+
RepositoryRegistry,
|
|
127
|
+
RepositoryRegistryBuilder,
|
|
128
|
+
)
|
|
129
|
+
from .schema import notifications_table, outbox_table, scheduled_tasks_table
|
|
130
|
+
from .sharding import (
|
|
131
|
+
DEFAULT_SHARD,
|
|
132
|
+
EmbeddedBitsShardingStrategy,
|
|
133
|
+
NoShardingStrategy,
|
|
134
|
+
ShardIdentifier,
|
|
135
|
+
ShardingStrategy,
|
|
136
|
+
UnknownShardPolicy,
|
|
137
|
+
read_shard,
|
|
138
|
+
sharded_uuid,
|
|
139
|
+
uuid_instant,
|
|
140
|
+
)
|
|
141
|
+
from .testing import ActionAssert, ActionSpec, StagedWork, run_action
|
|
142
|
+
|
|
143
|
+
__version__ = "0.1.0"
|
|
144
|
+
|
|
145
|
+
__all__ = [
|
|
146
|
+
"DEFAULT_RETRIES",
|
|
147
|
+
"DEFAULT_SHARD",
|
|
148
|
+
"AbstractRepository",
|
|
149
|
+
"Action",
|
|
150
|
+
"ActionAssert",
|
|
151
|
+
"ActionExecutor",
|
|
152
|
+
"ActionExecutorBuilder",
|
|
153
|
+
"ActionPlan",
|
|
154
|
+
"ActionRegistry",
|
|
155
|
+
"ActionRegistryBuilder",
|
|
156
|
+
"ActionSpec",
|
|
157
|
+
"BlockingCallError",
|
|
158
|
+
"ChangePersister",
|
|
159
|
+
"Clock",
|
|
160
|
+
"ConnectivityError",
|
|
161
|
+
"CrossShardError",
|
|
162
|
+
"DataSource",
|
|
163
|
+
"DataSourceConfig",
|
|
164
|
+
"DataSourceConfigBuilder",
|
|
165
|
+
"DatabaseRegistry",
|
|
166
|
+
"DatabaseRegistryBuilder",
|
|
167
|
+
"Db",
|
|
168
|
+
"DistributedJob",
|
|
169
|
+
"EkbatanError",
|
|
170
|
+
"EmbeddedBitsShardingStrategy",
|
|
171
|
+
"Entity",
|
|
172
|
+
"EntityBuilder",
|
|
173
|
+
"EntityNotFoundError",
|
|
174
|
+
"EntityRepository",
|
|
175
|
+
"EventDispatch",
|
|
176
|
+
"EventEntity",
|
|
177
|
+
"EventEntityBuilder",
|
|
178
|
+
"EventEnvelope",
|
|
179
|
+
"EventEnvelopeBuilder",
|
|
180
|
+
"EventFanout",
|
|
181
|
+
"EventFanoutJob",
|
|
182
|
+
"EventHandler",
|
|
183
|
+
"EventHandlerRegistry",
|
|
184
|
+
"EventHandlerRegistryBuilder",
|
|
185
|
+
"EventHandlingJob",
|
|
186
|
+
"EventNotificationState",
|
|
187
|
+
"EventPersister",
|
|
188
|
+
"ExecutionConfiguration",
|
|
189
|
+
"ExecutionConfigurationBuilder",
|
|
190
|
+
"ExecutionContext",
|
|
191
|
+
"FetchResult",
|
|
192
|
+
"ForkedProcessError",
|
|
193
|
+
"GenericState",
|
|
194
|
+
"HandlingConfig",
|
|
195
|
+
"HandlingConfigBuilder",
|
|
196
|
+
"Id",
|
|
197
|
+
"JobRegistry",
|
|
198
|
+
"JobRegistryBuilder",
|
|
199
|
+
"JobsConfig",
|
|
200
|
+
"KeyedLockProvider",
|
|
201
|
+
"Lease",
|
|
202
|
+
"LocalEventHandlerConfig",
|
|
203
|
+
"LocalEventHandlerConfigBuilder",
|
|
204
|
+
"LockAcquisitionError",
|
|
205
|
+
"MariaDBKeyedLockProvider",
|
|
206
|
+
"MigrationError",
|
|
207
|
+
"Model",
|
|
208
|
+
"ModelBuilder",
|
|
209
|
+
"ModelEvent",
|
|
210
|
+
"ModelRepository",
|
|
211
|
+
"MySQLKeyedLockProvider",
|
|
212
|
+
"NoShardingStrategy",
|
|
213
|
+
"Persistable",
|
|
214
|
+
"PostgresKeyedLockProvider",
|
|
215
|
+
"Repository",
|
|
216
|
+
"RepositoryRegistry",
|
|
217
|
+
"RepositoryRegistryBuilder",
|
|
218
|
+
"RetryConfig",
|
|
219
|
+
"Schedule",
|
|
220
|
+
"ShardGroupConfig",
|
|
221
|
+
"ShardGroupConfigBuilder",
|
|
222
|
+
"ShardIdentifier",
|
|
223
|
+
"ShardMemberConfig",
|
|
224
|
+
"ShardMemberConfigBuilder",
|
|
225
|
+
"ShardedId",
|
|
226
|
+
"ShardingConfig",
|
|
227
|
+
"ShardingConfigBuilder",
|
|
228
|
+
"ShardingStrategy",
|
|
229
|
+
"SingleTableJsonEventPersister",
|
|
230
|
+
"SqlKeyedLockProviderBuilder",
|
|
231
|
+
"StagedWork",
|
|
232
|
+
"StaleRecordError",
|
|
233
|
+
"StatementCount",
|
|
234
|
+
"SystemClock",
|
|
235
|
+
"Transaction",
|
|
236
|
+
"TransactionContextError",
|
|
237
|
+
"TransactionManager",
|
|
238
|
+
"TransactionThreadError",
|
|
239
|
+
"TypedValue",
|
|
240
|
+
"UnknownShardPolicy",
|
|
241
|
+
"UnsupportedError",
|
|
242
|
+
"ValidationError",
|
|
243
|
+
"VirtualClock",
|
|
244
|
+
"YoyoMigrator",
|
|
245
|
+
"YoyoMigratorBuilder",
|
|
246
|
+
"__version__",
|
|
247
|
+
"count_statements",
|
|
248
|
+
"current_plan",
|
|
249
|
+
"emit_ddl",
|
|
250
|
+
"job_registry",
|
|
251
|
+
"migrate",
|
|
252
|
+
"migrate_registry",
|
|
253
|
+
"notifications_table",
|
|
254
|
+
"outbox_table",
|
|
255
|
+
"read_shard",
|
|
256
|
+
"run_action",
|
|
257
|
+
"scheduled_tasks_table",
|
|
258
|
+
"sharded_uuid",
|
|
259
|
+
"unwrap",
|
|
260
|
+
"uuid_instant",
|
|
261
|
+
"validate_scripts",
|
|
262
|
+
]
|
ekbatan/_builder.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The shared half of the builders for the framework's plain data classes.
|
|
2
|
+
|
|
3
|
+
Each builder keeps its own typed setters - that is what a checker reads - and hands the value to
|
|
4
|
+
:meth:`FieldBuilder._set`. What they share is ``build()``: every required field named, in one
|
|
5
|
+
refusal, before the constructor is reached. Left to the dataclass, a missing field is a
|
|
6
|
+
``TypeError`` about a keyword argument, which names the constructor rather than the setter that
|
|
7
|
+
was never called.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, ClassVar, Self, cast
|
|
13
|
+
|
|
14
|
+
from .errors import ValidationError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FieldBuilder[T]:
|
|
18
|
+
"""Collects fields by name, then builds ``_target`` from them."""
|
|
19
|
+
|
|
20
|
+
__slots__ = ("_given",)
|
|
21
|
+
|
|
22
|
+
#: The class ``build()`` constructs.
|
|
23
|
+
_target: ClassVar[type[Any]]
|
|
24
|
+
#: Fields ``build()`` refuses to go without, in the order they are named when missing.
|
|
25
|
+
_required: ClassVar[tuple[str, ...]] = ()
|
|
26
|
+
#: Values used for a field whose setter was never called, where the dataclass has none.
|
|
27
|
+
_defaults: ClassVar[dict[str, Any]] = {}
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._given: dict[str, Any] = {}
|
|
31
|
+
|
|
32
|
+
def _set(self, name: str, value: Any) -> Self:
|
|
33
|
+
self._given[name] = value
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
def build(self) -> T:
|
|
37
|
+
missing = [name for name in self._required if self._given.get(name) is None]
|
|
38
|
+
if missing:
|
|
39
|
+
verb = "is" if len(missing) == 1 else "are"
|
|
40
|
+
raise ValidationError(f"{', '.join(missing)} {verb} required")
|
|
41
|
+
return cast("T", self._target(**{**self._defaults, **self._given}))
|
ekbatan/_credentials.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Refuses a url that carries a user name or a secret - Java's ``JdbcUrlCredentials``.
|
|
2
|
+
|
|
3
|
+
A url is logged and repeated - drivers repeat it in their errors, SQLAlchemy's echo prints it,
|
|
4
|
+
people paste it into tickets - so it carries neither: the login user goes to ``username``, the
|
|
5
|
+
login password to ``password``, and every other secret to ``data_source_properties``.
|
|
6
|
+
|
|
7
|
+
The url is read with SQLAlchemy's own parser, which is what turns it into the driver's
|
|
8
|
+
arguments: a user and a password before the ``@``, and the settings of the query string. Java
|
|
9
|
+
reads the url's raw text instead, because JDBC urls come in a dozen forms; a SQLAlchemy url comes
|
|
10
|
+
in one, and reading it the way SQLAlchemy reads it means a setting is refused exactly when it
|
|
11
|
+
would reach the driver.
|
|
12
|
+
|
|
13
|
+
Which setting names hold a user name or a secret is decided once, for every setting of every
|
|
14
|
+
driver this port connects through - psycopg (and the libpq under it), PyMySQL and sqlite3, and
|
|
15
|
+
``ekbatan.aio``'s asyncpg, aiomysql and aiosqlite - in the ledger ``tests/driver_settings.tsv``.
|
|
16
|
+
The names here are its refused part, and a test fails when the two disagree. Names are matched
|
|
17
|
+
ignoring case and spaces, as Java matches them; a setting with no value carries nothing.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import enum
|
|
23
|
+
import re
|
|
24
|
+
from typing import Any
|
|
25
|
+
from urllib.parse import unquote
|
|
26
|
+
|
|
27
|
+
from .errors import ValidationError
|
|
28
|
+
|
|
29
|
+
__all__ = ["Kind", "kind_of", "require_none_in"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Kind(enum.Enum):
|
|
33
|
+
"""What a refused setting holds, which decides where its value goes instead."""
|
|
34
|
+
|
|
35
|
+
#: The user this configuration logs in as.
|
|
36
|
+
LOGIN_USER = "login-user"
|
|
37
|
+
#: This configuration's login password.
|
|
38
|
+
LOGIN_PASSWORD = "login-password"
|
|
39
|
+
#: Any other user name. None of this port's drivers has one yet; kept so the kinds match Java's.
|
|
40
|
+
USER_NAME = "user-name"
|
|
41
|
+
#: Any other secret: a key's password, a client secret, a key that logs in like a password.
|
|
42
|
+
SECRET = "secret"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
#: The refused setting names of the ledger, lower-cased.
|
|
46
|
+
SETTINGS: dict[str, Kind] = {
|
|
47
|
+
# every driver that logs in
|
|
48
|
+
"user": Kind.LOGIN_USER,
|
|
49
|
+
"password": Kind.LOGIN_PASSWORD,
|
|
50
|
+
# PyMySQL's old name for the password, read when `password` is empty
|
|
51
|
+
"passwd": Kind.LOGIN_PASSWORD,
|
|
52
|
+
# libpq, under psycopg: the client key's password, OAuth, and two SCRAM keys that log in
|
|
53
|
+
# like a password although libpq does not mark them as secret
|
|
54
|
+
"sslpassword": Kind.SECRET,
|
|
55
|
+
"oauth_client_secret": Kind.SECRET,
|
|
56
|
+
"scram_client_key": Kind.SECRET,
|
|
57
|
+
"scram_server_key": Kind.SECRET,
|
|
58
|
+
# PyMySQL: the client key's password
|
|
59
|
+
"ssl_key_password": Kind.SECRET,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_SPACES = re.compile(r"\s+")
|
|
63
|
+
|
|
64
|
+
_PROPERTIES = "data_source_properties"
|
|
65
|
+
|
|
66
|
+
_WHY = (
|
|
67
|
+
": a url is logged and repeated where user names and secrets must never be. "
|
|
68
|
+
"The url is not repeated here."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def kind_of(name: str) -> Kind | None:
|
|
73
|
+
"""What the setting of this name holds, if it is refused. Spaces inside the name and its
|
|
74
|
+
case are ignored."""
|
|
75
|
+
return SETTINGS.get(_SPACES.sub("", name).lower())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def require_none_in(url: str) -> None:
|
|
79
|
+
"""Raise when the url carries a user name or a secret.
|
|
80
|
+
|
|
81
|
+
The message names every one - each setting once - and where each goes instead, and never
|
|
82
|
+
repeats the url or a value.
|
|
83
|
+
"""
|
|
84
|
+
from sqlalchemy.engine import make_url
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
parsed = make_url(url)
|
|
88
|
+
user, password = bool(parsed.username), bool(parsed.password)
|
|
89
|
+
query: list[tuple[str, Any]] = list(parsed.query.items())
|
|
90
|
+
except Exception:
|
|
91
|
+
# SQLAlchemy cannot read it - two hosts written `h1:3306,h2:3306`, say - so read its
|
|
92
|
+
# text the way Java reads every url: the part before an `@`, and the query's names.
|
|
93
|
+
user, password, query = _read_text(url)
|
|
94
|
+
|
|
95
|
+
refusals: list[tuple[str, str]] = []
|
|
96
|
+
if user and password:
|
|
97
|
+
refusals.append(
|
|
98
|
+
(
|
|
99
|
+
"a user name and a password before an '@'",
|
|
100
|
+
"give them to username(...) and password(...)",
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
elif user:
|
|
104
|
+
refusals.append(("a user name before an '@'", "give it to username(...)"))
|
|
105
|
+
elif password:
|
|
106
|
+
refusals.append(("a password before an '@'", "give it to password(...)"))
|
|
107
|
+
|
|
108
|
+
named: set[str] = set()
|
|
109
|
+
for name, value in query:
|
|
110
|
+
if not _has_value(value):
|
|
111
|
+
continue
|
|
112
|
+
kind = kind_of(name)
|
|
113
|
+
if kind is None or name.lower() in named:
|
|
114
|
+
continue
|
|
115
|
+
named.add(name.lower())
|
|
116
|
+
refusals.append(_refusal(kind, name))
|
|
117
|
+
|
|
118
|
+
if len(refusals) == 1:
|
|
119
|
+
what, move = refusals[0]
|
|
120
|
+
raise ValidationError(f"the url carries {what}. {move[0].upper()}{move[1:]} instead{_WHY}")
|
|
121
|
+
if refusals:
|
|
122
|
+
listed = "; ".join(f"{what} - {move}" for what, move in refusals)
|
|
123
|
+
raise ValidationError(f"the url carries {listed}{_WHY}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _read_text(url: str) -> tuple[bool, bool, list[tuple[str, Any]]]:
|
|
127
|
+
"""A url SQLAlchemy cannot parse, read from its text: a user and a password before an ``@`` in
|
|
128
|
+
the host part, and the names of the query's settings. Values are never kept."""
|
|
129
|
+
_, _, rest = url.partition("://")
|
|
130
|
+
host_end = min(
|
|
131
|
+
(i for i in (rest.find("/"), rest.find("?"), rest.find("#")) if i >= 0), default=len(rest)
|
|
132
|
+
)
|
|
133
|
+
host_part = rest[:host_end]
|
|
134
|
+
user = password = False
|
|
135
|
+
if "@" in host_part:
|
|
136
|
+
info = host_part.rpartition("@")[0]
|
|
137
|
+
name, colon, secret = info.partition(":")
|
|
138
|
+
user, password = bool(name.strip()), bool(colon and secret.strip())
|
|
139
|
+
query: list[tuple[str, Any]] = []
|
|
140
|
+
_, question, after = rest.partition("?")
|
|
141
|
+
if question:
|
|
142
|
+
for piece in after.split("#", 1)[0].split("&"):
|
|
143
|
+
name, equals, value = piece.partition("=")
|
|
144
|
+
if name.strip():
|
|
145
|
+
query.append((unquote(name).strip(), value if equals else ""))
|
|
146
|
+
return user, password, query
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _has_value(value: Any) -> bool:
|
|
150
|
+
"""A setting written with no value carries nothing; a repeated one is a tuple of values."""
|
|
151
|
+
values = value if isinstance(value, tuple) else (value,)
|
|
152
|
+
return any(str(each).strip() for each in values)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _refusal(kind: Kind, setting: str) -> tuple[str, str]:
|
|
156
|
+
if kind is Kind.LOGIN_USER:
|
|
157
|
+
return f"the login user (its '{setting}' setting)", "give it to username(...)"
|
|
158
|
+
if kind is Kind.LOGIN_PASSWORD:
|
|
159
|
+
return f"the login password (its '{setting}' setting)", "give it to password(...)"
|
|
160
|
+
if kind is Kind.USER_NAME:
|
|
161
|
+
return f"a user name (its '{setting}' setting)", f"put it under {_PROPERTIES}"
|
|
162
|
+
return f"a secret (its '{setting}' setting)", f"put it under {_PROPERTIES}"
|
ekbatan/_guards.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The two refusals: an object used in a process it was copied into, and the sync API on an
|
|
2
|
+
event loop's thread.
|
|
3
|
+
|
|
4
|
+
Both refuse and explain; neither fixes anything. A copied pool is not reset, a blocking call is
|
|
5
|
+
not moved to a thread - doing either behind the caller's back would hide a mistake in how the
|
|
6
|
+
application starts or where it calls from, and the fix is one line in the application.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
|
|
15
|
+
from .errors import BlockingCallError, ForkedProcessError
|
|
16
|
+
|
|
17
|
+
__all__ = ["refuse_if_forked", "refuse_on_an_event_loop"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def refuse_if_forked(created_in: int, what: str) -> None:
|
|
21
|
+
"""Refuse ``what`` in any process but ``created_in``, the one that built it.
|
|
22
|
+
|
|
23
|
+
``fork()`` copies an object's pools and threads' bookkeeping into the child, but not the
|
|
24
|
+
threads, and the connections it copies are the parent's sockets: two processes talking on
|
|
25
|
+
one connection corrupt each other's conversations.
|
|
26
|
+
"""
|
|
27
|
+
if os.getpid() != created_in:
|
|
28
|
+
raise ForkedProcessError(
|
|
29
|
+
f"{what} was created in another process and copied into this one by fork() - "
|
|
30
|
+
f"gunicorn --preload, uwsgi, Celery prefork, RQ and multiprocessing all fork. Its "
|
|
31
|
+
f"connections and threads belong to the process that created it. Create ekbatan's "
|
|
32
|
+
f"objects - data sources, the database registry, the job registry, lock providers - "
|
|
33
|
+
f"in your app's startup code (the FastAPI lifespan, or the top of the worker's "
|
|
34
|
+
f"main()), not at import time."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def refuse_on_an_event_loop(what: str) -> None:
|
|
39
|
+
"""Refuse ``what`` - a sync call that waits on a database - on an asyncio event loop's
|
|
40
|
+
thread, where it would stop every other task on the loop until it returns.
|
|
41
|
+
|
|
42
|
+
asyncio not imported means no loop can be running, so then this costs a dictionary lookup.
|
|
43
|
+
A worker thread (``asyncio.to_thread``, ``run_in_executor``, a FastAPI ``def`` endpoint)
|
|
44
|
+
has no running loop. Under gevent a worker "thread" is a greenlet on the loop's own OS
|
|
45
|
+
thread, so asyncio reports the loop as running there too; the loop records the thread - the
|
|
46
|
+
greenlet, under gevent - that runs it, and only that one is refused. There a blocking call
|
|
47
|
+
yields, as every call does under gevent.
|
|
48
|
+
"""
|
|
49
|
+
asyncio = sys.modules.get("asyncio")
|
|
50
|
+
if asyncio is None:
|
|
51
|
+
return
|
|
52
|
+
try:
|
|
53
|
+
loop = asyncio.get_running_loop()
|
|
54
|
+
except RuntimeError:
|
|
55
|
+
return
|
|
56
|
+
runner = getattr(loop, "_thread_id", None)
|
|
57
|
+
if runner is not None and runner != threading.get_ident():
|
|
58
|
+
return
|
|
59
|
+
raise BlockingCallError(
|
|
60
|
+
f"{what} was called on an asyncio event loop's thread (inside `async def`). The sync "
|
|
61
|
+
f"API waits on the database, and here it would stop the event loop and every request "
|
|
62
|
+
f"on it. In `async def` code use ekbatan.aio; or make the endpoint a plain `def`; or "
|
|
63
|
+
f"call it with `await asyncio.to_thread(...)`."
|
|
64
|
+
)
|