scaffold-framework 0.1.0__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.
- scaffold_framework-0.1.0/.github/workflows/ci.yml +64 -0
- scaffold_framework-0.1.0/.gitignore +10 -0
- scaffold_framework-0.1.0/.pre-commit-config.yaml +16 -0
- scaffold_framework-0.1.0/.python-version +1 -0
- scaffold_framework-0.1.0/.vscode/extensions.json +10 -0
- scaffold_framework-0.1.0/.vscode/settings.json +11 -0
- scaffold_framework-0.1.0/PKG-INFO +17 -0
- scaffold_framework-0.1.0/README.md +1 -0
- scaffold_framework-0.1.0/pyproject.toml +73 -0
- scaffold_framework-0.1.0/src/scaffold/__init__.py +0 -0
- scaffold_framework-0.1.0/src/scaffold/cli/__init__.py +8 -0
- scaffold_framework-0.1.0/src/scaffold/cli/base_app.py +37 -0
- scaffold_framework-0.1.0/src/scaffold/cli/decorators.py +47 -0
- scaffold_framework-0.1.0/src/scaffold/di.py +81 -0
- scaffold_framework-0.1.0/src/scaffold/persistence.py +109 -0
- scaffold_framework-0.1.0/src/scaffold/pub_sub.py +88 -0
- scaffold_framework-0.1.0/src/scaffold/py.typed +0 -0
- scaffold_framework-0.1.0/src/scaffold/task_queue.py +211 -0
- scaffold_framework-0.1.0/src/scaffold/utils.py +66 -0
- scaffold_framework-0.1.0/src/scaffold/uuid7.py +47 -0
- scaffold_framework-0.1.0/src/scaffold/web/__init__.py +0 -0
- scaffold_framework-0.1.0/src/scaffold/web/assets.py +93 -0
- scaffold_framework-0.1.0/src/scaffold/web/base_app.py +276 -0
- scaffold_framework-0.1.0/src/scaffold/web/base_controller.py +51 -0
- scaffold_framework-0.1.0/src/scaffold/web/decorators.py +55 -0
- scaffold_framework-0.1.0/src/scaffold/web/dev_server.py +544 -0
- scaffold_framework-0.1.0/src/scaffold/web/forms.py +18 -0
- scaffold_framework-0.1.0/src/scaffold/web/typing.py +181 -0
- scaffold_framework-0.1.0/tests/test_app.py +27 -0
- scaffold_framework-0.1.0/uv.lock +694 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
name: CI/CD
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- "**"
|
|
7
|
+
tags:
|
|
8
|
+
- "v*.*.*"
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
qa:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- name: Install uv
|
|
18
|
+
uses: astral-sh/setup-uv@v3
|
|
19
|
+
|
|
20
|
+
- name: "Set up Python"
|
|
21
|
+
uses: actions/setup-python@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version-file: ".python-version"
|
|
24
|
+
|
|
25
|
+
- name: Install the project
|
|
26
|
+
run: uv sync --all-extras --dev
|
|
27
|
+
|
|
28
|
+
- name: Run mypy
|
|
29
|
+
run: uv run mypy .
|
|
30
|
+
|
|
31
|
+
- name: Run ruff
|
|
32
|
+
run: uv run ruff check
|
|
33
|
+
|
|
34
|
+
- name: Run tests
|
|
35
|
+
run: uv run pytest
|
|
36
|
+
|
|
37
|
+
publish:
|
|
38
|
+
needs: qa
|
|
39
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
environment:
|
|
42
|
+
name: pypi
|
|
43
|
+
url: https://pypi.org/p/scaffold-framework
|
|
44
|
+
permissions:
|
|
45
|
+
id-token: write
|
|
46
|
+
|
|
47
|
+
steps:
|
|
48
|
+
- uses: actions/checkout@v4
|
|
49
|
+
with:
|
|
50
|
+
fetch-depth: 0
|
|
51
|
+
|
|
52
|
+
- name: Install uv
|
|
53
|
+
uses: astral-sh/setup-uv@v3
|
|
54
|
+
|
|
55
|
+
- name: Set up Python
|
|
56
|
+
uses: actions/setup-python@v5
|
|
57
|
+
with:
|
|
58
|
+
python-version-file: ".python-version"
|
|
59
|
+
|
|
60
|
+
- name: Build package
|
|
61
|
+
run: uv build
|
|
62
|
+
|
|
63
|
+
- name: Publish to PyPI
|
|
64
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
3
|
+
rev: v0.3.2
|
|
4
|
+
hooks:
|
|
5
|
+
# Run the linter.
|
|
6
|
+
- id: ruff
|
|
7
|
+
args: [ --fix ]
|
|
8
|
+
# Using this mirror lets us use mypyc-compiled black, which is about 2x faster
|
|
9
|
+
- repo: https://github.com/psf/black-pre-commit-mirror
|
|
10
|
+
rev: 24.2.0
|
|
11
|
+
hooks:
|
|
12
|
+
- id: black
|
|
13
|
+
- repo: https://github.com/pre-commit/mirrors-mypy
|
|
14
|
+
rev: v1.13.0
|
|
15
|
+
hooks:
|
|
16
|
+
- id: mypy
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
// "python.defaultInterpreterPath": "/venv/bin/python",
|
|
3
|
+
"editor.formatOnSave": true,
|
|
4
|
+
// "python.testing.pytestEnabled": true,
|
|
5
|
+
"mypy-type-checker.importStrategy": "fromEnvironment",
|
|
6
|
+
"mypy-type-checker.reportingScope": "workspace",
|
|
7
|
+
"[python]": {
|
|
8
|
+
"editor.defaultFormatter": "ms-python.black-formatter",
|
|
9
|
+
"editor.formatOnSave": true
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: scaffold-framework
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A web framework that make it easy to build apps according to SOLID & DDD principles and with layered/onion architecture.
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: alembic>=1.13.3
|
|
7
|
+
Requires-Dist: h11>=0.14.0
|
|
8
|
+
Requires-Dist: psycopg[binary,pool]>=3.2.3
|
|
9
|
+
Requires-Dist: pydantic>=2.9.2
|
|
10
|
+
Requires-Dist: quart>=0.19.9
|
|
11
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.35
|
|
12
|
+
Requires-Dist: watchfiles>=0.24.0
|
|
13
|
+
Requires-Dist: wsproto>=1.2.0
|
|
14
|
+
Requires-Dist: wtforms>=3.1.2
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Scaffold
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Scaffold
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "scaffold-framework"
|
|
3
|
+
description = "A web framework that make it easy to build apps according to SOLID & DDD principles and with layered/onion architecture."
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"alembic>=1.13.3",
|
|
8
|
+
"h11>=0.14.0",
|
|
9
|
+
"psycopg[binary,pool]>=3.2.3",
|
|
10
|
+
"pydantic>=2.9.2",
|
|
11
|
+
"quart>=0.19.9",
|
|
12
|
+
"sqlalchemy[asyncio]>=2.0.35",
|
|
13
|
+
"watchfiles>=0.24.0",
|
|
14
|
+
"wsproto>=1.2.0",
|
|
15
|
+
"wtforms>=3.1.2",
|
|
16
|
+
]
|
|
17
|
+
dynamic = ["version"]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling", "hatch-vcs", "hatch-fancy-pypi-readme"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.version]
|
|
24
|
+
source = "vcs"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.metadata.hooks.fancy-pypi-readme]
|
|
27
|
+
content-type = "text/markdown"
|
|
28
|
+
|
|
29
|
+
[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
|
|
30
|
+
path = "README.md"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/scaffold"]
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = ["mypy>=1.13.0", "pytest-asyncio>=0.24.0", "pytest>=8.3.3", "ruff>=0.8.0"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
addopts = ["--import-mode=importlib"]
|
|
41
|
+
|
|
42
|
+
[tool.mypy]
|
|
43
|
+
disable_error_code = "import-untyped"
|
|
44
|
+
enable_error_code = "explicit-override"
|
|
45
|
+
disallow_untyped_defs = true
|
|
46
|
+
# disallow_any_generics = true
|
|
47
|
+
warn_unreachable = true
|
|
48
|
+
|
|
49
|
+
[tool.black]
|
|
50
|
+
line-length = 120
|
|
51
|
+
unstable = true
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
target-version = "py312"
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
extend-select = [
|
|
58
|
+
"ANN",
|
|
59
|
+
"ASYNC",
|
|
60
|
+
"S",
|
|
61
|
+
"BLE",
|
|
62
|
+
"B",
|
|
63
|
+
"A",
|
|
64
|
+
"COM",
|
|
65
|
+
"C4",
|
|
66
|
+
"T10",
|
|
67
|
+
"EM",
|
|
68
|
+
"RET",
|
|
69
|
+
"SLF",
|
|
70
|
+
"I",
|
|
71
|
+
"UP",
|
|
72
|
+
]
|
|
73
|
+
extend-ignore = ["A002", "ANN101", "ANN102", "B010", "S101"]
|
|
File without changes
|
|
@@ -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
|
|
@@ -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)]
|
|
@@ -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
|
|
File without changes
|