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.
Files changed (30) hide show
  1. scaffold_framework-0.1.0/.github/workflows/ci.yml +64 -0
  2. scaffold_framework-0.1.0/.gitignore +10 -0
  3. scaffold_framework-0.1.0/.pre-commit-config.yaml +16 -0
  4. scaffold_framework-0.1.0/.python-version +1 -0
  5. scaffold_framework-0.1.0/.vscode/extensions.json +10 -0
  6. scaffold_framework-0.1.0/.vscode/settings.json +11 -0
  7. scaffold_framework-0.1.0/PKG-INFO +17 -0
  8. scaffold_framework-0.1.0/README.md +1 -0
  9. scaffold_framework-0.1.0/pyproject.toml +73 -0
  10. scaffold_framework-0.1.0/src/scaffold/__init__.py +0 -0
  11. scaffold_framework-0.1.0/src/scaffold/cli/__init__.py +8 -0
  12. scaffold_framework-0.1.0/src/scaffold/cli/base_app.py +37 -0
  13. scaffold_framework-0.1.0/src/scaffold/cli/decorators.py +47 -0
  14. scaffold_framework-0.1.0/src/scaffold/di.py +81 -0
  15. scaffold_framework-0.1.0/src/scaffold/persistence.py +109 -0
  16. scaffold_framework-0.1.0/src/scaffold/pub_sub.py +88 -0
  17. scaffold_framework-0.1.0/src/scaffold/py.typed +0 -0
  18. scaffold_framework-0.1.0/src/scaffold/task_queue.py +211 -0
  19. scaffold_framework-0.1.0/src/scaffold/utils.py +66 -0
  20. scaffold_framework-0.1.0/src/scaffold/uuid7.py +47 -0
  21. scaffold_framework-0.1.0/src/scaffold/web/__init__.py +0 -0
  22. scaffold_framework-0.1.0/src/scaffold/web/assets.py +93 -0
  23. scaffold_framework-0.1.0/src/scaffold/web/base_app.py +276 -0
  24. scaffold_framework-0.1.0/src/scaffold/web/base_controller.py +51 -0
  25. scaffold_framework-0.1.0/src/scaffold/web/decorators.py +55 -0
  26. scaffold_framework-0.1.0/src/scaffold/web/dev_server.py +544 -0
  27. scaffold_framework-0.1.0/src/scaffold/web/forms.py +18 -0
  28. scaffold_framework-0.1.0/src/scaffold/web/typing.py +181 -0
  29. scaffold_framework-0.1.0/tests/test_app.py +27 -0
  30. 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,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -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,10 @@
1
+ {
2
+ "recommendations": [
3
+ "ms-python.python",
4
+ "ms-python.debugpy",
5
+ "ms-python.vscode-pylance",
6
+ "ms-python.mypy-type-checker",
7
+ "ms-python.black-formatter",
8
+ "charliermarsh.ruff"
9
+ ]
10
+ }
@@ -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,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
@@ -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