duo-orm 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.
duo_orm/__init__.py ADDED
@@ -0,0 +1,82 @@
1
+ # duo_orm/__init__.py
2
+
3
+ """
4
+ DuoORM: An opinionated, modern ORM for Python.
5
+
6
+ This package provides a clean, symmetrical API for synchronous and
7
+ asynchronous database operations, built on SQLAlchemy 2.0.
8
+ """
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ # Apply lightweight operator ergonomics
13
+ from . import patch # noqa: F401
14
+
15
+ # --- Import and re-export all common components for a modern 2.0 workflow ---
16
+
17
+ # 1. The Core Factory
18
+ from .db import Database
19
+
20
+ # 2. The Model Kit (Modern 2.0 Style)
21
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
22
+
23
+ # 3. The Associations & Constraints Kit
24
+ from sqlalchemy import Table, ForeignKey, UniqueConstraint, CheckConstraint, Index, not_
25
+
26
+ # 4. The Types Kit
27
+ from sqlalchemy import types
28
+
29
+ # 5. The SQL Functions & Helpers Kit
30
+ from sqlalchemy import func, text, select
31
+
32
+ # 6. The Lifecycle Events Kit
33
+ from sqlalchemy import event
34
+
35
+ # 7. The Custom Exceptions
36
+ from .exceptions import (
37
+ YourOrmError,
38
+ ConfigurationError,
39
+ ObjectNotFoundError,
40
+ MultipleObjectsFoundError,
41
+ InvalidQueryError,
42
+ UnsupportedOperationError,
43
+ IntegrityError,
44
+ ValidationError,
45
+ )
46
+ from .query import json, array
47
+
48
+
49
+ __all__ = [
50
+ # Core Factory
51
+ "Database",
52
+ # Model Kit
53
+ "Mapped",
54
+ "mapped_column",
55
+ "relationship", # Often used in model definition
56
+ # Associations & Constraints Kit
57
+ "Table",
58
+ "ForeignKey",
59
+ "UniqueConstraint",
60
+ "CheckConstraint",
61
+ "Index",
62
+ "not_",
63
+ # Types Kit
64
+ "types",
65
+ # SQL Functions & Helpers Kit
66
+ "func",
67
+ "text",
68
+ "select",
69
+ # Lifecycle Events Kit
70
+ "event",
71
+ # Custom Exceptions
72
+ "YourOrmError",
73
+ "ConfigurationError",
74
+ "ObjectNotFoundError",
75
+ "MultipleObjectsFoundError",
76
+ "InvalidQueryError",
77
+ "UnsupportedOperationError",
78
+ "IntegrityError",
79
+ "ValidationError",
80
+ "json",
81
+ "array",
82
+ ]
duo_orm/basemodel.py ADDED
@@ -0,0 +1,146 @@
1
+ # duo_orm/basemodel.py
2
+
3
+ from __future__ import annotations
4
+ from datetime import datetime, timezone
5
+ from typing import TYPE_CHECKING, TypeVar, Dict, Any, Iterable
6
+
7
+ from sqlalchemy import inspect as sa_inspect, DateTime
8
+ from sqlalchemy.orm import mapped_column
9
+
10
+ from .query import QueryBuilder
11
+ from .executor import _save, _delete_instance, _bulk_create
12
+ if TYPE_CHECKING:
13
+ from .db import Database
14
+
15
+ # This helps with type hinting for model instances.
16
+ T = TypeVar("T")
17
+
18
+
19
+ class _YourOrmMethods:
20
+ """
21
+ A mixin class providing the Active Record-style API.
22
+
23
+ This class is not intended to be used directly by the end-user. It will be
24
+ mixed into the user-facing base model that is generated by the Database
25
+ instance.
26
+ """
27
+ # This class attribute will be set by the Database class when it creates
28
+ # the user-facing Model. This is the key that links the models back
29
+ # to the configured database instance.
30
+ _db: "Database" = None
31
+
32
+ # --- Class-level Querying API ---
33
+
34
+ @classmethod
35
+ def _get_query_builder(cls: T) -> "QueryBuilder[T]":
36
+ """Internal helper to create a QueryBuilder for this model."""
37
+ # The `cls._db` attribute provides the crucial link to the db instance.
38
+ return QueryBuilder(cls, db=cls._db)
39
+
40
+ @classmethod
41
+ def where(cls: T, *args) -> "QueryBuilder[T]":
42
+ """Starts a query with a WHERE clause."""
43
+ return cls._get_query_builder().where(*args)
44
+
45
+ @classmethod
46
+ def all(cls: T):
47
+ """Fetches all records for this model."""
48
+ return cls._get_query_builder().all()
49
+
50
+ @classmethod
51
+ def first(cls: T):
52
+ """Fetches the first record for this model."""
53
+ return cls._get_query_builder().first()
54
+
55
+ @classmethod
56
+ def related(cls: T, relationship_attr, **kwargs) -> "QueryBuilder[T]":
57
+ """Starts a query scoped by a relationship helper."""
58
+ return cls._get_query_builder().related(relationship_attr, **kwargs)
59
+
60
+ @classmethod
61
+ def order_by(cls: T, *args, **kwargs) -> "QueryBuilder[T]":
62
+ """Starts a query with ordering applied."""
63
+ return cls._get_query_builder().order_by(*args, **kwargs)
64
+
65
+ @classmethod
66
+ def paginate(cls: T, *args, **kwargs) -> "QueryBuilder[T]":
67
+ """Starts a query with pagination applied."""
68
+ return cls._get_query_builder().paginate(*args, **kwargs)
69
+
70
+ @classmethod
71
+ def bulk_create(cls: T, instances: list[T]):
72
+ """Performs a bulk insert of multiple model instances."""
73
+ for instance in instances:
74
+ instance.validate()
75
+ instance._apply_timestamp_hooks(is_insert=True)
76
+ return _bulk_create(cls, instances)
77
+
78
+ # --- Instance-level Actions ---
79
+
80
+ def save(self: T):
81
+ """
82
+ Saves the current instance to the database (INSERT or UPDATE).
83
+ """
84
+ self.validate()
85
+ state = sa_inspect(self)
86
+ is_insert = state.transient or state.pending
87
+ self._apply_timestamp_hooks(is_insert=is_insert)
88
+ return _save(self)
89
+
90
+ def delete(self: T, *, force: bool = False):
91
+ """Deletes the current instance."""
92
+ return _delete_instance(self)
93
+
94
+ # --- Developer Experience Helpers ---
95
+
96
+ def validate(self: T):
97
+ """
98
+ Hook for subclasses to implement custom validation.
99
+
100
+ Raise ValidationError with an optional field/detail payload to block persistence.
101
+ """
102
+ return None
103
+
104
+ @classmethod
105
+ def fields(cls) -> tuple[str, ...]:
106
+ """Returns the column names defined on this model."""
107
+ mapper = sa_inspect(cls)
108
+ return tuple(col.key for col in mapper.columns)
109
+
110
+ def to_dict(self: T) -> Dict[str, Any]:
111
+ """Serializes the instance to a plain dictionary of column values."""
112
+ mapper = sa_inspect(self.__class__)
113
+ return {col.key: getattr(self, col.key) for col in mapper.columns}
114
+
115
+ def _apply_timestamp_hooks(self: T, *, is_insert: bool):
116
+ """Applies Django-style auto_now/auto_now_add semantics based on column info."""
117
+ now = _utcnow()
118
+ mapper = sa_inspect(self.__class__)
119
+ for column in mapper.columns:
120
+ info = getattr(column, "info", {}) or {}
121
+ targets: set[str] = set()
122
+
123
+ if info.get("auto_now"):
124
+ targets.update({"create", "update"})
125
+ if info.get("auto_now_add"):
126
+ targets.add("create")
127
+
128
+ set_on = info.get("set_on")
129
+ if set_on:
130
+ if isinstance(set_on, str):
131
+ targets.add(set_on)
132
+ elif isinstance(set_on, Iterable):
133
+ targets.update(str(value) for value in set_on)
134
+
135
+ if not targets:
136
+ continue
137
+
138
+ if "update" in targets:
139
+ setattr(self, column.key, now)
140
+ if is_insert and "create" in targets:
141
+ setattr(self, column.key, now)
142
+ return None
143
+
144
+
145
+ def _utcnow() -> datetime:
146
+ return datetime.now(timezone.utc)
duo_orm/db.py ADDED
@@ -0,0 +1,251 @@
1
+ # duo_orm/db.py
2
+
3
+ from contextlib import contextmanager, asynccontextmanager
4
+
5
+ from sqlalchemy import create_engine
6
+ from sqlalchemy.orm import sessionmaker, declarative_base
7
+ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
8
+ from sqlalchemy.engine import make_url
9
+
10
+ from .session import active_session_var, is_async_context
11
+ from .basemodel import _YourOrmMethods
12
+
13
+
14
+ _DRIVER_CONFIG = {
15
+ "postgresql": {
16
+ "sync": "postgresql+psycopg",
17
+ "async": "postgresql+psycopg",
18
+ },
19
+ "oracle": {
20
+ "sync": "oracle+oracledb",
21
+ "async": "oracle+oracledb_async",
22
+ },
23
+ "mysql": {
24
+ "sync": "mysql+pymysql",
25
+ "async": "mysql+asyncmy",
26
+ },
27
+ "mssql": {
28
+ "sync": "mssql+pyodbc",
29
+ "async": "mssql+aioodbc",
30
+ },
31
+ "sqlite": {
32
+ "sync": "sqlite",
33
+ "async": "sqlite+aiosqlite",
34
+ },
35
+ }
36
+
37
+ _DIALECT_ALIASES = {
38
+ "postgres": "postgresql",
39
+ "postgresql": "postgresql",
40
+ "mysql": "mysql",
41
+ "mariadb": "mysql",
42
+ "mssql": "mssql",
43
+ "sqlite": "sqlite",
44
+ "oracle": "oracle",
45
+ }
46
+
47
+
48
+ def _normalize_dialect(url_str: str) -> tuple:
49
+ parsed = make_url(url_str)
50
+ drivername = parsed.drivername or ""
51
+ if "+" in drivername:
52
+ raise ValueError(
53
+ "Do not include driver in URLs. Provide only the base dialect "
54
+ "(e.g., postgresql://..., mysql://..., sqlite:///...). "
55
+ "Drivers are managed automatically."
56
+ )
57
+ base = _DIALECT_ALIASES.get(drivername.lower())
58
+ if not base or base not in _DRIVER_CONFIG:
59
+ raise ValueError(f"Unsupported or unknown dialect '{drivername}'.")
60
+ return parsed, base
61
+
62
+
63
+ def _resolve_urls(sync_url: str, async_url: str | None, derive_async: bool) -> tuple[str, str | None]:
64
+ parsed_sync, dialect = _normalize_dialect(sync_url)
65
+ drivers = _DRIVER_CONFIG[dialect]
66
+
67
+ resolved_sync = parsed_sync.set(drivername=drivers["sync"])
68
+ resolved_async: str | None = None
69
+
70
+ if async_url:
71
+ parsed_async, dialect_async = _normalize_dialect(async_url)
72
+ if dialect_async != dialect:
73
+ raise ValueError("async_url dialect must match the primary url dialect.")
74
+ resolved_async = parsed_async.set(drivername=drivers["async"]).render_as_string(hide_password=False)
75
+ elif derive_async:
76
+ resolved_async = parsed_sync.set(drivername=drivers["async"]).render_as_string(hide_password=False)
77
+
78
+ return resolved_sync.render_as_string(hide_password=False), resolved_async
79
+
80
+
81
+ class Database:
82
+ """
83
+ The main class that manages database connections and sessions.
84
+
85
+ This class acts as a factory for a pre-configured, database-aware
86
+ base model class that users will inherit from.
87
+ """
88
+
89
+ def __init__(self, db_url: str, *, async_url: str | None = None, derive_async: bool = True):
90
+ if not db_url:
91
+ raise ValueError("Database URL cannot be empty.")
92
+
93
+ self._sync_url, self._async_url = _resolve_urls(db_url, async_url, derive_async)
94
+ self._db_url = self._sync_url
95
+ self._sync_engine = None
96
+ self._async_engine = None
97
+ self._sync_session_factory = None
98
+ self._async_session_factory = None
99
+ self._connected = False
100
+
101
+ # --- This is the new "factory" logic ---
102
+
103
+ # 1. Create a new, unique declarative base from SQLAlchemy.
104
+ Base = declarative_base()
105
+
106
+ # 2. Manufacture the final, user-facing Model class by combining
107
+ # SQLAlchemy's base with our custom Active Record methods.
108
+ class Model(Base, _YourOrmMethods):
109
+ __abstract__ = True # make the factory base unmapped; user models define tables
110
+ # This is the magic link that solves the flaw!
111
+ # We inject a reference to this specific `db` instance
112
+ # directly into the Model class itself.
113
+ _db = self
114
+
115
+ # 3. Attach the newly created Model class as an attribute to this
116
+ # instance, so the user can access it via `db.Model`.
117
+ self.Model = Model
118
+
119
+ @property
120
+ def url(self):
121
+ return self._db_url
122
+
123
+ @property
124
+ def sync_url(self):
125
+ return self._sync_url
126
+
127
+ @property
128
+ def async_url(self):
129
+ return self._async_url
130
+
131
+ @property
132
+ def metadata(self):
133
+ """Returns the metadata from the manufactured Model class."""
134
+ # The metadata is now correctly associated with this db instance's models.
135
+ return self.Model.metadata
136
+
137
+ # --- The rest of the class remains the same ---
138
+
139
+ @property
140
+ def sync_engine(self):
141
+ if not self._sync_url:
142
+ raise RuntimeError("Sync engine is not configured for this Database.")
143
+ if self._sync_engine is None:
144
+ try:
145
+ self._sync_engine = create_engine(self._sync_url)
146
+ except TypeError as exc:
147
+ query_keys = make_url(self._sync_url).query.keys()
148
+ hint = ""
149
+ if query_keys:
150
+ hint = (
151
+ f" URL includes query parameters {sorted(query_keys)}; "
152
+ "verify they are supported by the chosen driver."
153
+ )
154
+ raise TypeError(f"Failed to create sync engine: {exc}.{hint}") from exc
155
+ return self._sync_engine
156
+
157
+ @property
158
+ def async_engine(self):
159
+ if not self._async_url:
160
+ raise RuntimeError("Async engine is not configured for this Database.")
161
+ if self._async_engine is None:
162
+ try:
163
+ self._async_engine = create_async_engine(self._async_url)
164
+ except TypeError as exc:
165
+ query_keys = make_url(self._async_url).query.keys()
166
+ hint = ""
167
+ if query_keys:
168
+ hint = (
169
+ f" URL includes query parameters {sorted(query_keys)}; "
170
+ "verify they are supported by the chosen driver."
171
+ )
172
+ raise TypeError(f"Failed to create async engine: {exc}.{hint}") from exc
173
+ return self._async_engine
174
+
175
+ @property
176
+ def sync_session_factory(self) -> sessionmaker:
177
+ if self._sync_session_factory is None:
178
+ self._sync_session_factory = sessionmaker(
179
+ bind=self.sync_engine, expire_on_commit=False
180
+ )
181
+ return self._sync_session_factory
182
+
183
+ @property
184
+ def async_session_factory(self) -> sessionmaker:
185
+ if self._async_session_factory is None:
186
+ self._async_session_factory = sessionmaker(
187
+ bind=self.async_engine,
188
+ class_=AsyncSession,
189
+ expire_on_commit=False,
190
+ )
191
+ return self._async_session_factory
192
+
193
+ def connect(self):
194
+ """
195
+ Eagerly initialize engines and session factories to surface configuration errors early.
196
+ Safe to call multiple times.
197
+ """
198
+ if self._connected:
199
+ return
200
+ # Touch available factories so any misconfiguration (bad URL, missing driver, etc.) raises immediately.
201
+ if self._sync_url:
202
+ _ = self.sync_engine
203
+ _ = self.sync_session_factory
204
+ if self._async_url:
205
+ _ = self.async_engine
206
+ _ = self.async_session_factory
207
+ self._connected = True
208
+
209
+ @contextmanager
210
+ def _sync_transaction_context(self):
211
+ with self.sync_session_factory() as session:
212
+ token = active_session_var.set(session)
213
+ try:
214
+ yield session
215
+ session.commit()
216
+ except Exception:
217
+ session.rollback()
218
+ raise
219
+ finally:
220
+ active_session_var.reset(token)
221
+
222
+ @asynccontextmanager
223
+ async def _async_transaction_context(self):
224
+ async with self.async_session_factory() as session:
225
+ token = active_session_var.set(session)
226
+ try:
227
+ yield session
228
+ await session.commit()
229
+ except Exception:
230
+ await session.rollback()
231
+ raise
232
+ finally:
233
+ active_session_var.reset(token)
234
+
235
+ def transaction(self):
236
+ if is_async_context():
237
+ return self._async_transaction_context()
238
+ else:
239
+ return self._sync_transaction_context()
240
+
241
+ @asynccontextmanager
242
+ async def standalone_session(self):
243
+ """Provides a raw, unmanaged SQLAlchemy AsyncSession."""
244
+ async with self.async_session_factory() as session:
245
+ yield session
246
+
247
+ @contextmanager
248
+ def sync_standalone_session(self):
249
+ """Provides a raw, unmanaged SQLAlchemy Session."""
250
+ with self.sync_session_factory() as session:
251
+ yield session
duo_orm/exceptions.py ADDED
@@ -0,0 +1,84 @@
1
+ # duo_orm/exceptions.py
2
+
3
+ """
4
+ This module contains the set of custom exceptions raised by DuoORM.
5
+
6
+ Having custom exceptions allows users of the library to reliably catch
7
+ errors specific to the ORM's operation, rather than generic Python errors.
8
+ """
9
+
10
+
11
+ class YourOrmError(Exception):
12
+ """Base exception for all errors raised by DuoORM."""
13
+ pass
14
+
15
+
16
+ class ConfigurationError(YourOrmError):
17
+ """
18
+ Raised when there is a problem with the ORM's configuration.
19
+
20
+ For example, if a required configuration file is missing or a
21
+ database URL is not provided.
22
+ """
23
+ pass
24
+
25
+
26
+ class ObjectNotFoundError(YourOrmError):
27
+ """
28
+ Raised when a query that expects a single result finds none.
29
+
30
+ For example, a `.one()` or `.get()` method would raise this.
31
+ """
32
+ pass
33
+
34
+
35
+ class MultipleObjectsFoundError(YourOrmError):
36
+ """
37
+ Raised when a query that expects a single result finds multiple.
38
+
39
+ Similar to ObjectNotFoundError, this would be used by `.one()` or `.get()`.
40
+ """
41
+ pass
42
+
43
+
44
+ class InvalidQueryError(YourOrmError):
45
+ """
46
+ Raised when a query is constructed in an invalid way, for example
47
+ by passing an unsupported operator.
48
+ """
49
+ pass
50
+
51
+
52
+ class UnsupportedOperationError(InvalidQueryError):
53
+ """
54
+ Raised when an operation is not supported by the current database dialect.
55
+
56
+ For example, using JSONContains on a database that does not support JSON.
57
+ """
58
+ pass
59
+
60
+
61
+ class IntegrityError(YourOrmError):
62
+ """
63
+ Raised when a database integrity constraint is violated.
64
+
65
+ This is a wrapper around the database driver's specific integrity error
66
+ (e.g., sqlalchemy.exc.IntegrityError) to provide a consistent API.
67
+ It typically signals a unique constraint violation.
68
+ """
69
+ pass
70
+
71
+
72
+ class ValidationError(YourOrmError):
73
+ """
74
+ Raised when model-level validation fails.
75
+
76
+ Attributes:
77
+ field (str | None): Optional field name associated with the error.
78
+ detail (Any): Optional structured detail for the error.
79
+ """
80
+
81
+ def __init__(self, message: str, field: str | None = None, detail=None):
82
+ super().__init__(message)
83
+ self.field = field
84
+ self.detail = detail