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.
Files changed (77) hide show
  1. ekbatan/__init__.py +262 -0
  2. ekbatan/_builder.py +41 -0
  3. ekbatan/_credentials.py +162 -0
  4. ekbatan/_guards.py +64 -0
  5. ekbatan/_json.py +839 -0
  6. ekbatan/_logtext.py +36 -0
  7. ekbatan/_metrics.py +56 -0
  8. ekbatan/_redact.py +77 -0
  9. ekbatan/_tracing.py +80 -0
  10. ekbatan/_yoyo.py +617 -0
  11. ekbatan/accounting.py +103 -0
  12. ekbatan/action/__init__.py +40 -0
  13. ekbatan/action/action.py +129 -0
  14. ekbatan/action/executor.py +714 -0
  15. ekbatan/action/outbox.py +446 -0
  16. ekbatan/action/persister.py +141 -0
  17. ekbatan/action/plan.py +143 -0
  18. ekbatan/action/registry.py +127 -0
  19. ekbatan/aio/__init__.py +73 -0
  20. ekbatan/aio/action.py +42 -0
  21. ekbatan/aio/executor.py +166 -0
  22. ekbatan/aio/locks/__init__.py +30 -0
  23. ekbatan/aio/locks/base.py +198 -0
  24. ekbatan/aio/locks/redis.py +244 -0
  25. ekbatan/aio/locks/sql.py +280 -0
  26. ekbatan/aio/outbox.py +120 -0
  27. ekbatan/aio/persistence.py +460 -0
  28. ekbatan/aio/persister.py +64 -0
  29. ekbatan/aio/repository.py +443 -0
  30. ekbatan/aio/testing.py +93 -0
  31. ekbatan/clock.py +151 -0
  32. ekbatan/codegen.py +343 -0
  33. ekbatan/config.py +622 -0
  34. ekbatan/config_files.py +683 -0
  35. ekbatan/distributedjobs/__init__.py +696 -0
  36. ekbatan/distributedjobs/_shutdown.py +111 -0
  37. ekbatan/distributedjobs/config.py +103 -0
  38. ekbatan/domain.py +881 -0
  39. ekbatan/errors.py +151 -0
  40. ekbatan/events/__init__.py +43 -0
  41. ekbatan/events/_across.py +75 -0
  42. ekbatan/events/config.py +133 -0
  43. ekbatan/events/dispatch.py +386 -0
  44. ekbatan/events/fanout.py +149 -0
  45. ekbatan/events/handler.py +352 -0
  46. ekbatan/events/jobs.py +331 -0
  47. ekbatan/events/loop.py +159 -0
  48. ekbatan/events/notification.py +237 -0
  49. ekbatan/events/repository.py +226 -0
  50. ekbatan/fetching.py +275 -0
  51. ekbatan/ids.py +236 -0
  52. ekbatan/locks/__init__.py +26 -0
  53. ekbatan/locks/_deadlines.py +122 -0
  54. ekbatan/locks/_holder.py +225 -0
  55. ekbatan/locks/base.py +176 -0
  56. ekbatan/locks/redis.py +334 -0
  57. ekbatan/locks/sql.py +385 -0
  58. ekbatan/migrations.py +1849 -0
  59. ekbatan/persistence.py +772 -0
  60. ekbatan/py.typed +0 -0
  61. ekbatan/repository.py +1774 -0
  62. ekbatan/schema.py +459 -0
  63. ekbatan/schemagen/__init__.py +423 -0
  64. ekbatan/schemagen/__main__.py +6 -0
  65. ekbatan/schemagen/_config.py +400 -0
  66. ekbatan/schemagen/_records.py +598 -0
  67. ekbatan/schemagen/_reflect.py +1307 -0
  68. ekbatan/schemagen/_render.py +1637 -0
  69. ekbatan/schemagen/_types.py +596 -0
  70. ekbatan/sharding.py +257 -0
  71. ekbatan/streaming.py +201 -0
  72. ekbatan/testing.py +411 -0
  73. ekbatan/time.py +44 -0
  74. ekbatan-0.1.0.dist-info/METADATA +348 -0
  75. ekbatan-0.1.0.dist-info/RECORD +77 -0
  76. ekbatan-0.1.0.dist-info/WHEEL +4 -0
  77. 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}))
@@ -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
+ )