standard-rdbms 0.2.3__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.
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.5
2
+ Name: standard-rdbms
3
+ Version: 0.2.3
4
+ Summary: Async relational database helpers for the Standard Python library
5
+ Project-URL: Documentation, https://appunni-m.github.io/python-standard/rdbms/
6
+ Project-URL: Repository, https://github.com/appunni-m/python-standard
7
+ Author: Standard contributors
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: database,mysql,postgresql,sqlalchemy,sqlite,standard
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: aiomysql<1,>=0.2
23
+ Requires-Dist: aiosqlite<1,>=0.19
24
+ Requires-Dist: asyncpg<1,>=0.29
25
+ Requires-Dist: cryptography>=41
26
+ Requires-Dist: pydantic<3,>=2.6
27
+ Requires-Dist: sqlalchemy[asyncio]<3,>=2.0
28
+ Requires-Dist: standard==0.2.3
29
+ Description-Content-Type: text/markdown
30
+
31
+ # standard-rdbms
32
+
33
+ Async SQLAlchemy helpers for the [`standard`](https://pypi.org/project/standard/)
34
+ Python library. The package provides `DatabaseConfig`, managed async sessions,
35
+ SQLite, PostgreSQL, MySQL, and the `ensure_utc` property decorator under
36
+ `std.rdbms`.
37
+
38
+ After publication, install the integration directly:
39
+
40
+ ```console
41
+ python -m pip install standard-rdbms
42
+ ```
43
+
44
+ For local development from this repository:
45
+
46
+ ```console
47
+ python -m pip install -e packages/standard-rdbms
48
+ ```
49
+
50
+ ```python
51
+ from std.rdbms import DatabaseConfig, SQLiteMemDatabase
52
+
53
+ database = SQLiteMemDatabase(DatabaseConfig(driver="sqlite+aiosqlite"))
54
+ ```
55
+
56
+ See the [RDBMS guide](https://appunni-m.github.io/python-standard/rdbms/)
57
+ for transactions, backend configuration, datetime behavior, and test commands.
@@ -0,0 +1,12 @@
1
+ std/rdbms/__init__.py,sha256=3da93qO8FI73ZnVZiS0T97-3EEUP646TAJuXRhPCsp8,503
2
+ std/rdbms/config.py,sha256=L3_GpLaAd1CXvgcnFOSxz5pmbJs-zVcJS78RZgR3Ky0,3721
3
+ std/rdbms/database.py,sha256=m6LH7cy59FtCpcQeAv7j-SELbTToFhp9z1Y9rEjPBrs,3208
4
+ std/rdbms/datetime_utils.py,sha256=FjHezM_U-Dzz-GTURBjima22hb0VTZ2_CJNrSqsro9E,960
5
+ std/rdbms/mysql.py,sha256=GcV5cU1mAvr9NOvWnXHZ0CxcN0JlOsZc9ayu56BbLA4,1366
6
+ std/rdbms/postgres.py,sha256=1_D3yS3VZHmjisp8h7tFV-FzSoy4VvzwRsoclhoPiD8,1386
7
+ std/rdbms/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ std/rdbms/sqlite.py,sha256=i6dkkhk--_UTDF7VGqbmu2yxAhV5cnzJFrWPi5Tu1jA,2443
9
+ standard_rdbms-0.2.3.dist-info/METADATA,sha256=XTgY3wUIfoPR2Y79VAQ34bE0cbly8uSxAyxYjzLf6Hc,1968
10
+ standard_rdbms-0.2.3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ standard_rdbms-0.2.3.dist-info/licenses/LICENSE,sha256=sTIUP5MvMnfCSD0TdNTwjyFwBdAsnTBG2PfgodF7c9k,1078
12
+ standard_rdbms-0.2.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Standard contributors
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.
std/rdbms/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Async relational database helpers for Standard."""
2
+
3
+ from std.rdbms.config import DatabaseConfig
4
+ from std.rdbms.database import Database
5
+ from std.rdbms.datetime_utils import ensure_utc
6
+ from std.rdbms.mysql import MySQLDatabase
7
+ from std.rdbms.postgres import PostgresDatabase
8
+ from std.rdbms.sqlite import SQLiteDatabase, SQLiteMemDatabase
9
+
10
+ __all__ = [
11
+ "Database",
12
+ "DatabaseConfig",
13
+ "MySQLDatabase",
14
+ "PostgresDatabase",
15
+ "SQLiteDatabase",
16
+ "SQLiteMemDatabase",
17
+ "ensure_utc",
18
+ ]
std/rdbms/config.py ADDED
@@ -0,0 +1,89 @@
1
+ """Validated settings and SQLAlchemy URL construction for relational databases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path, PureWindowsPath
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
9
+ from sqlalchemy.engine import URL
10
+
11
+
12
+ class DatabaseConfig(BaseModel):
13
+ """Connection and pool settings shared by the async database managers.
14
+
15
+ The ``url`` property is computed from the current fields. It includes the
16
+ password when one is configured, so treat the returned string as a secret.
17
+ The password is excluded from the model representation and serialization.
18
+ """
19
+
20
+ model_config = ConfigDict(validate_assignment=True, extra="forbid")
21
+
22
+ driver: str = Field(default="mysql+aiomysql", description="SQLAlchemy async dialect and driver")
23
+ host: str = Field(default="localhost", description="Database server hostname")
24
+ port: int | None = Field(default=None, ge=1, le=65535, description="Database server port")
25
+ user: str | None = Field(default=None, description="Database username")
26
+ password: str | None = Field(
27
+ default=None,
28
+ repr=False,
29
+ exclude=True,
30
+ description="Database password",
31
+ )
32
+ name: str = Field(default="agent_framework", description="Database name or SQLite path")
33
+ echo: bool = Field(default=False, description="Echo SQL statements for debugging")
34
+ pool_size: int = Field(default=5, ge=0, description="Number of persistent connections")
35
+ max_overflow: int = Field(default=10, ge=-1, description="Connections beyond the pool size")
36
+ pool_timeout: float = Field(default=30.0, ge=0, description="Seconds to wait for a connection")
37
+ pool_recycle: int = Field(
38
+ default=1800, ge=-1, description="Recycle connections after this many seconds"
39
+ )
40
+ connect_args: dict[str, Any] = Field(
41
+ default_factory=dict,
42
+ description="Driver-specific connection arguments",
43
+ )
44
+
45
+ @field_validator("driver", "host", "name")
46
+ @classmethod
47
+ def require_non_empty_text(cls, value: str) -> str:
48
+ """Reject empty strings before they become malformed connection URLs."""
49
+ if not value.strip():
50
+ raise ValueError("must not be empty")
51
+ return value
52
+
53
+ @property
54
+ def url(self) -> str:
55
+ """Return a SQLAlchemy URL string built with proper credential escaping."""
56
+ if self.driver.startswith("sqlite") and self.name == ":memory:":
57
+ return f"{self.driver}:///:memory:"
58
+ return self._sqlalchemy_url().render_as_string(hide_password=False)
59
+
60
+ def _sqlalchemy_url(self) -> URL:
61
+ """Build a structured SQLAlchemy URL without hand-escaping credentials."""
62
+ if self.driver.startswith("sqlite"):
63
+ database = self.name
64
+ if database != ":memory:":
65
+ is_absolute = (
66
+ Path(database).is_absolute() or PureWindowsPath(database).is_absolute()
67
+ )
68
+ if not is_absolute and not database.startswith(("./", ".\\")):
69
+ database = f"./{database}"
70
+ return URL.create(drivername=self.driver, database=database)
71
+
72
+ port = self.port
73
+ if port is None:
74
+ if "mysql" in self.driver:
75
+ port = 3306
76
+ elif "postgres" in self.driver:
77
+ port = 5432
78
+ else:
79
+ # Preserve the original fallback for custom network dialects.
80
+ port = 3306
81
+
82
+ return URL.create(
83
+ drivername=self.driver,
84
+ username=self.user or None,
85
+ password=self.password if self.user else None,
86
+ host=self.host,
87
+ port=port,
88
+ database=self.name,
89
+ )
std/rdbms/database.py ADDED
@@ -0,0 +1,90 @@
1
+ """Async SQLAlchemy database lifecycle and transaction management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import AsyncIterator
8
+ from contextlib import asynccontextmanager
9
+
10
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
11
+
12
+ from std.rdbms.config import DatabaseConfig
13
+
14
+
15
+ class Database(ABC):
16
+ """Own an async engine and provide commit-on-success session scopes.
17
+
18
+ Each session is intended for one asyncio task at a time. A successful
19
+ ``session()`` scope commits; any exception, including cancellation, rolls
20
+ back and re-raises. The session is closed in either case.
21
+ """
22
+
23
+ def __init__(self, settings: DatabaseConfig) -> None:
24
+ """Create the engine and its session factory without opening a connection."""
25
+ self.settings = settings
26
+ self._engine = self._create_engine()
27
+ self._session_factory = async_sessionmaker(
28
+ self._engine,
29
+ class_=AsyncSession,
30
+ expire_on_commit=False,
31
+ autoflush=False,
32
+ )
33
+ self._disposed = False
34
+
35
+ @property
36
+ def engine(self) -> AsyncEngine:
37
+ """Return the SQLAlchemy engine for migrations and explicit transactions."""
38
+ return self._engine
39
+
40
+ @abstractmethod
41
+ def _create_engine(self) -> AsyncEngine:
42
+ """Create the backend-specific async SQLAlchemy engine."""
43
+ raise NotImplementedError
44
+
45
+ @asynccontextmanager
46
+ async def session(self) -> AsyncIterator[AsyncSession]:
47
+ """Yield a session that commits on success and rolls back on failure."""
48
+ if self._disposed:
49
+ raise RuntimeError("Database has been disposed")
50
+
51
+ session = self._session_factory()
52
+ try:
53
+ yield session
54
+ await session.commit()
55
+ except BaseException as error:
56
+ try:
57
+ await session.rollback()
58
+ except BaseException as rollback_error:
59
+ error.add_note(
60
+ "Session rollback also failed: "
61
+ f"{type(rollback_error).__name__}: {rollback_error}"
62
+ )
63
+ raise
64
+ finally:
65
+ active_error = sys.exception()
66
+ try:
67
+ await session.close()
68
+ except BaseException as close_error:
69
+ if active_error is None:
70
+ raise
71
+ active_error.add_note(
72
+ f"Session close also failed: {type(close_error).__name__}: {close_error}"
73
+ )
74
+
75
+ async def dispose(self) -> None:
76
+ """Close pooled connections; calling this more than once is safe."""
77
+ if self._disposed:
78
+ return
79
+ await self._engine.dispose()
80
+ self._disposed = True
81
+
82
+ async def __aenter__(self) -> Database:
83
+ """Return this database for use as an async resource context manager."""
84
+ if self._disposed:
85
+ raise RuntimeError("Database has been disposed")
86
+ return self
87
+
88
+ async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
89
+ """Dispose the engine when leaving an async resource context."""
90
+ await self.dispose()
@@ -0,0 +1,28 @@
1
+ """Timezone helpers for database-backed model properties."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from datetime import UTC, datetime
7
+ from functools import wraps
8
+
9
+
10
+ def ensure_utc(function: Callable[..., datetime | None]) -> property:
11
+ """Decorate a property getter so returned datetimes are UTC-aware.
12
+
13
+ Naive values are interpreted as UTC, matching the historical helper
14
+ contract. Aware values are converted to UTC. ``None`` passes through.
15
+ """
16
+
17
+ @wraps(function)
18
+ def getter(instance: object) -> datetime | None:
19
+ value = function(instance)
20
+ if value is None:
21
+ return None
22
+ if not isinstance(value, datetime):
23
+ raise TypeError("ensure_utc property getter must return datetime or None")
24
+ if value.tzinfo is None or value.utcoffset() is None:
25
+ return value.replace(tzinfo=UTC)
26
+ return value.astimezone(UTC)
27
+
28
+ return property(getter)
std/rdbms/mysql.py ADDED
@@ -0,0 +1,34 @@
1
+ """Async MySQL database manager using SQLAlchemy's async dialect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
6
+
7
+ from std.rdbms.config import DatabaseConfig
8
+ from std.rdbms.database import Database
9
+
10
+
11
+ class MySQLDatabase(Database):
12
+ """Manage MySQL connections with health checks and configured pooling."""
13
+
14
+ def __init__(self, config: DatabaseConfig) -> None:
15
+ """Copy settings and provide safe MySQL charset and transaction defaults."""
16
+ settings = config.model_copy(deep=True)
17
+ if not settings.driver.startswith("mysql+"):
18
+ raise ValueError("MySQLDatabase requires a MySQL async driver")
19
+ settings.connect_args.setdefault("charset", "utf8mb4")
20
+ settings.connect_args.setdefault("autocommit", False)
21
+ super().__init__(settings)
22
+
23
+ def _create_engine(self) -> AsyncEngine:
24
+ """Create a pooled MySQL engine with pre-ping enabled."""
25
+ return create_async_engine(
26
+ self.settings.url,
27
+ echo=self.settings.echo,
28
+ pool_size=self.settings.pool_size,
29
+ max_overflow=self.settings.max_overflow,
30
+ pool_timeout=self.settings.pool_timeout,
31
+ pool_recycle=self.settings.pool_recycle,
32
+ pool_pre_ping=True,
33
+ connect_args=self.settings.connect_args,
34
+ )
std/rdbms/postgres.py ADDED
@@ -0,0 +1,33 @@
1
+ """Async PostgreSQL database manager using SQLAlchemy's async dialect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
6
+
7
+ from std.rdbms.config import DatabaseConfig
8
+ from std.rdbms.database import Database
9
+
10
+
11
+ class PostgresDatabase(Database):
12
+ """Manage PostgreSQL connections with health checks and configured pooling."""
13
+
14
+ def __init__(self, config: DatabaseConfig) -> None:
15
+ """Copy settings and provide a recognizable PostgreSQL application name."""
16
+ settings = config.model_copy(deep=True)
17
+ if not settings.driver.startswith(("postgresql+", "postgres+")):
18
+ raise ValueError("PostgresDatabase requires a PostgreSQL async driver")
19
+ settings.connect_args.setdefault("server_settings", {"application_name": "standard-rdbms"})
20
+ super().__init__(settings)
21
+
22
+ def _create_engine(self) -> AsyncEngine:
23
+ """Create a pooled PostgreSQL engine with pre-ping enabled."""
24
+ return create_async_engine(
25
+ self.settings.url,
26
+ echo=self.settings.echo,
27
+ pool_size=self.settings.pool_size,
28
+ max_overflow=self.settings.max_overflow,
29
+ pool_timeout=self.settings.pool_timeout,
30
+ pool_recycle=self.settings.pool_recycle,
31
+ pool_pre_ping=True,
32
+ connect_args=self.settings.connect_args,
33
+ )
std/rdbms/py.typed ADDED
File without changes
std/rdbms/sqlite.py ADDED
@@ -0,0 +1,59 @@
1
+ """Async SQLite managers for persistent files and isolated in-memory databases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
6
+ from sqlalchemy.pool import NullPool, StaticPool
7
+
8
+ from std.rdbms.config import DatabaseConfig
9
+ from std.rdbms.database import Database
10
+
11
+
12
+ def _sqlite_settings(config: DatabaseConfig) -> DatabaseConfig:
13
+ """Copy and validate SQLite settings without mutating the caller's model."""
14
+ settings = config.model_copy(deep=True)
15
+ if not settings.driver.startswith("sqlite+aiosqlite"):
16
+ raise ValueError("SQLite managers require the sqlite+aiosqlite driver")
17
+ settings.connect_args.setdefault("check_same_thread", False)
18
+ return settings
19
+
20
+
21
+ class SQLiteDatabase(Database):
22
+ """Manage a file-backed SQLite database with a fresh connection per checkout."""
23
+
24
+ def __init__(self, config: DatabaseConfig) -> None:
25
+ """Create a file-backed database; use ``SQLiteMemDatabase`` for memory."""
26
+ if config.name == ":memory:":
27
+ raise ValueError("Use SQLiteMemDatabase for an in-memory SQLite database")
28
+ super().__init__(_sqlite_settings(config))
29
+
30
+ def _create_engine(self) -> AsyncEngine:
31
+ """Create a file-backed engine with ``NullPool`` and configured connect args."""
32
+ return create_async_engine(
33
+ self.settings.url,
34
+ echo=self.settings.echo,
35
+ poolclass=NullPool,
36
+ connect_args=self.settings.connect_args,
37
+ )
38
+
39
+
40
+ class SQLiteMemDatabase(Database):
41
+ """Manage one shared in-memory SQLite database for the lifetime of its engine."""
42
+
43
+ def __init__(self, config: DatabaseConfig) -> None:
44
+ """Force this manager's copied settings to use one shared in-memory database."""
45
+ settings = config.model_copy(deep=True)
46
+ if not settings.driver.startswith("sqlite+aiosqlite"):
47
+ raise ValueError("SQLite managers require the sqlite+aiosqlite driver")
48
+ settings.name = ":memory:"
49
+ settings.connect_args.setdefault("check_same_thread", False)
50
+ super().__init__(settings)
51
+
52
+ def _create_engine(self) -> AsyncEngine:
53
+ """Create an engine with ``StaticPool`` so sessions see the same memory DB."""
54
+ return create_async_engine(
55
+ self.settings.url,
56
+ echo=self.settings.echo,
57
+ poolclass=StaticPool,
58
+ connect_args=self.settings.connect_args,
59
+ )