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 +23 -8
- activemodel/cli/__init__.py +147 -0
- activemodel/mixins/timestamps.py +4 -1
- activemodel/mixins/typeid.py +1 -1
- activemodel/pytest/__init__.py +1 -1
- activemodel/pytest/factories.py +102 -0
- activemodel/pytest/plugin.py +81 -0
- activemodel/pytest/transaction.py +103 -16
- activemodel/pytest/truncate.py +108 -7
- activemodel/query_wrapper.py +12 -2
- activemodel/session_manager.py +45 -11
- activemodel/types/sqlalchemy_protocol.py +10 -0
- activemodel/types/sqlalchemy_protocol.pyi +132 -0
- activemodel/types/typeid.py +1 -0
- activemodel/utils.py +3 -39
- {activemodel-0.11.0.dist-info → activemodel-0.12.0.dist-info}/METADATA +27 -6
- activemodel-0.12.0.dist-info/RECORD +30 -0
- activemodel-0.12.0.dist-info/entry_points.txt +2 -0
- activemodel-0.11.0.dist-info/RECORD +0 -25
- activemodel-0.11.0.dist-info/entry_points.txt +0 -2
- {activemodel-0.11.0.dist-info → activemodel-0.12.0.dist-info}/WHEEL +0 -0
- {activemodel-0.11.0.dist-info → activemodel-0.12.0.dist-info}/licenses/LICENSE +0 -0
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
|
|
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
|
-
|
|
163
|
-
|
|
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
|
|
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()
|
activemodel/mixins/timestamps.py
CHANGED
|
@@ -20,7 +20,10 @@ class TimestampsMixin:
|
|
|
20
20
|
>>> class MyModel(TimestampsMixin, SQLModel):
|
|
21
21
|
>>> pass
|
|
22
22
|
|
|
23
|
-
|
|
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(
|
activemodel/mixins/typeid.py
CHANGED
|
@@ -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:
|
activemodel/pytest/__init__.py
CHANGED
|
@@ -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
|
|
14
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
activemodel/pytest/truncate.py
CHANGED
|
@@ -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
|
-
#
|
|
32
|
-
exception_tables =
|
|
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
|
-
|
|
36
|
-
)
|
|
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
|
|
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
|
|
activemodel/query_wrapper.py
CHANGED
|
@@ -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()}"
|
activemodel/session_manager.py
CHANGED
|
@@ -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(
|
|
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__(
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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]": ...
|
activemodel/types/typeid.py
CHANGED
|
@@ -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.
|
|
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
|
|
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,,
|
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|