hajimi-sqlalchemy 0.0.1__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 huruhi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: hajimi-sqlalchemy
3
+ Version: 0.0.1
4
+ Requires-Python: >=3.14
5
+ License-File: LICENSE
6
+ Requires-Dist: hajimi-core>=0.1.0
7
+ Requires-Dist: sqlalchemy[asyncio,postgresql-psycopg]>=2.0.49
8
+ Dynamic: license-file
@@ -0,0 +1,7 @@
1
+ # hajimi-core
2
+
3
+ ## Install
4
+
5
+ ```bash
6
+ pip install hajimi-sqlalchemy
7
+ ```
@@ -0,0 +1,4 @@
1
+ from .base import BaseModel
2
+ from .config import HajimiSQLAlchemyConfig
3
+ from .dependencies import SessionDep
4
+ from .plugin import HajimiSQLAlchemyPlugin
@@ -0,0 +1,5 @@
1
+ from sqlalchemy.orm import DeclarativeBase
2
+
3
+
4
+ class BaseModel(DeclarativeBase):
5
+ __abstract__ = True
@@ -0,0 +1,29 @@
1
+ from dataclasses import dataclass
2
+
3
+ from sqlalchemy import URL
4
+
5
+
6
+ @dataclass(frozen=True, kw_only=True)
7
+ class HajimiSQLAlchemyConfig:
8
+ username: str
9
+ password: str
10
+ host: str
11
+ port: int = 5432
12
+ database: str
13
+
14
+ echo: bool = False
15
+ pool_size: int = 3
16
+ max_overflow: int = 2
17
+ pool_timeout: int = 30
18
+ pool_recycle: int = 1800
19
+
20
+ @property
21
+ def url(self) -> URL:
22
+ return URL.create(
23
+ drivername='postgresql+psycopg',
24
+ username=self.username,
25
+ password=self.password,
26
+ host=self.host,
27
+ port=self.port,
28
+ database=self.database,
29
+ )
@@ -0,0 +1,20 @@
1
+ """FastAPI dependencies exposing the SQLAlchemy async session."""
2
+
3
+ from typing import Annotated, AsyncGenerator
4
+
5
+ from fastapi import Depends, Request
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from .plugin import HajimiSQLAlchemyPlugin
9
+
10
+
11
+ async def get_session(request: Request) -> AsyncGenerator[AsyncSession, None]:
12
+ """Yield an ``AsyncSession`` scoped to the request, opened from the registered plugin."""
13
+ app_state = request.app.state._state['__app_state__']
14
+ plugin: HajimiSQLAlchemyPlugin = getattr(app_state, HajimiSQLAlchemyPlugin.__name__)
15
+
16
+ async with plugin.session_maker.begin() as session:
17
+ yield session
18
+
19
+
20
+ SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -0,0 +1,44 @@
1
+ from fastapi import FastAPI
2
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
3
+
4
+ from .config import HajimiSQLAlchemyConfig
5
+
6
+
7
+ class HajimiSQLAlchemyPlugin:
8
+ def __init__(self, config: HajimiSQLAlchemyConfig) -> None:
9
+ self._config = config
10
+ self._engine: AsyncEngine | None = None
11
+ self._session_maker: async_sessionmaker[AsyncSession] | None = None
12
+
13
+ @property
14
+ def session_maker(self) -> async_sessionmaker[AsyncSession]:
15
+ if not isinstance(self._session_maker, async_sessionmaker):
16
+ raise RuntimeError(
17
+ 'HajimiSQLAlchemyPlugin.session_maker is unavailable: '
18
+ 'startup() has not been called or shutdown() has already run.'
19
+ )
20
+
21
+ return self._session_maker
22
+
23
+ async def startup(self, app: FastAPI) -> None:
24
+ self._engine = create_async_engine(
25
+ url=self._config.url,
26
+ echo=self._config.echo,
27
+ pool_size=self._config.pool_size,
28
+ max_overflow=self._config.max_overflow,
29
+ pool_timeout=self._config.pool_timeout,
30
+ pool_recycle=self._config.pool_recycle,
31
+ pool_pre_ping=True,
32
+ connect_args={'connect_timeout': 6},
33
+ )
34
+ self._session_maker = async_sessionmaker(engine=self._engine)
35
+ app.state.__app__state[self.__name__] = self
36
+
37
+ async def shutdown(self, app: FastAPI) -> None:
38
+ if self._session_maker is not None:
39
+ self._session_maker = None
40
+ if self._engine is not None:
41
+ await self._engine.dispose()
42
+ self._engine = None
43
+
44
+ app.state.__app__state.pop(self.__name__, None)
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: hajimi-sqlalchemy
3
+ Version: 0.0.1
4
+ Requires-Python: >=3.14
5
+ License-File: LICENSE
6
+ Requires-Dist: hajimi-core>=0.1.0
7
+ Requires-Dist: sqlalchemy[asyncio,postgresql-psycopg]>=2.0.49
8
+ Dynamic: license-file
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ hajimi_sqlalchemy/__init__.py
5
+ hajimi_sqlalchemy/base.py
6
+ hajimi_sqlalchemy/config.py
7
+ hajimi_sqlalchemy/dependencies.py
8
+ hajimi_sqlalchemy/plugin.py
9
+ hajimi_sqlalchemy.egg-info/PKG-INFO
10
+ hajimi_sqlalchemy.egg-info/SOURCES.txt
11
+ hajimi_sqlalchemy.egg-info/dependency_links.txt
12
+ hajimi_sqlalchemy.egg-info/requires.txt
13
+ hajimi_sqlalchemy.egg-info/top_level.txt
14
+ tests/test_base.py
15
+ tests/test_config.py
16
+ tests/test_dependencies.py
17
+ tests/test_init.py
18
+ tests/test_plugin.py
@@ -0,0 +1,2 @@
1
+ hajimi-core>=0.1.0
2
+ sqlalchemy[asyncio,postgresql-psycopg]>=2.0.49
@@ -0,0 +1 @@
1
+ hajimi_sqlalchemy
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hajimi-sqlalchemy"
7
+ version = "0.0.1"
8
+ requires-python = ">=3.14"
9
+ dependencies = [
10
+ "hajimi-core>=0.1.0",
11
+ "sqlalchemy[asyncio,postgresql-psycopg]>=2.0.49",
12
+ ]
13
+
14
+ [dependency-groups]
15
+ dev = [
16
+ "pytest>=9.0.2",
17
+ "pytest-cov>=7.1.0",
18
+ "pytest-asyncio>=0.25.0",
19
+ ]
20
+
21
+ [tool.pytest.ini_options]
22
+ asyncio_mode = "auto"
23
+ pythonpath = ["."]
24
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,40 @@
1
+ """Tests for hajimi_sqlalchemy.base.BaseModel."""
2
+
3
+ from sqlalchemy import Column, Integer, String
4
+ from sqlalchemy.orm import DeclarativeBase
5
+
6
+ from hajimi_sqlalchemy.base import BaseModel
7
+
8
+
9
+ def test_base_model_is_subclass_of_declarative_base():
10
+ """BaseModel must inherit from SQLAlchemy's DeclarativeBase."""
11
+
12
+ assert isinstance(BaseModel, type)
13
+ assert issubclass(BaseModel, DeclarativeBase)
14
+
15
+
16
+ def test_base_model_is_marked_abstract():
17
+ """``__abstract__ = True`` so BaseModel never produces a table of its own."""
18
+
19
+ assert BaseModel.__abstract__ is True
20
+
21
+
22
+ def test_base_model_carries_metadata():
23
+ """BaseModel exposes a SQLAlchemy ``MetaData`` instance for subclasses to share."""
24
+
25
+ # DeclarativeBase always carries a MetaData attribute.
26
+ assert hasattr(BaseModel, 'metadata')
27
+
28
+
29
+ def test_concrete_subclass_uses_base_model_metadata():
30
+ """A concrete subclass should be wired through BaseModel.metadata."""
31
+
32
+ class Item(BaseModel):
33
+ __tablename__ = 'items_for_base_model_test'
34
+ id = Column(Integer, primary_key=True)
35
+ name = Column(String)
36
+
37
+ # The subclass must share BaseModel's MetaData.
38
+ assert Item.metadata is BaseModel.metadata
39
+ # And SQLAlchemy's declarative machinery has registered the table.
40
+ assert 'items_for_base_model_test' in BaseModel.metadata.tables
@@ -0,0 +1,121 @@
1
+ """Tests for hajimi_sqlalchemy.config.HajimiSQLAlchemyConfig."""
2
+
3
+ import dataclasses
4
+
5
+ import pytest
6
+ from sqlalchemy import URL
7
+
8
+ from hajimi_sqlalchemy.config import HajimiSQLAlchemyConfig
9
+
10
+
11
+ def _base(**overrides):
12
+ """Build a config with sensible defaults, applying caller overrides."""
13
+
14
+ defaults = dict(
15
+ username='u',
16
+ password='p',
17
+ host='localhost',
18
+ port=5432,
19
+ database='d',
20
+ echo=False,
21
+ pool_size=3,
22
+ max_overflow=2,
23
+ pool_timeout=30,
24
+ pool_recycle=1800,
25
+ )
26
+ defaults.update(overrides)
27
+ return HajimiSQLAlchemyConfig(**defaults)
28
+
29
+
30
+ def test_defaults_when_only_required_fields_given():
31
+ """Defaults are applied for every optional connection / pool field."""
32
+
33
+ cfg = HajimiSQLAlchemyConfig(username='u', password='p', host='h', database='d')
34
+
35
+ assert cfg.username == 'u'
36
+ assert cfg.password == 'p'
37
+ assert cfg.host == 'h'
38
+ assert cfg.port == 5432
39
+ assert cfg.database == 'd'
40
+
41
+ assert cfg.echo is False
42
+ assert cfg.pool_size == 3
43
+ assert cfg.max_overflow == 2
44
+ assert cfg.pool_timeout == 30
45
+ assert cfg.pool_recycle == 1800
46
+
47
+
48
+ def test_custom_values_override_defaults():
49
+ """Every optional field can be supplied explicitly."""
50
+
51
+ cfg = _base(port=6543, echo=True, pool_size=11, max_overflow=4, pool_timeout=12, pool_recycle=900)
52
+
53
+ assert cfg.port == 6543
54
+ assert cfg.echo is True
55
+ assert cfg.pool_size == 11
56
+ assert cfg.max_overflow == 4
57
+ assert cfg.pool_timeout == 12
58
+ assert cfg.pool_recycle == 900
59
+
60
+
61
+ def test_url_property_uses_custom_port():
62
+ """The SQLAlchemy URL reflects every connection parameter including a custom port."""
63
+
64
+ cfg = _base(
65
+ username='alice',
66
+ password='s3cret',
67
+ host='db.example.com',
68
+ port=6543,
69
+ database='appdb',
70
+ )
71
+
72
+ url = cfg.url
73
+
74
+ assert isinstance(url, URL)
75
+ assert url.drivername == 'postgresql+psycopg'
76
+ assert url.username == 'alice'
77
+ assert url.password == 's3cret'
78
+ assert url.host == 'db.example.com'
79
+ assert url.port == 6543
80
+ assert url.database == 'appdb'
81
+
82
+
83
+ def test_url_property_uses_default_port():
84
+ """When port is left at the default, the URL still carries it through."""
85
+
86
+ cfg = HajimiSQLAlchemyConfig(username='u', password='p', host='db', database='d')
87
+
88
+ url = cfg.url
89
+
90
+ assert url.port == 5432
91
+ assert url.drivername == 'postgresql+psycopg'
92
+
93
+
94
+ def test_url_property_is_recomputed_each_call():
95
+ """Each access returns a fresh URL object reflecting the current field values."""
96
+
97
+ cfg = _base(host='first')
98
+ first = cfg.url
99
+
100
+ cfg2 = dataclasses.replace(cfg, host='second')
101
+ second = cfg2.url
102
+
103
+ assert first.host == 'first'
104
+ assert second.host == 'second'
105
+
106
+
107
+ def test_instance_is_frozen():
108
+ """The dataclass is frozen: assignments raise FrozenInstanceError."""
109
+
110
+ cfg = HajimiSQLAlchemyConfig(username='u', password='p', host='h', database='d')
111
+
112
+ with pytest.raises(dataclasses.FrozenInstanceError):
113
+ cfg.username = 'new'
114
+
115
+
116
+ def test_config_is_kw_only():
117
+ """Positional arguments are rejected because the dataclass is kw_only."""
118
+
119
+ with pytest.raises(TypeError):
120
+ # Intentionally pass positional args; should raise because of kw_only=True.
121
+ HajimiSQLAlchemyConfig('u', 'p', 'h', 5432, 'd')
@@ -0,0 +1,65 @@
1
+ """Tests for hajimi_sqlalchemy.dependencies."""
2
+
3
+ import typing
4
+
5
+ import pytest
6
+ from fastapi import FastAPI
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from hajimi_sqlalchemy.config import HajimiSQLAlchemyConfig
10
+ from hajimi_sqlalchemy.dependencies import SessionDep, get_session
11
+ from hajimi_sqlalchemy.plugin import HajimiSQLAlchemyPlugin
12
+
13
+
14
+ class _Namespace:
15
+ """Container that holds the plugin attribute under ``HajimiSQLAlchemyPlugin.__name__``."""
16
+
17
+
18
+ def _make_request(app):
19
+ """Minimal ``Request``-shaped object exposing ``app``."""
20
+
21
+ class _Request:
22
+ def __init__(self, app):
23
+ self.app = app
24
+
25
+ return _Request(app)
26
+
27
+
28
+ async def test_get_session_yields_session_and_finishes(fake_session, fake_session_factory):
29
+ """``get_session`` must yield the session from ``session_maker.begin()`` then exit cleanly."""
30
+
31
+ cfg = HajimiSQLAlchemyConfig(username='u', password='p', host='localhost', database='d')
32
+ plugin = HajimiSQLAlchemyPlugin(cfg)
33
+ # Bypass real startup() — this test focuses on dependencies.get_session itself.
34
+ plugin._session_maker = fake_session_factory
35
+
36
+ namespace = _Namespace()
37
+ setattr(namespace, HajimiSQLAlchemyPlugin.__name__, plugin)
38
+
39
+ app = FastAPI()
40
+ # Seed starlette's underlying state dict with the exact key dependencies.py looks up.
41
+ app.state._state['__app_state__'] = namespace
42
+
43
+ request = _make_request(app)
44
+
45
+ gen = get_session(request)
46
+
47
+ # First __anext__ enters the ``async with`` and yields the session.
48
+ session = await gen.__anext__()
49
+ assert session is fake_session
50
+
51
+ # Second __anext__ runs the cleanup path (yield -> line 13) and the generator returns.
52
+ with pytest.raises(StopAsyncIteration):
53
+ await gen.__anext__()
54
+
55
+
56
+ def test_session_dep_is_annotated_async_session():
57
+ """``SessionDep`` is an ``Annotated`` alias whose first type-arg is ``AsyncSession``."""
58
+
59
+ origin = typing.get_origin(SessionDep)
60
+ args = typing.get_args(SessionDep)
61
+
62
+ # Annotated[...] has a non-None typing origin.
63
+ assert origin is not None
64
+ # The first type-arg of Annotated[..., Depends(...)] should be AsyncSession.
65
+ assert args[0] is AsyncSession
@@ -0,0 +1,28 @@
1
+ """Tests for the top-level ``hajimi_sqlalchemy`` package."""
2
+
3
+ import hajimi_sqlalchemy
4
+ from hajimi_sqlalchemy import BaseModel, HajimiSQLAlchemyConfig, HajimiSQLAlchemyPlugin
5
+
6
+
7
+ def test_package_imports():
8
+ """The package must import without side effects and expose ``__name__``."""
9
+
10
+ assert hajimi_sqlalchemy.__name__ == 'hajimi_sqlalchemy'
11
+
12
+
13
+ def test_package_reexports_base_model():
14
+ """``BaseModel`` is part of the public surface."""
15
+
16
+ assert BaseModel is hajimi_sqlalchemy.base.BaseModel
17
+
18
+
19
+ def test_package_reexports_config():
20
+ """``HajimiSQLAlchemyConfig`` is part of the public surface."""
21
+
22
+ assert HajimiSQLAlchemyConfig is hajimi_sqlalchemy.config.HajimiSQLAlchemyConfig
23
+
24
+
25
+ def test_package_reexports_plugin():
26
+ """``HajimiSQLAlchemyPlugin`` is part of the public surface."""
27
+
28
+ assert HajimiSQLAlchemyPlugin is hajimi_sqlalchemy.plugin.HajimiSQLAlchemyPlugin
@@ -0,0 +1,94 @@
1
+ """Tests for hajimi_sqlalchemy.plugin.HajimiSQLAlchemyPlugin."""
2
+
3
+ import pytest
4
+
5
+ from hajimi_sqlalchemy.plugin import HajimiSQLAlchemyPlugin
6
+
7
+ from .stubs import FakeAsyncSessionMaker
8
+
9
+
10
+ def test_init_leaves_engine_and_session_maker_uninitialised(primed_plugin):
11
+ """Constructing a plugin must not eagerly create a database engine."""
12
+
13
+ assert primed_plugin._engine is None
14
+ assert primed_plugin._session_maker is None
15
+
16
+
17
+ def test_session_maker_raises_before_startup(primed_plugin):
18
+ """Accessing session_maker before startup() raises a descriptive RuntimeError."""
19
+
20
+ with pytest.raises(RuntimeError, match='session_maker is unavailable'):
21
+ _ = primed_plugin.session_maker
22
+
23
+
24
+ async def test_startup_creates_engine_and_registers_plugin(
25
+ fake_engine, primed_plugin, app_for_plugin, plugin_state_key,
26
+ ):
27
+ """startup() must drive the patched factories and stash the plugin on app.state."""
28
+
29
+ await primed_plugin.startup(app_for_plugin)
30
+
31
+ assert primed_plugin._engine is fake_engine
32
+ # The session_maker is an instance of the stand-in sessionmaker type, built inside startup().
33
+ assert isinstance(primed_plugin._session_maker, FakeAsyncSessionMaker)
34
+ stored = app_for_plugin.state._state[plugin_state_key][HajimiSQLAlchemyPlugin.__name__]
35
+ assert stored is primed_plugin
36
+
37
+
38
+ async def test_session_maker_property_succeeds_after_startup(primed_plugin, app_for_plugin):
39
+ """Once startup() has run, the session_maker property returns the cached factory."""
40
+
41
+ await primed_plugin.startup(app_for_plugin)
42
+
43
+ assert isinstance(primed_plugin.session_maker, FakeAsyncSessionMaker)
44
+
45
+
46
+ async def test_shutdown_disposes_engine_and_clears_state(
47
+ fake_engine, primed_plugin, app_for_plugin, plugin_state_key,
48
+ ):
49
+ """shutdown() must dispose the engine, clear internal refs and remove the store entry."""
50
+
51
+ await primed_plugin.startup(app_for_plugin)
52
+ # Sanity check: startup registered the plugin.
53
+ assert app_for_plugin.state._state[plugin_state_key][HajimiSQLAlchemyPlugin.__name__] is primed_plugin
54
+
55
+ await primed_plugin.shutdown(app_for_plugin)
56
+
57
+ assert fake_engine.dispose_calls == 1
58
+ assert primed_plugin._engine is None
59
+ assert primed_plugin._session_maker is None
60
+ assert HajimiSQLAlchemyPlugin.__name__ not in app_for_plugin.state._state[plugin_state_key]
61
+
62
+
63
+ async def test_shutdown_without_startup_is_safe(primed_plugin, app_for_plugin):
64
+ """Calling shutdown() without a prior startup() must be a safe no-op."""
65
+
66
+ await primed_plugin.shutdown(app_for_plugin)
67
+
68
+ assert primed_plugin._engine is None
69
+ assert primed_plugin._session_maker is None
70
+
71
+
72
+ async def test_shutdown_is_idempotent(fake_engine, primed_plugin, app_for_plugin):
73
+ """A second shutdown() after the first must remain a no-op."""
74
+
75
+ await primed_plugin.startup(app_for_plugin)
76
+ await primed_plugin.shutdown(app_for_plugin)
77
+ initial_dispose_calls = fake_engine.dispose_calls
78
+
79
+ await primed_plugin.shutdown(app_for_plugin)
80
+
81
+ # The second call must not re-dispose the already-None engine.
82
+ assert fake_engine.dispose_calls == initial_dispose_calls
83
+ assert primed_plugin._engine is None
84
+ assert primed_plugin._session_maker is None
85
+
86
+
87
+ async def test_session_maker_unavailable_after_shutdown(primed_plugin, app_for_plugin):
88
+ """After shutdown(), the session_maker getter falls back to the error branch."""
89
+
90
+ await primed_plugin.startup(app_for_plugin)
91
+ await primed_plugin.shutdown(app_for_plugin)
92
+
93
+ with pytest.raises(RuntimeError, match='session_maker is unavailable'):
94
+ _ = primed_plugin.session_maker