scaffold-framework 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.
- scaffold/__init__.py +0 -0
- scaffold/cli/__init__.py +8 -0
- scaffold/cli/base_app.py +37 -0
- scaffold/cli/decorators.py +47 -0
- scaffold/di.py +81 -0
- scaffold/persistence.py +109 -0
- scaffold/pub_sub.py +88 -0
- scaffold/py.typed +0 -0
- scaffold/task_queue.py +211 -0
- scaffold/utils.py +66 -0
- scaffold/uuid7.py +47 -0
- scaffold/web/__init__.py +0 -0
- scaffold/web/assets.py +93 -0
- scaffold/web/base_app.py +276 -0
- scaffold/web/base_controller.py +51 -0
- scaffold/web/decorators.py +55 -0
- scaffold/web/dev_server.py +544 -0
- scaffold/web/forms.py +18 -0
- scaffold/web/typing.py +181 -0
- scaffold_framework-0.1.0.dist-info/METADATA +17 -0
- scaffold_framework-0.1.0.dist-info/RECORD +22 -0
- scaffold_framework-0.1.0.dist-info/WHEEL +4 -0
scaffold/__init__.py
ADDED
|
File without changes
|
scaffold/cli/__init__.py
ADDED
scaffold/cli/base_app.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
4
|
+
from .decorators import Command
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BaseCLIApp:
|
|
8
|
+
def __init__(self) -> None:
|
|
9
|
+
self.parser = argparse.ArgumentParser()
|
|
10
|
+
self.subparsers = self.parser.add_subparsers()
|
|
11
|
+
self._register_commands()
|
|
12
|
+
|
|
13
|
+
def _register_commands(self) -> None:
|
|
14
|
+
for attr_name in dir(self):
|
|
15
|
+
attr = getattr(self, attr_name)
|
|
16
|
+
if isinstance(attr, Command):
|
|
17
|
+
subparser = self.subparsers.add_parser(
|
|
18
|
+
attr.command_name,
|
|
19
|
+
help=attr.command_help,
|
|
20
|
+
)
|
|
21
|
+
for args, kwargs in attr.arguments:
|
|
22
|
+
subparser.add_argument(*args, **kwargs)
|
|
23
|
+
subparser.set_defaults(func=attr)
|
|
24
|
+
|
|
25
|
+
def run(self) -> None:
|
|
26
|
+
args = self.parser.parse_args()
|
|
27
|
+
|
|
28
|
+
if hasattr(args, "func"):
|
|
29
|
+
kwargs = dict(vars(args))
|
|
30
|
+
del kwargs["func"]
|
|
31
|
+
result = args.func(self, **kwargs)
|
|
32
|
+
|
|
33
|
+
if asyncio.iscoroutine(result):
|
|
34
|
+
asyncio.run(result)
|
|
35
|
+
|
|
36
|
+
else:
|
|
37
|
+
self.parser.print_help()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Command:
|
|
6
|
+
def __init__(
|
|
7
|
+
self,
|
|
8
|
+
func: Callable,
|
|
9
|
+
name: str = "",
|
|
10
|
+
description: str = "",
|
|
11
|
+
) -> None:
|
|
12
|
+
self.func = func
|
|
13
|
+
self.command_name = name
|
|
14
|
+
self.command_help = description
|
|
15
|
+
self.arguments: list[tuple[tuple[str, ...], dict[str, Any]]] = []
|
|
16
|
+
|
|
17
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
|
18
|
+
return self.func(*args, **kwargs)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def command(
|
|
22
|
+
name: str,
|
|
23
|
+
description: str = "",
|
|
24
|
+
) -> Callable[[Callable | Command], Command]:
|
|
25
|
+
def decorator(f: Callable | Command) -> Command:
|
|
26
|
+
if isinstance(f, Command):
|
|
27
|
+
f.command_name = name
|
|
28
|
+
f.command_help = description
|
|
29
|
+
return f
|
|
30
|
+
|
|
31
|
+
return Command(f, name, description)
|
|
32
|
+
|
|
33
|
+
return decorator
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def argument(
|
|
37
|
+
*args: Any, # noqa: ANN401
|
|
38
|
+
**kwargs: Any, # noqa: ANN401
|
|
39
|
+
) -> Callable[[Callable | Command], Command]:
|
|
40
|
+
def decorator(f: Callable | Command) -> Command:
|
|
41
|
+
if not isinstance(f, Command):
|
|
42
|
+
f = Command(f)
|
|
43
|
+
|
|
44
|
+
f.arguments.append((args, kwargs))
|
|
45
|
+
return f
|
|
46
|
+
|
|
47
|
+
return decorator
|
scaffold/di.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
An extremely simple DI container (in <100 lines of code) with auto-wiring based on type hints.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from typing import Self, cast
|
|
8
|
+
|
|
9
|
+
# Since `type` does not accept abstract classes, we have to use `Callable` as well (although it's not ideal), see https://github.com/python/mypy/issues/4717
|
|
10
|
+
type Dependency[T] = type[T] | Callable[..., T]
|
|
11
|
+
type Provider[T] = type[T] | Callable[["Container"], T]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Container:
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self.providers: dict[Dependency, Provider] = {}
|
|
17
|
+
self.singletons: dict[Dependency, object] = {}
|
|
18
|
+
self.init_functions: list[Callable] = []
|
|
19
|
+
|
|
20
|
+
def add_singleton[T](self, cls: Dependency[T], provider: Provider[T]) -> None:
|
|
21
|
+
self.providers[cls] = provider
|
|
22
|
+
self.singletons[cls] = None
|
|
23
|
+
|
|
24
|
+
def add_transient[T](self, cls: Dependency[T], provider: Provider[T]) -> None:
|
|
25
|
+
self.providers[cls] = provider
|
|
26
|
+
|
|
27
|
+
def add_init_function(
|
|
28
|
+
self,
|
|
29
|
+
init_function: Callable[[Self], Awaitable[None]],
|
|
30
|
+
) -> None:
|
|
31
|
+
self.init_functions.append(init_function)
|
|
32
|
+
|
|
33
|
+
async def init(self) -> None:
|
|
34
|
+
for init_function in self.init_functions:
|
|
35
|
+
await init_function(self)
|
|
36
|
+
|
|
37
|
+
def resolve[T](self, cls: Dependency[T]) -> T:
|
|
38
|
+
instance: T
|
|
39
|
+
|
|
40
|
+
if cls in self.providers:
|
|
41
|
+
provider = self.providers[cls]
|
|
42
|
+
|
|
43
|
+
if cls in self.singletons and self.singletons[cls] is not None:
|
|
44
|
+
return cast(T, self.singletons[cls])
|
|
45
|
+
|
|
46
|
+
if isinstance(provider, type):
|
|
47
|
+
instance = self._instantiate(provider)
|
|
48
|
+
|
|
49
|
+
else:
|
|
50
|
+
instance = provider(self)
|
|
51
|
+
|
|
52
|
+
if cls in self.singletons:
|
|
53
|
+
self.singletons[cls] = instance
|
|
54
|
+
|
|
55
|
+
return instance
|
|
56
|
+
|
|
57
|
+
if isinstance(cls, type):
|
|
58
|
+
return self._instantiate(cls)
|
|
59
|
+
|
|
60
|
+
raise RuntimeError
|
|
61
|
+
|
|
62
|
+
def _instantiate[T](self, cls: type[T]) -> T:
|
|
63
|
+
constructor_signature = inspect.signature(cls.__init__)
|
|
64
|
+
dependencies = {}
|
|
65
|
+
|
|
66
|
+
for name, param in constructor_signature.parameters.items():
|
|
67
|
+
# TODO is there a better way to ignore the "self" param?
|
|
68
|
+
if name == "self" or param.annotation == inspect.Parameter.empty:
|
|
69
|
+
continue # Skip parameters that are not type-annotated or are 'self'
|
|
70
|
+
dependencies[name] = self[param.annotation]
|
|
71
|
+
|
|
72
|
+
return cls(**dependencies)
|
|
73
|
+
|
|
74
|
+
def __getitem__[T](self, cls: Dependency[T]) -> T:
|
|
75
|
+
return self.resolve(cls)
|
|
76
|
+
|
|
77
|
+
def get_factory[C](self, cls: Dependency[C]) -> Callable[[], C]:
|
|
78
|
+
def factory() -> C:
|
|
79
|
+
return self[cls]
|
|
80
|
+
|
|
81
|
+
return factory
|
scaffold/persistence.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from types import TracebackType, get_original_bases
|
|
4
|
+
from typing import Protocol, final, get_args, override
|
|
5
|
+
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EntityId(Protocol):
|
|
10
|
+
@property
|
|
11
|
+
def value(self) -> object: ...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Entity(Protocol):
|
|
15
|
+
@property
|
|
16
|
+
def id(self) -> EntityId: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GenericSqlRepository[E: Entity, ID: EntityId, DTO](abc.ABC):
|
|
20
|
+
dto_class: type[DTO]
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
def __init_subclass__(cls) -> None:
|
|
24
|
+
# TODO Check that the runtime type of the ID type param is the same as the type hint of E.id.
|
|
25
|
+
# Ideally, we would like to do something like `GenericSqlRepository[ID: EntityId, E: Entity[ID], DTO](abc.ABC)`
|
|
26
|
+
# to check it statically but that's currently not possible.
|
|
27
|
+
|
|
28
|
+
cls.dto_class = get_args(get_original_bases(cls)[0])[2]
|
|
29
|
+
|
|
30
|
+
return super().__init_subclass__()
|
|
31
|
+
|
|
32
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
33
|
+
self._session = session
|
|
34
|
+
self._identity_map: dict[EntityId, E] = {}
|
|
35
|
+
|
|
36
|
+
async def get(self, entity_id: ID) -> E | None:
|
|
37
|
+
if entity_id in self._identity_map:
|
|
38
|
+
return self._identity_map[entity_id]
|
|
39
|
+
|
|
40
|
+
dto = await self._session.get(self.dto_class, entity_id.value)
|
|
41
|
+
|
|
42
|
+
if dto is not None:
|
|
43
|
+
return self.map_dto_to_entity_and_track(dto)
|
|
44
|
+
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
def add(self, entity: E) -> None:
|
|
48
|
+
dto = self._map_entity_to_dto(entity)
|
|
49
|
+
self._session.add(dto)
|
|
50
|
+
self._track(entity)
|
|
51
|
+
|
|
52
|
+
async def remove(self, entity: E) -> None:
|
|
53
|
+
dto = self._map_entity_to_dto(entity)
|
|
54
|
+
await self._session.delete(dto)
|
|
55
|
+
self._identity_map.pop(entity.id, None)
|
|
56
|
+
|
|
57
|
+
@final
|
|
58
|
+
def map_dto_to_entity_and_track(self, dto: DTO) -> E:
|
|
59
|
+
entity = self._map_dto_to_entity(dto)
|
|
60
|
+
self._track(entity)
|
|
61
|
+
return entity
|
|
62
|
+
|
|
63
|
+
@abc.abstractmethod
|
|
64
|
+
def _map_entity_to_dto(self, entity: E) -> DTO:
|
|
65
|
+
"""Convert a domain entity to a DTO."""
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
@abc.abstractmethod
|
|
69
|
+
def _map_dto_to_entity(self, dto: DTO) -> E:
|
|
70
|
+
"""Convert a DTO to a domain entity."""
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def _track(self, entity: E) -> None:
|
|
74
|
+
self._identity_map[entity.id] = entity
|
|
75
|
+
|
|
76
|
+
async def sync_state(self) -> None:
|
|
77
|
+
for entity in self._identity_map.values():
|
|
78
|
+
dto = self._map_entity_to_dto(entity)
|
|
79
|
+
await self._session.merge(dto)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class GenericSqlUnitOfWork:
|
|
83
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
84
|
+
self._session = session
|
|
85
|
+
|
|
86
|
+
async def __aenter__(self) -> None:
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
async def __aexit__(
|
|
90
|
+
self,
|
|
91
|
+
exc_type: type,
|
|
92
|
+
exc: BaseException,
|
|
93
|
+
tb: TracebackType,
|
|
94
|
+
) -> None:
|
|
95
|
+
await self.rollback()
|
|
96
|
+
await self._session.close()
|
|
97
|
+
|
|
98
|
+
async def commit(self) -> None:
|
|
99
|
+
for repo in self._repositories:
|
|
100
|
+
await repo.sync_state()
|
|
101
|
+
|
|
102
|
+
await self._session.commit()
|
|
103
|
+
|
|
104
|
+
async def rollback(self) -> None:
|
|
105
|
+
await self._session.rollback()
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def _repositories(self) -> Sequence[GenericSqlRepository]:
|
|
109
|
+
return [value for value in self.__dict__.values() if isinstance(value, GenericSqlRepository)]
|
scaffold/pub_sub.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
|
|
5
|
+
from psycopg import sql
|
|
6
|
+
from psycopg_pool import AsyncConnectionPool
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PostgresPubSubService:
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
connection_pool: AsyncConnectionPool,
|
|
13
|
+
database_channel: str = "pub_sub_messages",
|
|
14
|
+
) -> None:
|
|
15
|
+
self._connection_pool = connection_pool
|
|
16
|
+
self._database_channel = database_channel
|
|
17
|
+
self._subscribers: dict[str, list[asyncio.Queue]] = {}
|
|
18
|
+
self._listener_task: asyncio.Task | None = None
|
|
19
|
+
|
|
20
|
+
async def init(self) -> None:
|
|
21
|
+
await self._connection_pool.open()
|
|
22
|
+
|
|
23
|
+
async def publish(self, channel_name: str, message: str) -> None:
|
|
24
|
+
async with self._connection_pool.connection() as conn:
|
|
25
|
+
payload = json.dumps({"channel_name": channel_name, "message": message})
|
|
26
|
+
await conn.execute(
|
|
27
|
+
sql.SQL("NOTIFY {database_channel}, {payload}").format(
|
|
28
|
+
database_channel=sql.Identifier(self._database_channel),
|
|
29
|
+
payload=sql.Literal(payload),
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
await conn.commit()
|
|
33
|
+
|
|
34
|
+
async def subscribe(self, channel_name: str) -> AsyncGenerator[str, None]:
|
|
35
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
36
|
+
self._subscribers.setdefault(channel_name, []).append(queue)
|
|
37
|
+
|
|
38
|
+
# Start the listener task if it's not already running
|
|
39
|
+
if not self._listener_task or self._listener_task.done():
|
|
40
|
+
self._listener_task = asyncio.create_task(self._listen())
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
while True:
|
|
44
|
+
message = await queue.get()
|
|
45
|
+
yield message
|
|
46
|
+
|
|
47
|
+
finally:
|
|
48
|
+
# Cleanup when the subscriber is done
|
|
49
|
+
self._subscribers[channel_name].remove(queue)
|
|
50
|
+
if not self._subscribers[channel_name]:
|
|
51
|
+
del self._subscribers[channel_name]
|
|
52
|
+
|
|
53
|
+
# Cancel the listener task if no subscribers remain
|
|
54
|
+
if not any(self._subscribers.values()) and self._listener_task:
|
|
55
|
+
self._listener_task.cancel()
|
|
56
|
+
try:
|
|
57
|
+
await self._listener_task
|
|
58
|
+
except asyncio.CancelledError:
|
|
59
|
+
pass # Listener task has been cancelled
|
|
60
|
+
self._listener_task = None
|
|
61
|
+
|
|
62
|
+
async def _listen(self) -> None:
|
|
63
|
+
async with self._connection_pool.connection() as conn:
|
|
64
|
+
await conn.execute(
|
|
65
|
+
sql.SQL("LISTEN {database_channel}").format(
|
|
66
|
+
database_channel=sql.Identifier(self._database_channel),
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
await conn.commit()
|
|
70
|
+
|
|
71
|
+
async for notification in conn.notifies():
|
|
72
|
+
payload = notification.payload
|
|
73
|
+
try:
|
|
74
|
+
# Parse the JSON payload
|
|
75
|
+
data = json.loads(payload)
|
|
76
|
+
channel_name = data.get("channel_name")
|
|
77
|
+
message = data.get("message")
|
|
78
|
+
|
|
79
|
+
if channel_name and message:
|
|
80
|
+
# Dispatch the message to subscribers of the logical channel
|
|
81
|
+
subscribers = self._subscribers.get(channel_name, [])
|
|
82
|
+
for queue in subscribers:
|
|
83
|
+
await queue.put(message)
|
|
84
|
+
|
|
85
|
+
except json.JSONDecodeError:
|
|
86
|
+
# Handle invalid JSON payloads
|
|
87
|
+
# TODO log this?
|
|
88
|
+
continue
|
scaffold/py.typed
ADDED
|
File without changes
|
scaffold/task_queue.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import datetime
|
|
3
|
+
import importlib
|
|
4
|
+
import uuid
|
|
5
|
+
from collections.abc import AsyncGenerator, Callable
|
|
6
|
+
from typing import Protocol, override, runtime_checkable
|
|
7
|
+
|
|
8
|
+
import pydantic
|
|
9
|
+
from psycopg import sql
|
|
10
|
+
from psycopg.rows import dict_row
|
|
11
|
+
from psycopg.types.json import Json
|
|
12
|
+
from psycopg_pool import AsyncConnectionPool
|
|
13
|
+
|
|
14
|
+
from scaffold.uuid7 import uuid7
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@runtime_checkable
|
|
18
|
+
class HandlerProtocol[T](Protocol):
|
|
19
|
+
async def handle_task(self, task: T) -> None: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PostgresTaskQueue[T, H: HandlerProtocol]:
|
|
23
|
+
@override
|
|
24
|
+
def __init_subclass__(cls) -> None:
|
|
25
|
+
# TODO Check that the runtime type of the `T` type param is the same as the type hint of `H.handle_task` `task` param.
|
|
26
|
+
# Ideally, we would like to do something like `GenericFakeTaskQueue[T, H: HandlerProtocol[T]]`
|
|
27
|
+
# to check it statically but that's currently not possible. Now, the `HandlerProtocol`` is not parametrized so it's
|
|
28
|
+
# equivalent to `HandlerProtocol[Any]``.
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
connection_pool: AsyncConnectionPool,
|
|
34
|
+
schema_name: str = "public",
|
|
35
|
+
table_name: str = "task",
|
|
36
|
+
notify_channel_name: str = "task_queue_notifications",
|
|
37
|
+
) -> None:
|
|
38
|
+
self._handler_factories: dict[type[T], Callable[[], H]] = {}
|
|
39
|
+
self._connection_pool = connection_pool
|
|
40
|
+
self._schema_name = schema_name
|
|
41
|
+
self._table_name = table_name
|
|
42
|
+
self._notify_channel_name = notify_channel_name
|
|
43
|
+
|
|
44
|
+
async def init(self) -> None:
|
|
45
|
+
await self._connection_pool.open()
|
|
46
|
+
async with self._connection_pool.connection() as conn:
|
|
47
|
+
# TODO add queue name
|
|
48
|
+
stmt = sql.SQL(
|
|
49
|
+
"""
|
|
50
|
+
CREATE TABLE IF NOT EXISTS {table} (
|
|
51
|
+
id UUID PRIMARY KEY,
|
|
52
|
+
class_name VARCHAR NOT NULL,
|
|
53
|
+
module_name VARCHAR NOT NULL,
|
|
54
|
+
data JSONB NOT NULL,
|
|
55
|
+
enqueued_at TIMESTAMP NOT NULL,
|
|
56
|
+
dequeued_at TIMESTAMP,
|
|
57
|
+
acknowledged_at TIMESTAMP,
|
|
58
|
+
visibility_timeout INTEGER NOT NULL
|
|
59
|
+
)
|
|
60
|
+
""",
|
|
61
|
+
).format(table=self._full_table_identifier)
|
|
62
|
+
# TODO create table task_failure
|
|
63
|
+
await conn.execute(stmt)
|
|
64
|
+
|
|
65
|
+
async def enqueue(
|
|
66
|
+
self,
|
|
67
|
+
task: T,
|
|
68
|
+
visibility_timeout: int = 30,
|
|
69
|
+
) -> None:
|
|
70
|
+
async with self._connection_pool.connection() as conn:
|
|
71
|
+
stmt = sql.SQL(
|
|
72
|
+
"""\
|
|
73
|
+
INSERT INTO {table} (id, class_name, module_name, data, enqueued_at, visibility_timeout)
|
|
74
|
+
VALUES (%s, %s, %s, %s, %s, %s)
|
|
75
|
+
""",
|
|
76
|
+
).format(table=self._full_table_identifier)
|
|
77
|
+
|
|
78
|
+
class_name = task.__class__.__name__
|
|
79
|
+
module_name = task.__class__.__module__
|
|
80
|
+
|
|
81
|
+
# TODO check that the task is a data class
|
|
82
|
+
# dataclasses.is_dataclass(task)
|
|
83
|
+
|
|
84
|
+
# TODO cache type adapters?
|
|
85
|
+
data = Json(
|
|
86
|
+
task,
|
|
87
|
+
dumps=lambda obj: pydantic.TypeAdapter(task.__class__).dump_json(obj),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
await conn.execute(
|
|
91
|
+
stmt,
|
|
92
|
+
(
|
|
93
|
+
str(uuid7()),
|
|
94
|
+
class_name,
|
|
95
|
+
module_name,
|
|
96
|
+
data,
|
|
97
|
+
datetime.datetime.utcnow(),
|
|
98
|
+
visibility_timeout,
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
await conn.execute(
|
|
102
|
+
sql.SQL("NOTIFY {channel_name}").format(
|
|
103
|
+
channel_name=sql.Identifier(self._notify_channel_name),
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
await conn.commit()
|
|
107
|
+
|
|
108
|
+
async def handle_task(self, task_id: uuid.UUID, task: T) -> None:
|
|
109
|
+
# TODO handle exceptions
|
|
110
|
+
handler = self._handler_factories[type(task)]()
|
|
111
|
+
# TODO check if whether the handler is a coroutine function
|
|
112
|
+
await handler.handle_task(task)
|
|
113
|
+
await self.ack(task_id)
|
|
114
|
+
|
|
115
|
+
async def handle_tasks(self) -> None:
|
|
116
|
+
async with asyncio.TaskGroup() as tg:
|
|
117
|
+
async for task_data in self._tasks:
|
|
118
|
+
task_id = task_data["id"]
|
|
119
|
+
# TODO cache this
|
|
120
|
+
task_module = importlib.import_module(task_data["module_name"])
|
|
121
|
+
task_class = getattr(task_module, task_data["class_name"])
|
|
122
|
+
task = pydantic.TypeAdapter(task_class).validate_python(
|
|
123
|
+
task_data["data"],
|
|
124
|
+
)
|
|
125
|
+
tg.create_task(self.handle_task(task_id, task))
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
async def _tasks(self) -> AsyncGenerator[dict]:
|
|
129
|
+
async with self._connection_pool.connection() as listen_conn:
|
|
130
|
+
await listen_conn.execute(
|
|
131
|
+
sql.SQL("LISTEN {channel_name}").format(
|
|
132
|
+
channel_name=sql.Identifier(self._notify_channel_name),
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
await listen_conn.commit()
|
|
136
|
+
|
|
137
|
+
while True:
|
|
138
|
+
task = await self._get_task()
|
|
139
|
+
if task:
|
|
140
|
+
yield task
|
|
141
|
+
else:
|
|
142
|
+
# We are doing long polling as well because there's no notification when a message times out
|
|
143
|
+
async for _ in listen_conn.notifies(timeout=1):
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
async def _get_task(self) -> dict | None:
|
|
147
|
+
async with self._connection_pool.connection() as conn:
|
|
148
|
+
cursor = conn.cursor(row_factory=dict_row)
|
|
149
|
+
cursor = await cursor.execute(
|
|
150
|
+
sql.SQL(
|
|
151
|
+
"""\
|
|
152
|
+
UPDATE {table}
|
|
153
|
+
SET dequeued_at = NOW()
|
|
154
|
+
WHERE id = (
|
|
155
|
+
SELECT
|
|
156
|
+
id
|
|
157
|
+
FROM
|
|
158
|
+
{table}
|
|
159
|
+
WHERE
|
|
160
|
+
acknowledged_at IS NULL
|
|
161
|
+
AND (dequeued_at IS NULL OR dequeued_at < NOW() - make_interval(secs => visibility_timeout))
|
|
162
|
+
ORDER BY
|
|
163
|
+
enqueued_at
|
|
164
|
+
FOR UPDATE SKIP LOCKED
|
|
165
|
+
LIMIT
|
|
166
|
+
1
|
|
167
|
+
)
|
|
168
|
+
RETURNING id, class_name, module_name, data
|
|
169
|
+
""",
|
|
170
|
+
).format(table=self._full_table_identifier),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return await cursor.fetchone()
|
|
174
|
+
|
|
175
|
+
async def ack(self, task_id: uuid.UUID) -> None:
|
|
176
|
+
async with self._connection_pool.connection() as conn:
|
|
177
|
+
await conn.execute(
|
|
178
|
+
sql.SQL(
|
|
179
|
+
"UPDATE {table} SET acknowledged_at = NOW() WHERE id = %s",
|
|
180
|
+
).format(
|
|
181
|
+
table=self._full_table_identifier,
|
|
182
|
+
),
|
|
183
|
+
(task_id,),
|
|
184
|
+
)
|
|
185
|
+
await conn.commit()
|
|
186
|
+
|
|
187
|
+
def register(self, task_type: type[T], handler_factory: Callable[[], H]) -> None:
|
|
188
|
+
self._handler_factories[task_type] = handler_factory
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def _full_table_identifier(self) -> sql.Identifier:
|
|
192
|
+
return sql.Identifier(self._schema_name, self._table_name)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class GenericFakeTaskQueue[T, H: HandlerProtocol]:
|
|
196
|
+
def __init__(
|
|
197
|
+
self,
|
|
198
|
+
) -> None:
|
|
199
|
+
self.handler_factories: dict[type[T], Callable[[], H]] = {}
|
|
200
|
+
self.queue: list[T] = []
|
|
201
|
+
|
|
202
|
+
def enqueue(self, task: T) -> None:
|
|
203
|
+
self.queue.append(task)
|
|
204
|
+
|
|
205
|
+
def register(self, task_type: type[T], handler_factory: Callable[[], H]) -> None:
|
|
206
|
+
self.handler_factories[task_type] = handler_factory
|
|
207
|
+
|
|
208
|
+
async def run(self) -> None:
|
|
209
|
+
for task in self.queue:
|
|
210
|
+
handler = self.handler_factories[type(task)]()
|
|
211
|
+
await handler.handle_task(task)
|
scaffold/utils.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import os
|
|
3
|
+
import pkgutil
|
|
4
|
+
from importlib import import_module
|
|
5
|
+
from types import ModuleType
|
|
6
|
+
from typing import cast
|
|
7
|
+
from urllib.parse import urlparse, urlunparse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def find_subclasses[T: type](package: ModuleType, base_class: T) -> list[T]:
|
|
11
|
+
subclasses: list[T] = []
|
|
12
|
+
|
|
13
|
+
for _, subpath, ispkg in pkgutil.iter_modules(package.__path__):
|
|
14
|
+
fullpath = package.__name__ + "." + subpath
|
|
15
|
+
module = import_module(fullpath)
|
|
16
|
+
if ispkg:
|
|
17
|
+
subclasses += find_subclasses(module, base_class)
|
|
18
|
+
for _, obj in inspect.getmembers(module):
|
|
19
|
+
if (
|
|
20
|
+
inspect.isclass(obj)
|
|
21
|
+
and issubclass(obj, base_class)
|
|
22
|
+
and obj != base_class
|
|
23
|
+
and obj.__module__ == module.__name__
|
|
24
|
+
):
|
|
25
|
+
subclasses.append(cast(T, obj))
|
|
26
|
+
|
|
27
|
+
return subclasses
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_env_flag(variable_name: str) -> bool:
|
|
31
|
+
val = os.environ.get(variable_name)
|
|
32
|
+
return bool(val and val.lower() not in {"0", "false", "no"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_postgresql_url(for_sqlalchemy: bool = False) -> str:
|
|
36
|
+
# Get the base URL from the environment variable
|
|
37
|
+
base_url = os.environ.get("DATABASE_URL")
|
|
38
|
+
|
|
39
|
+
if not base_url:
|
|
40
|
+
message = "DATABASE_URL environment variable is not set"
|
|
41
|
+
raise ValueError(message)
|
|
42
|
+
|
|
43
|
+
# Parse the URL
|
|
44
|
+
parsed = urlparse(base_url)
|
|
45
|
+
|
|
46
|
+
# Assert that the scheme is postgresql
|
|
47
|
+
assert parsed.scheme == "postgresql", "DATABASE_URL must use the postgresql:// scheme"
|
|
48
|
+
|
|
49
|
+
if for_sqlalchemy:
|
|
50
|
+
# Append the driver name for SQLAlchemy
|
|
51
|
+
new_scheme = "postgresql+psycopg"
|
|
52
|
+
else:
|
|
53
|
+
# Keep the original postgresql scheme
|
|
54
|
+
new_scheme = "postgresql"
|
|
55
|
+
|
|
56
|
+
# Reconstruct the URL with the new scheme
|
|
57
|
+
return urlunparse(
|
|
58
|
+
(
|
|
59
|
+
new_scheme,
|
|
60
|
+
parsed.netloc,
|
|
61
|
+
parsed.path,
|
|
62
|
+
parsed.params,
|
|
63
|
+
parsed.query,
|
|
64
|
+
parsed.fragment,
|
|
65
|
+
),
|
|
66
|
+
)
|