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 ADDED
File without changes
@@ -0,0 +1,8 @@
1
+ from .base_app import BaseCLIApp
2
+ from .decorators import argument, command
3
+
4
+ __all__ = [
5
+ "BaseCLIApp",
6
+ "argument",
7
+ "command",
8
+ ]
@@ -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
@@ -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
+ )