activemodel 0.11.0__py3-none-any.whl → 0.12.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.
activemodel/base_model.py CHANGED
@@ -2,10 +2,11 @@ import json
2
2
  import typing as t
3
3
  from uuid import UUID
4
4
 
5
- import pydash
6
5
  import sqlalchemy as sa
7
6
  import sqlmodel as sm
7
+ import textcase
8
8
  from sqlalchemy import Connection, event
9
+ from sqlalchemy.dialects.postgresql import insert as postgres_insert
9
10
  from sqlalchemy.orm import Mapper, declared_attr
10
11
  from sqlalchemy.orm.attributes import flag_modified as sa_flag_modified
11
12
  from sqlalchemy.orm.base import instance_state
@@ -19,7 +20,6 @@ from . import get_column_from_field_patch # noqa: F401
19
20
  from .logger import logger
20
21
  from .query_wrapper import QueryWrapper
21
22
  from .session_manager import get_session
22
- from sqlalchemy.dialects.postgresql import insert as postgres_insert
23
23
 
24
24
  POSTGRES_INDEXES_NAMING_CONVENTION = {
25
25
  "ix": "%(column_0_label)s_idx",
@@ -152,19 +152,19 @@ class BaseModel(SQLModel):
152
152
  @declared_attr
153
153
  def __tablename__(cls) -> str:
154
154
  """
155
- Automatically generates the table name for the model by converting the class name from camel case to snake case.
156
- This is the recommended format for table names:
155
+ Automatically generates the table name for the model by converting the model's class name from camel case to snake case.
156
+ This is the recommended text case style for table names:
157
157
 
158
158
  https://wiki.postgresql.org/wiki/Don%27t_Do_This#Don.27t_use_upper_case_table_or_column_names
159
159
 
160
- By default, the class is lower cased which makes it harder to read.
160
+ By default, the model's class name is lower cased which makes it harder to read.
161
161
 
162
- Many snake_case libraries struggle with snake case for names like LLMCache, which is why we are using a more
163
- complicated implementation from pydash.
162
+ Also, many text case conversion libraries struggle handling words like "LLMCache", this is why we are using
163
+ a more precise library which processes such acronyms: [`textcase`](https://pypi.org/project/textcase/).
164
164
 
165
165
  https://stackoverflow.com/questions/1175208/elegant-python-function-to-convert-camelcase-to-snake-case
166
166
  """
167
- return pydash.strings.snake_case(cls.__name__)
167
+ return textcase.snake(cls.__name__)
168
168
 
169
169
  @classmethod
170
170
  def foreign_key(cls, **kwargs):
@@ -234,6 +234,8 @@ class BaseModel(SQLModel):
234
234
  return result
235
235
 
236
236
  def delete(self):
237
+ "Delete record completely from the database"
238
+
237
239
  with get_session() as session:
238
240
  if old_session := Session.object_session(self):
239
241
  old_session.expunge(self)
@@ -404,6 +406,19 @@ class BaseModel(SQLModel):
404
406
  with get_session() as session:
405
407
  return session.exec(statement).first()
406
408
 
409
+ @classmethod
410
+ def one_or_none(cls, *args: t.Any, **kwargs: t.Any):
411
+ """
412
+ Gets a single record from the database. Pass an PK ID or a kwarg to filter by.
413
+ Returns None if no record is found. Throws an error if more than one record is found.
414
+ """
415
+
416
+ args, kwargs = cls.__process_filter_args__(*args, **kwargs)
417
+ statement = select(cls).filter(*args).filter_by(**kwargs)
418
+
419
+ with get_session() as session:
420
+ return session.exec(statement).one_or_none()
421
+
407
422
  @classmethod
408
423
  def one(cls, *args: t.Any, **kwargs: t.Any):
409
424
  """
@@ -0,0 +1,147 @@
1
+ """
2
+ This module provides utilities for generating Protocol type definitions for SQLAlchemy's
3
+ SelectOfScalar methods, as well as formatting and fixing Python files using ruff.
4
+ """
5
+
6
+ import inspect
7
+ import logging
8
+ import os
9
+ import subprocess
10
+ from pathlib import Path
11
+ from typing import Any # already imported in header of generated file
12
+
13
+ import sqlmodel as sm
14
+ from sqlmodel.sql.expression import SelectOfScalar
15
+
16
+ from test.test_wrapper import QueryWrapper
17
+
18
+ # Set up logging
19
+ logging.basicConfig(level=logging.DEBUG)
20
+ logger = logging.getLogger(__name__)
21
+
22
+ QUERY_WRAPPER_CLASS_NAME = QueryWrapper.__name__
23
+
24
+
25
+ def format_python_file(file_path: str | Path) -> bool:
26
+ """
27
+ Format a Python file using ruff.
28
+
29
+ Args:
30
+ file_path: Path to the Python file to format
31
+
32
+ Returns:
33
+ bool: True if formatting was successful, False otherwise
34
+ """
35
+ try:
36
+ subprocess.run(["ruff", "format", str(file_path)], check=True)
37
+ logger.info(f"Formatted file using ruff at {file_path}")
38
+ return True
39
+ except subprocess.CalledProcessError as e:
40
+ logger.error(f"Error running ruff to format the file: {e}")
41
+ return False
42
+
43
+
44
+ def fix_python_file(file_path: str | Path) -> bool:
45
+ """
46
+ Fix linting issues in a Python file using ruff.
47
+
48
+ Args:
49
+ file_path: Path to the Python file to fix
50
+
51
+ Returns:
52
+ bool: True if fixing was successful, False otherwise
53
+ """
54
+ try:
55
+ subprocess.run(["ruff", "check", str(file_path), "--fix"], check=True)
56
+ logger.info(f"Fixed linting issues using ruff at {file_path}")
57
+ return True
58
+ except subprocess.CalledProcessError as e:
59
+ logger.error(f"Error running ruff to fix the file: {e}")
60
+ return False
61
+
62
+
63
+ def generate_sqlalchemy_protocol():
64
+ """Generate Protocol type definitions for SQLAlchemy SelectOfScalar methods"""
65
+ logger.info("Starting SQLAlchemy protocol generation")
66
+
67
+ header = """
68
+ # IMPORTANT: This file is auto-generated. Do not edit directly.
69
+
70
+ from typing import Protocol, TypeVar, Any, Generic
71
+ import sqlmodel as sm
72
+ from sqlalchemy.sql.base import _NoArg
73
+
74
+ T = TypeVar('T', bound=sm.SQLModel, covariant=True)
75
+
76
+ class SQLAlchemyQueryMethods(Protocol, Generic[T]):
77
+ \"""Protocol defining SQLAlchemy query methods forwarded by QueryWrapper.__getattr__\"""
78
+ """
79
+ # Initialize output list for generated method signatures
80
+ output: list = []
81
+
82
+ try:
83
+ # Get all methods from SelectOfScalar
84
+ methods = inspect.getmembers(SelectOfScalar)
85
+ logger.debug(f"Discovered {len(methods)} methods from SelectOfScalar")
86
+
87
+ for name, method in methods:
88
+ # Skip private/dunder methods
89
+ if name.startswith("_"):
90
+ continue
91
+
92
+ if not inspect.isfunction(method) and not inspect.ismethod(method):
93
+ logger.debug(f"Skipping non-method: {name}")
94
+ continue
95
+
96
+ logger.debug(f"Processing method: {name}")
97
+ try:
98
+ signature = inspect.signature(method)
99
+ params = []
100
+
101
+ # Process parameters, skipping 'self'
102
+ for param_name, param in list(signature.parameters.items())[1:]:
103
+ if param.kind == param.VAR_POSITIONAL:
104
+ params.append(f"*{param_name}: Any")
105
+ elif param.kind == param.VAR_KEYWORD:
106
+ params.append(f"**{param_name}: Any")
107
+ else:
108
+ if param.default is inspect.Parameter.empty:
109
+ params.append(f"{param_name}: Any")
110
+ else:
111
+ default_repr = repr(param.default)
112
+ params.append(f"{param_name}: Any = {default_repr}")
113
+
114
+ params_str = ", ".join(params)
115
+ output.append(
116
+ f' def {name}(self, {params_str}) -> "{QUERY_WRAPPER_CLASS_NAME}[T]": ...'
117
+ )
118
+ except (ValueError, TypeError) as e:
119
+ logger.warning(f"Could not get signature for {name}: {e}")
120
+ # Some methods might not have proper signatures
121
+ output.append(
122
+ f' def {name}(self, *args: Any, **kwargs: Any) -> "{QUERY_WRAPPER_CLASS_NAME}[T]": ...'
123
+ )
124
+
125
+ # Write the output to a file
126
+ protocol_path = (
127
+ Path(__file__).parent.parent / "types" / "sqlalchemy_protocol.py"
128
+ )
129
+
130
+ # Ensure directory exists
131
+ os.makedirs(protocol_path.parent, exist_ok=True)
132
+
133
+ with open(protocol_path, "w") as f:
134
+ f.write(header + "\n".join(output))
135
+
136
+ logger.info(f"Generated SQLAlchemy protocol at {protocol_path}")
137
+
138
+ # Format and fix the generated file with ruff
139
+ format_python_file(protocol_path)
140
+ fix_python_file(protocol_path)
141
+ except Exception as e:
142
+ logger.error(f"Error generating SQLAlchemy protocol: {e}", exc_info=True)
143
+ raise
144
+
145
+
146
+ if __name__ == "__main__":
147
+ generate_sqlalchemy_protocol()
@@ -20,7 +20,10 @@ class TimestampsMixin:
20
20
  >>> class MyModel(TimestampsMixin, SQLModel):
21
21
  >>> pass
22
22
 
23
- Originally pulled from: https://github.com/tiangolo/sqlmodel/issues/252
23
+ Notes:
24
+
25
+ - Originally pulled from: https://github.com/tiangolo/sqlmodel/issues/252
26
+ - Related issue: https://github.com/fastapi/sqlmodel/issues/539
24
27
  """
25
28
 
26
29
  created_at: datetime | None = Field(
@@ -17,7 +17,7 @@ def TypeIDMixin(prefix: str):
17
17
  # NOTE this will cause issues on code reloads
18
18
  assert prefix
19
19
  assert prefix not in _prefixes, (
20
- f"prefix {prefix} already exists, pick a different one"
20
+ f"TypeID prefix '{prefix}' already exists, pick a different one"
21
21
  )
22
22
 
23
23
  class _TypeIDMixin:
@@ -1,2 +1,2 @@
1
- from .transaction import database_reset_transaction
1
+ from .transaction import database_reset_transaction, test_session
2
2
  from .truncate import database_reset_truncate
@@ -0,0 +1,102 @@
1
+ """
2
+ Notes on polyfactory:
3
+
4
+ 1. is_supported_type validates that the class can be used to generate a factory
5
+ https://github.com/litestar-org/polyfactory/issues/655#issuecomment-2727450854
6
+ """
7
+
8
+ import typing as t
9
+
10
+ from polyfactory.factories.pydantic_factory import ModelFactory
11
+ from polyfactory.field_meta import FieldMeta
12
+ from typeid import TypeID
13
+
14
+ from activemodel.session_manager import global_session
15
+
16
+ # TODO not currently used
17
+ # def type_id_provider(cls, field_meta):
18
+ # # TODO this doesn't work well with __ args:
19
+ # # https://github.com/litestar-org/polyfactory/pull/666/files
20
+ # return str(TypeID("hi"))
21
+
22
+
23
+ # BaseFactory.add_provider(TypeIDType, type_id_provider)
24
+
25
+
26
+ class SQLModelFactory[T](ModelFactory[T]):
27
+ """
28
+ Base factory for SQLModel models:
29
+
30
+ 1. Ability to ignore all relationship fks
31
+ 2. Option to ignore all pks
32
+ """
33
+
34
+ __is_base_factory__ = True
35
+
36
+ @classmethod
37
+ def should_set_field_value(cls, field_meta: FieldMeta, **kwargs: t.Any) -> bool:
38
+ # TODO what is this checking for?
39
+ has_object_override = hasattr(cls, field_meta.name)
40
+
41
+ # TODO this should be more intelligent, it's goal is to detect all of the relationship field and avoid settings them
42
+ if not has_object_override and (
43
+ field_meta.name == "id" or field_meta.name.endswith("_id")
44
+ ):
45
+ return False
46
+
47
+ return super().should_set_field_value(field_meta, **kwargs)
48
+
49
+
50
+ # TODO we need to think through how to handle relationships and autogenerate them
51
+ class ActiveModelFactory[T](SQLModelFactory[T]):
52
+ __is_base_factory__ = True
53
+ __sqlalchemy_session__ = None
54
+
55
+ # TODO we shouldn't have to type this, but `save()` typing is not working
56
+ @classmethod
57
+ def save(cls, *args, **kwargs) -> T:
58
+ """
59
+ Where this gets tricky, is this can be called multiple times within the same callstack. This can happen when
60
+ a factory uses other factories to create relationships.
61
+
62
+ In a truncation strategy, the __sqlalchemy_session__ is set to None.
63
+ """
64
+ with global_session(cls.__sqlalchemy_session__):
65
+ return cls.build(*args, **kwargs).save()
66
+
67
+ @classmethod
68
+ def foreign_key_typeid(cls):
69
+ """
70
+ Return a random type id for the foreign key on this model.
71
+
72
+ This is helpful for generating TypeIDs for testing 404s, parsing, manually settings, etc
73
+ """
74
+ # TODO right now assumes the model is typeid, maybe we should assert against this?
75
+ primary_key_name = cls.__model__.primary_key_column().name
76
+ return TypeID(
77
+ cls.__model__.model_fields[primary_key_name].sa_column.type.prefix
78
+ )
79
+
80
+ @classmethod
81
+ def should_set_field_value(cls, field_meta: FieldMeta, **kwargs: t.Any) -> bool:
82
+ # do not default deleted at mixin to deleted!
83
+ # TODO should be smarter about detecting if the mixin is in place
84
+ if field_meta.name in ["deleted_at", "updated_at", "created_at"]:
85
+ return False
86
+
87
+ return super().should_set_field_value(field_meta, **kwargs)
88
+
89
+ # @classmethod
90
+ # def build(
91
+ # cls,
92
+ # factory_use_construct: bool | None = None,
93
+ # sqlmodel_save: bool = False,
94
+ # **kwargs: t.Any,
95
+ # ) -> T:
96
+ # result = super().build(factory_use_construct=factory_use_construct, **kwargs)
97
+
98
+ # # TODO allow magic dunder method here
99
+ # if sqlmodel_save:
100
+ # result.save()
101
+
102
+ # return result
@@ -0,0 +1,81 @@
1
+ """Pytest plugin integration for activemodel.
2
+
3
+ Currently provides:
4
+
5
+ * ``db_session`` fixture – quick access to a database session (see ``test_session``)
6
+ * ``activemodel_preserve_tables`` ini option – configure tables to preserve when using
7
+ ``database_reset_truncate`` (comma separated list or multiple lines depending on config style)
8
+
9
+ Configuration examples:
10
+
11
+ pytest.ini::
12
+
13
+ [pytest]
14
+ activemodel_preserve_tables = alembic_version,zip_code,seed_table
15
+
16
+ pyproject.toml::
17
+
18
+ [tool.pytest.ini_options]
19
+ activemodel_preserve_tables = [
20
+ "alembic_version",
21
+ "zip_code",
22
+ "seed_table",
23
+ ]
24
+
25
+ The list always implicitly includes ``alembic_version`` even if not specified.
26
+ """
27
+
28
+ from activemodel.session_manager import global_session
29
+ import pytest
30
+
31
+ from .transaction import set_factory_session, set_polyfactory_session, test_session
32
+
33
+
34
+ def pytest_addoption(
35
+ parser: pytest.Parser,
36
+ ) -> None: # pragma: no cover - executed during collection
37
+ """Register custom ini options.
38
+
39
+ We treat this as a *linelist* so pyproject.toml list syntax works. Comma separated works too because
40
+ pytest splits lines first; users can still provide one line with commas.
41
+ """
42
+
43
+ parser.addini(
44
+ "activemodel_preserve_tables",
45
+ help=(
46
+ "Tables to preserve when calling activemodel.pytest.database_reset_truncate. "
47
+ ),
48
+ type="linelist",
49
+ default=["alembic_version"],
50
+ )
51
+
52
+
53
+ @pytest.fixture(scope="function")
54
+ def db_session():
55
+ """
56
+ Helpful for tests that are more similar to unit tests. If you doing a routing or integration test, you
57
+ probably don't need this. If your unit test is simple (you are just creating a couple of models) you
58
+ can most likely skip this.
59
+
60
+ This is helpful if you are doing a lot of lazy-loaded params or need a database session to be in place
61
+ for testing code that will run within a celery worker or something similar.
62
+
63
+ >>> def the_test(db_session):
64
+ """
65
+ with test_session() as session:
66
+ yield session
67
+
68
+
69
+ @pytest.fixture(scope="function")
70
+ def db_truncate_session():
71
+ """
72
+ Provides a database session for testing when using a truncation cleaning strategy.
73
+
74
+ When not using a transaction cleaning strategy, no global test session is set
75
+ """
76
+ with global_session() as session:
77
+ # set global database sessions for model factories to avoid lazy loading issues
78
+ set_factory_session(session)
79
+ set_polyfactory_session(session)
80
+
81
+ yield session
@@ -1,19 +1,107 @@
1
+ import contextlib
2
+ import contextvars
3
+
4
+ from sqlmodel import Session
1
5
  from activemodel import SessionManager
6
+ from activemodel.session_manager import global_session
2
7
 
3
8
  from ..logger import logger
4
9
 
10
+ try:
11
+ import factory as factory_exists
12
+ except ImportError:
13
+ factory_exists = None
14
+
15
+ try:
16
+ import polyfactory as polyfactory_exists
17
+ except ImportError:
18
+ polyfactory_exists = None
19
+
20
+
21
+ _test_session = contextvars.ContextVar[Session | None]("test_session", default=None)
22
+
23
+
24
+ def set_factory_session(session):
25
+ if not factory_exists:
26
+ return
27
+ from factory.alchemy import SQLAlchemyModelFactory
28
+
29
+ # Ensure that all factories use the same session
30
+ for factory in SQLAlchemyModelFactory.__subclasses__():
31
+ factory._meta.sqlalchemy_session = factory_session
32
+ factory._meta.sqlalchemy_session_persistence = "commit"
33
+
34
+
35
+ def set_polyfactory_session(session):
36
+ if not polyfactory_exists:
37
+ return
38
+
39
+ from .factories import ActiveModelFactory
40
+
41
+ ActiveModelFactory.__sqlalchemy_session__ = session
42
+
43
+
44
+ @contextlib.contextmanager
45
+ def test_session():
46
+ """
47
+ Configures a session-global database session for a test.
48
+
49
+ Use this as a fixture using `db_session`. This method is meant to be used as a context manager.
50
+
51
+ This is useful for tests that need to interact with the database multiple times before calling application code
52
+ that uses the objects. This is intended to be used outside of an integration test. Integration tests generally
53
+ do not use database transactions to clean the database and instead use truncation. The transaction fixture
54
+ configures a session, which is then used here. This method requires that this global test session is already
55
+ configured. If the transaction fixture is not used, then there is no session available for use and this will fail.
56
+
57
+ ActiveModelFactory.save() does this automatically, but if you need to manually create objects
58
+ and persist them to a DB, you can run into issues with the simple `expunge()` call
59
+ used to reassociate an object with a new session. If there are more complex relationships
60
+ this approach will fail and give you detached object errors.
61
+
62
+ >>> from activemodel.pytest import test_session
63
+ >>> def test_the_thing():
64
+ >>> with test_session():
65
+ ... obj = MyModel(name="test").save()
66
+ ... obj2 = MyModelFactory.save()
67
+
68
+ More information: https://grok.com/share/bGVnYWN5_c21dd39f-84a7-44cf-a05b-9b26c8febb0b
69
+ """
70
+
71
+ if model_factory_session := _test_session.get():
72
+ with global_session(model_factory_session) as session:
73
+ yield session
74
+ else:
75
+ raise ValueError("No test session available")
76
+
77
+
78
+ def database_truncate_session():
79
+ """
80
+ Provides a database session for testing when using a truncation cleaning strategy.
81
+
82
+ When not using a transaction cleaning strategy, no global test session is set
83
+ """
84
+ with test_session() as session:
85
+ yield session
86
+
5
87
 
6
88
  def database_reset_transaction():
7
89
  """
8
90
  Wrap all database interactions for a given test in a nested transaction and roll it back after the test.
9
91
 
92
+ This is provided as a function, not a fixture, since you'll need to determine when a integration test is run. Here's
93
+ an example of how to build a fixture from this method:
94
+
10
95
  >>> from activemodel.pytest import database_reset_transaction
11
96
  >>> database_reset_transaction = pytest.fixture(scope="function", autouse=True)(database_reset_transaction)
12
97
 
13
- Transaction-based DB cleaning does *not* work if the DB mutations are happening in a separate process, which should
14
- use spawn, because the same session is not shared across processes. Note that using `fork` is dangerous.
98
+ Transaction-based DB cleaning does *not* work if the DB mutations are happening in a separate process because the
99
+ same session is not shared across python processes. For this scenario, use the truncate method.
15
100
 
16
- In this case, you should use the truncate.
101
+ Note that using `fork` as a multiprocess start method is dangerous. Use spawn. This link has more documentation
102
+ around this topic:
103
+
104
+ https://github.com/iloveitaly/python-starter-template/blob/master/app/configuration/lang.py
17
105
 
18
106
  References:
19
107
 
@@ -28,30 +116,29 @@ def database_reset_transaction():
28
116
 
29
117
  engine = SessionManager.get_instance().get_engine()
30
118
 
31
- logger.info("starting global database transaction")
119
+ logger.debug("starting global database transaction")
32
120
 
33
121
  with engine.begin() as connection:
34
122
  transaction = connection.begin_nested()
35
123
 
36
124
  if SessionManager.get_instance().session_connection is not None:
37
- logger.warning("session override already exists")
38
- # TODO should we throw an exception here?
125
+ raise ValueError("global session already set")
39
126
 
127
+ # NOTE we very intentionally do NOT
40
128
  SessionManager.get_instance().session_connection = connection
41
129
 
42
130
  try:
43
- with SessionManager.get_instance().get_session() as factory_session:
44
- try:
45
- from factory.alchemy import SQLAlchemyModelFactory
131
+ with SessionManager.get_instance().get_session() as model_factory_session:
132
+ # set global database sessions for model factories to avoid lazy loading issues
133
+ set_factory_session(model_factory_session)
134
+ set_polyfactory_session(model_factory_session)
46
135
 
47
- # Ensure that all factories use the same session
48
- for factory in SQLAlchemyModelFactory.__subclasses__():
49
- factory._meta.sqlalchemy_session = factory_session
50
- factory._meta.sqlalchemy_session_persistence = "commit"
51
- except ImportError:
52
- pass
136
+ test_session_token = _test_session.set(model_factory_session)
53
137
 
54
- yield
138
+ try:
139
+ yield
140
+ finally:
141
+ _test_session.reset(test_session_token)
55
142
  finally:
56
143
  logger.debug("rolling back transaction")
57
144
 
@@ -1,10 +1,110 @@
1
+ import os
2
+ from typing import Iterable
3
+
4
+ import pytest
1
5
  from sqlmodel import SQLModel
2
6
 
3
7
  from ..logger import logger
4
8
  from ..session_manager import get_engine
9
+ from pytest import Config
10
+ import typing as t
11
+
12
+ T = t.TypeVar("T")
13
+
14
+
15
+ def _normalize_to_list_of_strings(str_or_list: list[str] | str) -> list[str]:
16
+ if isinstance(str_or_list, list):
17
+ return str_or_list
18
+
19
+ raw_list = str_or_list.split(",")
20
+ return [entry.strip() for entry in raw_list if entry and entry.strip()]
21
+
22
+
23
+ def _get_pytest_option(
24
+ config: Config, key: str, *, cast: t.Callable[[t.Any], T] | None = str
25
+ ) -> T | None:
26
+ if not config:
27
+ return None
28
+
29
+ try:
30
+ val = config.getoption(key)
31
+ except ValueError:
32
+ val = None
33
+
34
+ if val is None:
35
+ val = config.getini(key)
36
+
37
+ if val is not None:
38
+ if cast:
39
+ return cast(val)
40
+
41
+ return val
42
+
43
+ return None
44
+
45
+
46
+ def _normalize_preserve_tables(raw: Iterable[str]) -> list[str]:
47
+ """Normalize user supplied table list: strip, dedupe (order not preserved).
48
+
49
+ Returns a sorted list (case-insensitive sort while preserving original casing
50
+ for readability in logs).
51
+ """
52
+
53
+ cleaned = {name.strip() for name in raw if name and name.strip()}
54
+ # deterministic order: casefold sort
55
+ return sorted(cleaned, key=lambda s: s.casefold())
56
+
57
+
58
+ def _get_excluded_tables(
59
+ pytest_config: Config | None, preserve_tables: list[str] | None
60
+ ) -> list[str]:
61
+ """Resolve list of tables to exclude (i.e. *preserve* / NOT truncate).
62
+
63
+ Precedence (lowest -> highest):
64
+ 1. pytest ini option ``activemodel_preserve_tables`` (if available)
65
+ 2. Environment variable ``ACTIVEMODEL_PRESERVE_TABLES`` (comma separated)
66
+ 3. Function argument ``preserve_tables``
67
+
68
+ Behavior:
69
+ * If user supplies nothing via any channel, defaults to ["alembic_version"].
70
+ * Case-insensitive matching during truncation; returned list is normalized
71
+ (deduped, sorted) for deterministic logging.
72
+ * Emits a warning only when the ini option is *explicitly* specified but empty after normalization.
73
+ """
74
+
75
+ # 1. pytest ini option (registered as type="linelist" -> typically list[str])
76
+ ini_tables = (
77
+ _get_pytest_option(
78
+ pytest_config,
79
+ "activemodel_preserve_tables",
80
+ cast=_normalize_to_list_of_strings,
81
+ )
82
+ or []
83
+ )
84
+
85
+ # 2. environment variable
86
+ env_var = os.getenv("ACTIVEMODEL_PRESERVE_TABLES", "")
87
+ env_tables = _normalize_to_list_of_strings(env_var)
88
+
89
+ # 3. function argument
90
+ arg_tables = preserve_tables or []
91
+
92
+ # Consider customization only if any non-empty source provided values OR the function arg explicitly passed
93
+ combined_raw = [*ini_tables, *env_tables, *arg_tables]
94
+
95
+ # if no user customization, force alembic_version
96
+ if not combined_raw:
97
+ return ["alembic_version"]
98
+
99
+ normalized = _normalize_preserve_tables(combined_raw)
100
+ logger.debug(f"excluded tables for truncation: {normalized}")
101
+
102
+ return normalized
5
103
 
6
104
 
7
- def database_reset_truncate():
105
+ def database_reset_truncate(
106
+ preserve_tables: list[str] | None = None, pytest_config: Config | None = None
107
+ ):
8
108
  """
9
109
  Transaction is most likely the better way to go, but there are some scenarios where the session override
10
110
  logic does not work properly and you need to truncate tables back to their original state.
@@ -28,18 +128,19 @@ def database_reset_truncate():
28
128
 
29
129
  logger.info("truncating database")
30
130
 
31
- # TODO get additonal tables to preserve from config
32
- exception_tables = ["alembic_version"]
131
+ # Determine excluded (preserved) tables and build case-insensitive lookup set
132
+ exception_tables = _get_excluded_tables(pytest_config, preserve_tables)
133
+ exception_lookup = {t.lower() for t in exception_tables}
33
134
 
34
- assert (
35
- SQLModel.metadata.sorted_tables
36
- ), "No model metadata. Ensure model metadata is imported before running truncate_db"
135
+ assert SQLModel.metadata.sorted_tables, (
136
+ "No model metadata. Ensure model metadata is imported before running truncate_db"
137
+ )
37
138
 
38
139
  with get_engine().connect() as connection:
39
140
  for table in reversed(SQLModel.metadata.sorted_tables):
40
141
  transaction = connection.begin()
41
142
 
42
- if table.name not in exception_tables:
143
+ if table.name.lower() not in exception_lookup:
43
144
  logger.debug(f"truncating table={table.name}")
44
145
  connection.execute(table.delete())
45
146
 
@@ -1,11 +1,13 @@
1
1
  import sqlmodel as sm
2
2
  from sqlmodel.sql.expression import SelectOfScalar
3
3
 
4
+ from activemodel.types.sqlalchemy_protocol import SQLAlchemyQueryMethods
5
+
4
6
  from .session_manager import get_session
5
7
  from .utils import compile_sql
6
8
 
7
9
 
8
- class QueryWrapper[T: sm.SQLModel]:
10
+ class QueryWrapper[T: sm.SQLModel](SQLAlchemyQueryMethods[T]):
9
11
  """
10
12
  Make it easy to run queries off of a model
11
13
  """
@@ -46,13 +48,20 @@ class QueryWrapper[T: sm.SQLModel]:
46
48
  with get_session() as session:
47
49
  return session.scalar(sm.select(sm.func.count()).select_from(self.target))
48
50
 
51
+ def scalar(self):
52
+ """
53
+ >>>
54
+ """
55
+ with get_session() as session:
56
+ return session.scalar(self.target)
57
+
49
58
  def exec(self):
50
59
  with get_session() as session:
51
60
  return session.exec(self.target)
52
61
 
53
62
  def delete(self):
54
63
  with get_session() as session:
55
- session.delete(self.target)
64
+ return session.delete(self.target)
56
65
 
57
66
  def __getattr__(self, name):
58
67
  """
@@ -87,4 +96,5 @@ class QueryWrapper[T: sm.SQLModel]:
87
96
  return compile_sql(self.target)
88
97
 
89
98
  def __repr__(self) -> str:
99
+ # TODO we should improve structure of this a bit more, maybe wrap in <> or something?
90
100
  return f"{self.__class__.__name__}: Current SQL:\n{self.sql()}"
@@ -13,6 +13,8 @@ from pydantic import BaseModel
13
13
  from sqlalchemy import Connection, Engine
14
14
  from sqlmodel import Session, create_engine
15
15
 
16
+ ACTIVEMODEL_LOG_SQL = config("ACTIVEMODEL_LOG_SQL", cast=bool, default=False)
17
+
16
18
 
17
19
  def _serialize_pydantic_model(model: BaseModel | list[BaseModel] | None) -> str | None:
18
20
  """
@@ -50,18 +52,26 @@ class SessionManager:
50
52
  "optionally specify a specific session connection to use for all get_session() calls, useful for testing and migrations"
51
53
 
52
54
  @classmethod
53
- def get_instance(cls, database_url: str | None = None) -> "SessionManager":
55
+ def get_instance(
56
+ cls,
57
+ database_url: str | None = None,
58
+ *,
59
+ engine_options: dict[str, t.Any] | None = None,
60
+ ) -> "SessionManager":
54
61
  if cls._instance is None:
55
62
  assert database_url is not None, (
56
63
  "Database URL required for first initialization"
57
64
  )
58
- cls._instance = cls(database_url)
65
+ cls._instance = cls(database_url, engine_options=engine_options)
59
66
 
60
67
  return cls._instance
61
68
 
62
- def __init__(self, database_url: str):
69
+ def __init__(
70
+ self, database_url: str, *, engine_options: dict[str, t.Any] | None = None
71
+ ):
63
72
  self._database_url = database_url
64
73
  self._engine = None
74
+ self._engine_options: dict = engine_options or {}
65
75
 
66
76
  self.session_connection = None
67
77
 
@@ -72,11 +82,12 @@ class SessionManager:
72
82
  self._database_url,
73
83
  # NOTE very important! This enables pydantic models to be serialized for JSONB columns
74
84
  json_serializer=_serialize_pydantic_model,
75
- # TODO move to a constants area
76
- echo=config("ACTIVEMODEL_LOG_SQL", cast=bool, default=False),
85
+ echo=ACTIVEMODEL_LOG_SQL,
86
+ echo_pool=ACTIVEMODEL_LOG_SQL,
77
87
  # https://docs.sqlalchemy.org/en/20/core/pooling.html#disconnect-handling-pessimistic
78
88
  pool_pre_ping=True,
79
89
  # some implementations include `future=True` but it's not required anymore
90
+ **self._engine_options,
80
91
  )
81
92
 
82
93
  return self._engine
@@ -99,9 +110,10 @@ class SessionManager:
99
110
  return Session(self.get_engine())
100
111
 
101
112
 
102
- def init(database_url: str):
113
+ # TODO would be great one day to type engine_options as the SQLAlchemy EngineOptions
114
+ def init(database_url: str, *, engine_options: dict[str, t.Any] | None = None):
103
115
  "configure activemodel to connect to a specific database"
104
- return SessionManager.get_instance(database_url)
116
+ return SessionManager.get_instance(database_url, engine_options=engine_options)
105
117
 
106
118
 
107
119
  def get_engine():
@@ -128,18 +140,40 @@ a place to persist a session to use globally across the application.
128
140
 
129
141
 
130
142
  @contextlib.contextmanager
131
- def global_session():
143
+ def global_session(session: Session | None = None):
132
144
  """
133
- Generate a session shared across all activemodel calls.
145
+ Generate a session and share it across all activemodel calls.
134
146
 
135
147
  Alternatively, you can pass a session to use globally into the context manager, which is helpful for migrations
136
148
  and testing.
149
+
150
+ This may only be called a single time per callstack. There is one exception: if you call this multiple times
151
+ and pass in the same session reference, it will result in a noop.
152
+
153
+ Args:
154
+ session: Use an existing session instead of creating a new one
137
155
  """
138
156
 
157
+ if session is not None and _session_context.get() is session:
158
+ yield session
159
+ return
160
+
139
161
  if _session_context.get() is not None:
140
- raise RuntimeError("global session already set")
162
+ raise RuntimeError("ActiveModel: global session already set")
141
163
 
142
- with SessionManager.get_instance().get_session() as s:
164
+ @contextlib.contextmanager
165
+ def manage_existing_session():
166
+ "if an existing session already exists, use it without triggering another __enter__"
167
+ yield session
168
+
169
+ # Use provided session or create a new one
170
+ session_context = (
171
+ manage_existing_session()
172
+ if session is not None
173
+ else SessionManager.get_instance().get_session()
174
+ )
175
+
176
+ with session_context as s:
143
177
  token = _session_context.set(s)
144
178
 
145
179
  try:
@@ -0,0 +1,10 @@
1
+ # IMPORTANT: This file is auto-generated. Do not edit directly.
2
+
3
+ from typing import Protocol, TypeVar, Any, Generic
4
+ import sqlmodel as sm
5
+ from sqlalchemy.sql.base import _NoArg
6
+ from typing import TYPE_CHECKING
7
+
8
+
9
+ class SQLAlchemyQueryMethods[T: sm.SQLModel](Protocol):
10
+ pass
@@ -0,0 +1,132 @@
1
+ # IMPORTANT: This file is auto-generated. Do not edit directly.
2
+
3
+ from typing import Protocol, TypeVar, Any, Generic
4
+ import sqlmodel as sm
5
+ from sqlalchemy.sql.base import _NoArg
6
+
7
+ from ..query_wrapper import QueryWrapper
8
+
9
+ class SQLAlchemyQueryMethods[T: sm.SQLModel](Protocol):
10
+ """Protocol defining SQLAlchemy query methods forwarded by QueryWrapper.__getattr__"""
11
+
12
+ def add_columns(self, *entities: Any) -> "QueryWrapper[T]": ...
13
+ def add_cte(self, *ctes: Any, nest_here: Any = False) -> "QueryWrapper[T]": ...
14
+ def alias(self, name: Any = None, flat: Any = False) -> "QueryWrapper[T]": ...
15
+ def argument_for(self, argument_name: Any, default: Any) -> "QueryWrapper[T]": ...
16
+ def as_scalar(
17
+ self,
18
+ ) -> "QueryWrapper[T]": ...
19
+ def column(self, column: Any) -> "QueryWrapper[T]": ...
20
+ def compare(self, other: Any, **kw: Any) -> "QueryWrapper[T]": ...
21
+ def compile(
22
+ self, bind: Any = None, dialect: Any = None, **kw: Any
23
+ ) -> "QueryWrapper[T]": ...
24
+ def correlate(self, *fromclauses: Any) -> "QueryWrapper[T]": ...
25
+ def correlate_except(self, *fromclauses: Any) -> "QueryWrapper[T]": ...
26
+ def corresponding_column(
27
+ self, column: Any, require_embedded: Any = False
28
+ ) -> "QueryWrapper[T]": ...
29
+ def cte(
30
+ self, name: Any = None, recursive: Any = False, nesting: Any = False
31
+ ) -> "QueryWrapper[T]": ...
32
+ def distinct(self, *expr: Any) -> "QueryWrapper[T]": ...
33
+ def except_(self, *other: Any) -> "QueryWrapper[T]": ...
34
+ def except_all(self, *other: Any) -> "QueryWrapper[T]": ...
35
+ def execution_options(self, **kw: Any) -> "QueryWrapper[T]": ...
36
+ def exists(
37
+ self,
38
+ ) -> "QueryWrapper[T]": ...
39
+ def fetch(
40
+ self,
41
+ count: Any,
42
+ with_ties: Any = False,
43
+ percent: Any = False,
44
+ **dialect_kw: Any,
45
+ ) -> "QueryWrapper[T]": ...
46
+ def filter(self, *criteria: Any) -> "QueryWrapper[T]": ...
47
+ def filter_by(self, **kwargs: Any) -> "QueryWrapper[T]": ...
48
+ def from_statement(self, statement: Any) -> "QueryWrapper[T]": ...
49
+ def get_children(self, **kw: Any) -> "QueryWrapper[T]": ...
50
+ def get_execution_options(
51
+ self,
52
+ ) -> "QueryWrapper[T]": ...
53
+ def get_final_froms(
54
+ self,
55
+ ) -> "QueryWrapper[T]": ...
56
+ def get_label_style(
57
+ self,
58
+ ) -> "QueryWrapper[T]": ...
59
+ def group_by(
60
+ self, _GenerativeSelect__first: Any = _NoArg.NO_ARG, *clauses: Any
61
+ ) -> "QueryWrapper[T]": ...
62
+ def having(self, *having: Any) -> "QueryWrapper[T]": ...
63
+ def intersect(self, *other: Any) -> "QueryWrapper[T]": ...
64
+ def intersect_all(self, *other: Any) -> "QueryWrapper[T]": ...
65
+ def is_derived_from(self, fromclause: Any) -> "QueryWrapper[T]": ...
66
+ def join(
67
+ self, target: Any, onclause: Any = None, isouter: Any = False, full: Any = False
68
+ ) -> "QueryWrapper[T]": ...
69
+ def join_from(
70
+ self,
71
+ from_: Any,
72
+ target: Any,
73
+ onclause: Any = None,
74
+ isouter: Any = False,
75
+ full: Any = False,
76
+ ) -> "QueryWrapper[T]": ...
77
+ def label(self, name: Any) -> "QueryWrapper[T]": ...
78
+ def lateral(self, name: Any = None) -> "QueryWrapper[T]": ...
79
+ def limit(self, limit: Any) -> "QueryWrapper[T]": ...
80
+ def memoized_instancemethod(
81
+ self,
82
+ ) -> "QueryWrapper[T]": ...
83
+ def offset(self, offset: Any) -> "QueryWrapper[T]": ...
84
+ def options(self, *options: Any) -> "QueryWrapper[T]": ...
85
+ def order_by(
86
+ self, _GenerativeSelect__first: Any = _NoArg.NO_ARG, *clauses: Any
87
+ ) -> QueryWrapper[T]: ...
88
+ def outerjoin(
89
+ self, target: Any, onclause: Any = None, full: Any = False
90
+ ) -> "QueryWrapper[T]": ...
91
+ def outerjoin_from(
92
+ self, from_: Any, target: Any, onclause: Any = None, full: Any = False
93
+ ) -> "QueryWrapper[T]": ...
94
+ def params(
95
+ self, _ClauseElement__optionaldict: Any = None, **kwargs: Any
96
+ ) -> "QueryWrapper[T]": ...
97
+ def prefix_with(self, *prefixes: Any, dialect: Any = "*") -> "QueryWrapper[T]": ...
98
+ def reduce_columns(self, only_synonyms: Any = True) -> "QueryWrapper[T]": ...
99
+ def replace_selectable(self, old: Any, alias: Any) -> "QueryWrapper[T]": ...
100
+ def scalar_subquery(
101
+ self,
102
+ ) -> "QueryWrapper[T]": ...
103
+ def select(self, *arg: Any, **kw: Any) -> "QueryWrapper[T]": ...
104
+ def select_from(self, *froms: Any) -> "QueryWrapper[T]": ...
105
+ def self_group(self, against: Any = None) -> "QueryWrapper[T]": ...
106
+ def set_label_style(self, style: Any) -> "QueryWrapper[T]": ...
107
+ def slice(self, start: Any, stop: Any) -> "QueryWrapper[T]": ...
108
+ def subquery(self, name: Any = None) -> "QueryWrapper[T]": ...
109
+ def suffix_with(self, *suffixes: Any, dialect: Any = "*") -> "QueryWrapper[T]": ...
110
+ def union(self, *other: Any) -> "QueryWrapper[T]": ...
111
+ def union_all(self, *other: Any) -> "QueryWrapper[T]": ...
112
+ def unique_params(
113
+ self, _ClauseElement__optionaldict: Any = None, **kwargs: Any
114
+ ) -> "QueryWrapper[T]": ...
115
+ def where(self, *whereclause: Any) -> "QueryWrapper[T]": ...
116
+ def with_for_update(
117
+ self,
118
+ nowait: Any = False,
119
+ read: Any = False,
120
+ of: Any = None,
121
+ skip_locked: Any = False,
122
+ key_share: Any = False,
123
+ ) -> "QueryWrapper[T]": ...
124
+ def with_hint(
125
+ self, selectable: Any, text: Any, dialect_name: Any = "*"
126
+ ) -> "QueryWrapper[T]": ...
127
+ def with_only_columns(
128
+ self, *entities: Any, maintain_column_froms: Any = False, **_Select__kw: Any
129
+ ) -> "QueryWrapper[T]": ...
130
+ def with_statement_hint(
131
+ self, text: Any, dialect_name: Any = "*"
132
+ ) -> "QueryWrapper[T]": ...
@@ -76,6 +76,7 @@ class TypeIDType(types.TypeDecorator):
76
76
  if isinstance(value, str):
77
77
  # no prefix, raw UUID, let's coerce it into a UUID which SQLAlchemy can handle
78
78
  # ex: '01942886-7afc-7129-8f57-db09137ed002'
79
+ # if an invalid uuid is passed, `ValueError('badly formed hexadecimal UUID string')` will be raised
79
80
  return UUID(value)
80
81
 
81
82
  if isinstance(value, TypeID):
activemodel/utils.py CHANGED
@@ -1,18 +1,12 @@
1
- import inspect
2
- import pkgutil
3
- import sys
4
- from types import ModuleType
5
-
6
1
  from sqlalchemy import text
7
- from sqlmodel import SQLModel
8
2
  from sqlmodel.sql.expression import SelectOfScalar
9
3
 
10
- from .logger import logger
11
4
  from .session_manager import get_engine, get_session
12
5
 
13
6
 
14
- def compile_sql(target: SelectOfScalar):
15
- "convert a query into SQL, helpful for debugging"
7
+ def compile_sql(target: SelectOfScalar) -> str:
8
+ "convert a query into SQL, helpful for debugging sqlalchemy/sqlmodel queries"
9
+
16
10
  dialect = get_engine().dialect
17
11
  # TODO I wonder if we could store the dialect to avoid getting an engine reference
18
12
  compiled = target.compile(dialect=dialect, compile_kwargs={"literal_binds": True})
@@ -25,36 +19,6 @@ def raw_sql_exec(raw_query: str):
25
19
  session.execute(text(raw_query))
26
20
 
27
21
 
28
- def find_all_sqlmodels(module: ModuleType):
29
- """Import all model classes from module and submodules into current namespace."""
30
-
31
- logger.debug(f"Starting model import from module: {module.__name__}")
32
- model_classes = {}
33
-
34
- # Walk through all submodules
35
- for loader, module_name, is_pkg in pkgutil.walk_packages(module.__path__):
36
- full_name = f"{module.__name__}.{module_name}"
37
- logger.debug(f"Importing submodule: {full_name}")
38
-
39
- # Check if module is already imported
40
- if full_name in sys.modules:
41
- submodule = sys.modules[full_name]
42
- else:
43
- logger.warning(
44
- f"Module not found in sys.modules, not importing: {full_name}"
45
- )
46
- continue
47
-
48
- # Get all classes from module
49
- for name, obj in inspect.getmembers(submodule):
50
- if inspect.isclass(obj) and issubclass(obj, SQLModel) and obj != SQLModel:
51
- logger.debug(f"Found model class: {name}")
52
- model_classes[name] = obj
53
-
54
- logger.debug(f"Completed model import. Found {len(model_classes)} models")
55
- return model_classes
56
-
57
-
58
22
  def hash_function_code(func):
59
23
  "get sha of a function to easily assert that it hasn't changed"
60
24
 
@@ -1,15 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: activemodel
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: Make SQLModel more like an a real ORM
5
5
  Project-URL: Repository, https://github.com/iloveitaly/activemodel
6
6
  Author-email: Michael Bianco <iloveitaly@gmail.com>
7
7
  License-File: LICENSE
8
8
  Keywords: activemodel,activerecord,orm,sqlalchemy,sqlmodel
9
9
  Requires-Python: >=3.10
10
- Requires-Dist: pydash>=8.0.4
11
10
  Requires-Dist: python-decouple-typed>=3.11.0
12
11
  Requires-Dist: sqlmodel>=0.0.22
12
+ Requires-Dist: textcase>=0.4.0
13
13
  Requires-Dist: typeid-python>=0.3.1
14
14
  Description-Content-Type: text/markdown
15
15
 
@@ -59,15 +59,17 @@ from sqlmodel import SQLModel
59
59
 
60
60
  SQLModel.metadata.create_all(get_engine())
61
61
 
62
- # now you can create a user!
62
+ # now you can create a user! without managing sessions!
63
63
  User(a_field="a").save()
64
64
  ```
65
65
 
66
66
  Maybe you like JSON:
67
67
 
68
68
  ```python
69
- from activemodel import BaseModel
69
+ from sqlalchemy.dialects.postgresql import JSONB
70
70
  from pydantic import BaseModel as PydanticBaseModel
71
+
72
+ from activemodel import BaseModel
71
73
  from activemodel.mixins import PydanticJSONMixin, TypeIDMixin, TimestampsMixin
72
74
 
73
75
  class SubObject(PydanticBaseModel):
@@ -81,11 +83,28 @@ class User(
81
83
  TypeIDMixin("user"),
82
84
  table=True
83
85
  ):
84
- list_field: list[SubObject] = Field(sa_type=JSONB())
86
+ list_field: list[SubObject] = Field(sa_type=JSONB)
85
87
  ```
86
88
 
89
+ You'll probably want to query the model. Look ma, no sessions!
90
+
91
+ ```python
92
+ User.where(id="user_123").all()
93
+
94
+ # or, even better, for this case
95
+ User.one("user_123")
96
+ ```
97
+
98
+ Magically creating sessions for DB operations is one of the main problems this project tackles. Even better, you can set
99
+ a single session object to be used for all DB operations. This is helpful for DB transactions, [specifically rolling back
100
+ DB operations on each test.](#pytest)
101
+
87
102
  ## Usage
88
103
 
104
+ ### Pytest
105
+
106
+ TODO detail out truncation and transactions
107
+
89
108
  ### Integrating Alembic
90
109
 
91
110
  `alembic init` will not work out of the box. You need to mutate a handful of files:
@@ -207,6 +226,7 @@ SQLModel & SQLAlchemy are tricky. Here are some useful internal tricks:
207
226
  * Set the value on a field, without marking it as dirty `attributes.set_committed_value(instance, field_name, val)`
208
227
  * Is a model dirty `instance_state(instance).modified`
209
228
  * `select(Table).outerjoin??` won't work in a ipython session, but `Table.__table__.outerjoin??` will. `__table__` is a reference to the underlying SQLAlchemy table record.
229
+ * `get_engine().pool.stats()` is helpful for inspecting connection pools and limits\
210
230
 
211
231
  ### TypeID
212
232
 
@@ -270,6 +290,7 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
270
290
 
271
291
  * https://github.com/woofz/sqlmodel-basecrud
272
292
  * https://github.com/0xthiagomartins/sqlmodel-controller
293
+ * https://github.com/litestar-org/advanced-alchemy?tab=readme-ov-file
273
294
 
274
295
  ## Inspiration
275
296
 
@@ -282,4 +303,4 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
282
303
 
283
304
  ## Upstream Changes
284
305
 
285
- - [ ] https://github.com/fastapi/sqlmodel/pull/1293
306
+ - [ ] https://github.com/fastapi/sqlmodel/pull/1293
@@ -0,0 +1,30 @@
1
+ activemodel/__init__.py,sha256=q_lHQyIM70ApvjduTo9GtenQjJXsfYZsAAquD_51kF4,137
2
+ activemodel/base_model.py,sha256=zku7nKOcN4_YpIpHjP3_sWeJWGTf141gUYtPPilY0MU,17359
3
+ activemodel/celery.py,sha256=L1vKcO_HoPA5ZCfsXjxgPpDUMYDuoQMakGA9rppN7Lo,897
4
+ activemodel/errors.py,sha256=wycWYmk9ws4TZpxvTdtXVy2SFESb8NqKgzdivBoF0vw,115
5
+ activemodel/get_column_from_field_patch.py,sha256=wAEDm_ZvSqyJwfgkXVpxsevw11hd-7VLy7zuJG8Ak7Y,4986
6
+ activemodel/logger.py,sha256=vU7QiGSy_AJuJFmClUocqIJ-Ltku_8C24ZU8L6fLJR0,53
7
+ activemodel/query_wrapper.py,sha256=gXpEAAFpRqQNUbTubTX-qkMNHeztFnyxtjdJbplCkKA,2867
8
+ activemodel/session_manager.py,sha256=Aqc96ByAgrcuRRPsMZiTN3tlSXUFZvM3U7N8vCO2TIk,6720
9
+ activemodel/utils.py,sha256=tZlAk0G46g6dwYuN7dIr8xU9QC_aLZYqjDXYkGiCtUg,888
10
+ activemodel/cli/__init__.py,sha256=HrgJjB5pRuE6hbwgy0Dw4oHvGZ47kH0LPVAdG9l6-vw,5021
11
+ activemodel/mixins/__init__.py,sha256=05EQl2u_Wgf_wkly-GTaTsR7zWpmpKcb96Js7r_rZTw,160
12
+ activemodel/mixins/pydantic_json.py,sha256=0pprGZA95BGZL4WOh--NJcvxLWey4YW85lLk4GGTjFM,3530
13
+ activemodel/mixins/soft_delete.py,sha256=Ax4mGsQI7AVTE8c4GiWxpyB_W179-dDct79GtjP0owU,461
14
+ activemodel/mixins/timestamps.py,sha256=C6QQNnzrNUOW1EAsMpEVpImEeTIYDMPP0wocEw2RDQw,1078
15
+ activemodel/mixins/typeid.py,sha256=777btWRUW6YBGPApeaEdHQaoKmwblehukHzmkKoXv6o,1340
16
+ activemodel/pytest/__init__.py,sha256=IJpD-BwJuPii5IxTJoOCryaq4_oyXNRj4RjlS5Plmc8,112
17
+ activemodel/pytest/factories.py,sha256=d4Mt9Lto8Pqd2bXuxDSt92IeEzTM5VGL3rQwG8jY4x4,3475
18
+ activemodel/pytest/plugin.py,sha256=QsHnaKmkFZjR_pUHLaU7tb47vrfcNU0zsvfBaN0g2f0,2509
19
+ activemodel/pytest/transaction.py,sha256=KMQ7jHSU9Bf14rPwdVAFxxR9mZ41uRAXwiSQ3HAzkw0,5801
20
+ activemodel/pytest/truncate.py,sha256=LeuG2fSq92oR-S1_gE-1Y3DhO48jw4ubMIRh2tb6U08,4780
21
+ activemodel/types/__init__.py,sha256=y5fiGVtPJxGEhuf-TvyrkhM2yaKRcIWo6XAx-CFFjM8,31
22
+ activemodel/types/sqlalchemy_protocol.py,sha256=2MSuGIp6pcIyiy8uK7qX3FLWABBMQOJGlIC969WRQdY,277
23
+ activemodel/types/sqlalchemy_protocol.pyi,sha256=SP4Z50SGcw6qSexGgNd_4g6E_sQwpIE44vgNT4ncmeI,5667
24
+ activemodel/types/typeid.py,sha256=qycqklKv5nKuCqjJRnxA-6MjtcWJ4vFUsAVBc1ySwfg,7865
25
+ activemodel/types/typeid_patch.py,sha256=y6kiCJQ_NzeKfuI4UtRAs7QW_nEog5RIA_-k4HUBMkU,575
26
+ activemodel-0.12.0.dist-info/METADATA,sha256=j9sLMrYMP-2pnGE5yFFYELUqgbO-H8uKNCFrpHgw-8Q,10724
27
+ activemodel-0.12.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
28
+ activemodel-0.12.0.dist-info/entry_points.txt,sha256=rytVrsNgUT4oDiW9RvRH6JBTHQn0hPZLK-jzQt3dY9s,51
29
+ activemodel-0.12.0.dist-info/licenses/LICENSE,sha256=L8mmpX47rB-xtJ_HsK0zpfO6viEjxbLYGn70BMp8os4,1071
30
+ activemodel-0.12.0.dist-info/RECORD,,
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ activemodel = activemodel.pytest.plugin
@@ -1,25 +0,0 @@
1
- activemodel/__init__.py,sha256=q_lHQyIM70ApvjduTo9GtenQjJXsfYZsAAquD_51kF4,137
2
- activemodel/base_model.py,sha256=2lUcmOxS1i1K9qdKEVjI9vLpbFltUlh-cAfvGPYbXlI,16709
3
- activemodel/celery.py,sha256=L1vKcO_HoPA5ZCfsXjxgPpDUMYDuoQMakGA9rppN7Lo,897
4
- activemodel/errors.py,sha256=wycWYmk9ws4TZpxvTdtXVy2SFESb8NqKgzdivBoF0vw,115
5
- activemodel/get_column_from_field_patch.py,sha256=wAEDm_ZvSqyJwfgkXVpxsevw11hd-7VLy7zuJG8Ak7Y,4986
6
- activemodel/logger.py,sha256=vU7QiGSy_AJuJFmClUocqIJ-Ltku_8C24ZU8L6fLJR0,53
7
- activemodel/query_wrapper.py,sha256=rNdvueppMse2MIi-RafTEC34GPGRal_wqH2CzhmlWS8,2520
8
- activemodel/session_manager.py,sha256=9Yb5sPOUginIC7M0oB3dvxkhaidX2iKGFpV3lpXkKzw,5454
9
- activemodel/utils.py,sha256=g17UqkphzTmb6YdpmYwT1TM00eDiXXuWn39-xNiu0AA,2112
10
- activemodel/mixins/__init__.py,sha256=05EQl2u_Wgf_wkly-GTaTsR7zWpmpKcb96Js7r_rZTw,160
11
- activemodel/mixins/pydantic_json.py,sha256=0pprGZA95BGZL4WOh--NJcvxLWey4YW85lLk4GGTjFM,3530
12
- activemodel/mixins/soft_delete.py,sha256=Ax4mGsQI7AVTE8c4GiWxpyB_W179-dDct79GtjP0owU,461
13
- activemodel/mixins/timestamps.py,sha256=Q-IFljeVVJQqw3XHdOi7dkqzefiVg1zhJvq_bldpmjg,992
14
- activemodel/mixins/typeid.py,sha256=VjhORJ-wf3HT43DMmez6MmZTWjH7fb5c-7Qcdwgdiqg,1331
15
- activemodel/pytest/__init__.py,sha256=W9KKQHbPkyq0jrMXaiL8hG2Nsbjy_LN9HhvgGm8W_7g,98
16
- activemodel/pytest/transaction.py,sha256=ln-3N5tXHT0fqy6a8m_NIYg5AXAeA2hDuftQtFxNqi4,2600
17
- activemodel/pytest/truncate.py,sha256=IGiPLkUm2yyOKww6c6CKcVbwi2xAAFBopx9q2ABfu8w,1582
18
- activemodel/types/__init__.py,sha256=y5fiGVtPJxGEhuf-TvyrkhM2yaKRcIWo6XAx-CFFjM8,31
19
- activemodel/types/typeid.py,sha256=Vqzete8IvZ5SHKf3DW2eKIWxweIZvUN2kjhLNuOl3Cc,7753
20
- activemodel/types/typeid_patch.py,sha256=y6kiCJQ_NzeKfuI4UtRAs7QW_nEog5RIA_-k4HUBMkU,575
21
- activemodel-0.11.0.dist-info/METADATA,sha256=tNJjq1XJNFXkua5Ui7XCr6DR_qtSQvbK_f_UPtCwHTE,9986
22
- activemodel-0.11.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
23
- activemodel-0.11.0.dist-info/entry_points.txt,sha256=YLX62TP_hR-n3HMBkdBex4W7XRiyOtIPkwy22puIjjQ,61
24
- activemodel-0.11.0.dist-info/licenses/LICENSE,sha256=L8mmpX47rB-xtJ_HsK0zpfO6viEjxbLYGn70BMp8os4,1071
25
- activemodel-0.11.0.dist-info/RECORD,,
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- activemodel = python_package_template:main