activemodel 0.3.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. activemodel-0.5.0/PKG-INFO +66 -0
  2. activemodel-0.5.0/README.md +51 -0
  3. activemodel-0.5.0/activemodel/__init__.py +2 -0
  4. activemodel-0.5.0/activemodel/_session_manager.py +153 -0
  5. activemodel-0.5.0/activemodel/base_model.py +228 -0
  6. activemodel-0.5.0/activemodel/logger.py +3 -0
  7. activemodel-0.5.0/activemodel/mixins/__init__.py +2 -0
  8. {activemodel-0.3.0/activemodel → activemodel-0.5.0/activemodel/mixins}/timestamps.py +3 -4
  9. activemodel-0.5.0/activemodel/mixins/typeid.py +36 -0
  10. activemodel-0.5.0/activemodel/pytest/__init__.py +2 -0
  11. activemodel-0.5.0/activemodel/pytest/transaction.py +51 -0
  12. activemodel-0.5.0/activemodel/pytest/truncate.py +46 -0
  13. {activemodel-0.3.0 → activemodel-0.5.0}/activemodel/query_wrapper.py +4 -12
  14. activemodel-0.5.0/activemodel/session_manager.py +62 -0
  15. activemodel-0.5.0/activemodel/types/typeid.py +56 -0
  16. activemodel-0.5.0/activemodel/utils.py +15 -0
  17. activemodel-0.5.0/activemodel.egg-info/PKG-INFO +66 -0
  18. activemodel-0.5.0/activemodel.egg-info/SOURCES.txt +23 -0
  19. activemodel-0.5.0/activemodel.egg-info/requires.txt +4 -0
  20. {activemodel-0.3.0 → activemodel-0.5.0}/pyproject.toml +3 -1
  21. activemodel-0.3.0/PKG-INFO +0 -34
  22. activemodel-0.3.0/README.md +0 -21
  23. activemodel-0.3.0/activemodel/__init__.py +0 -6
  24. activemodel-0.3.0/activemodel/base_model.py +0 -137
  25. activemodel-0.3.0/activemodel.egg-info/PKG-INFO +0 -34
  26. activemodel-0.3.0/activemodel.egg-info/SOURCES.txt +0 -13
  27. activemodel-0.3.0/activemodel.egg-info/requires.txt +0 -2
  28. {activemodel-0.3.0 → activemodel-0.5.0}/LICENSE +0 -0
  29. {activemodel-0.3.0 → activemodel-0.5.0}/activemodel.egg-info/dependency_links.txt +0 -0
  30. {activemodel-0.3.0 → activemodel-0.5.0}/activemodel.egg-info/entry_points.txt +0 -0
  31. {activemodel-0.3.0 → activemodel-0.5.0}/activemodel.egg-info/top_level.txt +0 -0
  32. {activemodel-0.3.0 → activemodel-0.5.0}/setup.cfg +0 -0
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.1
2
+ Name: activemodel
3
+ Version: 0.5.0
4
+ Summary: Make SQLModel more like an a real ORM
5
+ Author-email: Michael Bianco <iloveitaly@gmail.com>
6
+ Project-URL: Repository, https://github.com/iloveitaly/activemodel
7
+ Keywords: sqlmodel,orm,activerecord,activemodel,sqlalchemy
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: pydash>=8.0.4
12
+ Requires-Dist: python-decouple-typed>=3.11.0
13
+ Requires-Dist: sqlmodel>=0.0.22
14
+ Requires-Dist: typeid-python>=0.3.1
15
+
16
+ # ActiveModel: ORM Wrapper for SQLModel
17
+
18
+ No, this isn't *really* [ActiveModel](https://guides.rubyonrails.org/active_model_basics.html). It's just a wrapper around SQLModel that provides a more ActiveRecord-like interface.
19
+
20
+ SQLModel is *not* an ORM. It's a SQL query builder and a schema definition tool.
21
+
22
+ This package provides a thin wrapper around SQLModel that provides a more ActiveRecord-like interface with things like:
23
+
24
+ * Timestamp column mixins
25
+ * Lifecycle hooks
26
+
27
+ ## Limitations
28
+
29
+ ### Validation
30
+
31
+ SQLModel does not currently support pydantic validations (when `table=True`). This is very surprising, but is actually the intended functionality:
32
+
33
+ * https://github.com/fastapi/sqlmodel/discussions/897
34
+ * https://github.com/fastapi/sqlmodel/pull/1041
35
+ * https://github.com/fastapi/sqlmodel/issues/453
36
+ * https://github.com/fastapi/sqlmodel/issues/52#issuecomment-1311987732
37
+
38
+ For validation:
39
+
40
+ * When consuming API data, use a separate shadow model to validate the data with `table=False` and then inherit from that model in a model with `table=True`.
41
+ * When validating ORM data, use SQL Alchemy hooks.
42
+
43
+ <!--
44
+
45
+ This looks neat
46
+ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d2a4/src/my_model/model.py#L155
47
+ schema_extra={
48
+ 'pattern': r'^[a-z0-9_\-\.]+\@[a-z0-9_\-\.]+\.[a-z\.]+$'
49
+ },
50
+
51
+ extra constraints
52
+
53
+ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d2a4/src/my_model/model.py#L424C1-L426C6
54
+ -->
55
+ ## Related Projects
56
+
57
+ * https://github.com/woofz/sqlmodel-basecrud
58
+
59
+ ## Inspiration
60
+
61
+ * https://github.com/peterdresslar/fastapi-sqlmodel-alembic-pg
62
+ * [Albemic instructions](https://github.com/fastapi/sqlmodel/pull/899/files)
63
+ * https://github.com/fastapiutils/fastapi-utils/
64
+ * https://github.com/fastapi/full-stack-fastapi-template
65
+ * https://github.com/DarylStark/my_data/
66
+ * https://github.com/petrgazarov/FastAPI-app/tree/main/fastapi_app
@@ -0,0 +1,51 @@
1
+ # ActiveModel: ORM Wrapper for SQLModel
2
+
3
+ No, this isn't *really* [ActiveModel](https://guides.rubyonrails.org/active_model_basics.html). It's just a wrapper around SQLModel that provides a more ActiveRecord-like interface.
4
+
5
+ SQLModel is *not* an ORM. It's a SQL query builder and a schema definition tool.
6
+
7
+ This package provides a thin wrapper around SQLModel that provides a more ActiveRecord-like interface with things like:
8
+
9
+ * Timestamp column mixins
10
+ * Lifecycle hooks
11
+
12
+ ## Limitations
13
+
14
+ ### Validation
15
+
16
+ SQLModel does not currently support pydantic validations (when `table=True`). This is very surprising, but is actually the intended functionality:
17
+
18
+ * https://github.com/fastapi/sqlmodel/discussions/897
19
+ * https://github.com/fastapi/sqlmodel/pull/1041
20
+ * https://github.com/fastapi/sqlmodel/issues/453
21
+ * https://github.com/fastapi/sqlmodel/issues/52#issuecomment-1311987732
22
+
23
+ For validation:
24
+
25
+ * When consuming API data, use a separate shadow model to validate the data with `table=False` and then inherit from that model in a model with `table=True`.
26
+ * When validating ORM data, use SQL Alchemy hooks.
27
+
28
+ <!--
29
+
30
+ This looks neat
31
+ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d2a4/src/my_model/model.py#L155
32
+ schema_extra={
33
+ 'pattern': r'^[a-z0-9_\-\.]+\@[a-z0-9_\-\.]+\.[a-z\.]+$'
34
+ },
35
+
36
+ extra constraints
37
+
38
+ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d2a4/src/my_model/model.py#L424C1-L426C6
39
+ -->
40
+ ## Related Projects
41
+
42
+ * https://github.com/woofz/sqlmodel-basecrud
43
+
44
+ ## Inspiration
45
+
46
+ * https://github.com/peterdresslar/fastapi-sqlmodel-alembic-pg
47
+ * [Albemic instructions](https://github.com/fastapi/sqlmodel/pull/899/files)
48
+ * https://github.com/fastapiutils/fastapi-utils/
49
+ * https://github.com/fastapi/full-stack-fastapi-template
50
+ * https://github.com/DarylStark/my_data/
51
+ * https://github.com/petrgazarov/FastAPI-app/tree/main/fastapi_app
@@ -0,0 +1,2 @@
1
+ from .base_model import BaseModel
2
+ from .session_manager import SessionManager, get_engine, get_session, init
@@ -0,0 +1,153 @@
1
+ """
2
+ Adapted from: https://github.com/fastapiutils/fastapi-utils/blob/master/fastapi_utils/session.py
3
+ """
4
+
5
+ from contextlib import contextmanager
6
+
7
+ import sqlalchemy as sa
8
+ from sqlalchemy.orm import Session
9
+
10
+
11
+ class FastSessionMaker:
12
+ """
13
+ A convenience class for managing a (cached) sqlalchemy ORM engine and sessionmaker.
14
+
15
+ Intended for use creating ORM sessions injected into endpoint functions by FastAPI.
16
+ """
17
+
18
+ def __init__(self, database_uri: str):
19
+ """
20
+ `database_uri` should be any sqlalchemy-compatible database URI.
21
+
22
+ In particular, `sqlalchemy.create_engine(database_uri)` should work to create an engine.
23
+
24
+ Typically, this would look like:
25
+
26
+ "<scheme>://<user>:<password>@<host>:<port>/<database>"
27
+
28
+ A concrete example looks like "postgresql://db_user:password@db:5432/app"
29
+ """
30
+ self.database_uri = database_uri
31
+
32
+ self._cached_engine: sa.engine.Engine | None = None
33
+ self._cached_sessionmaker: sa.orm.sessionmaker | None = None
34
+
35
+ @property
36
+ def cached_engine(self) -> sa.engine.Engine:
37
+ """
38
+ Returns a lazily-cached sqlalchemy engine for the instance's database_uri.
39
+ """
40
+ engine = self._cached_engine
41
+ if engine is None:
42
+ engine = self.get_new_engine()
43
+ self._cached_engine = engine
44
+ return engine
45
+
46
+ @property
47
+ def cached_sessionmaker(self) -> sa.orm.sessionmaker:
48
+ """
49
+ Returns a lazily-cached sqlalchemy sessionmaker using the instance's (lazily-cached) engine.
50
+ """
51
+ sessionmaker = self._cached_sessionmaker
52
+ if sessionmaker is None:
53
+ sessionmaker = self.get_new_sessionmaker(self.cached_engine)
54
+ self._cached_sessionmaker = sessionmaker
55
+ return sessionmaker
56
+
57
+ def get_new_engine(self) -> sa.engine.Engine:
58
+ """
59
+ Returns a new sqlalchemy engine using the instance's database_uri.
60
+ """
61
+ return get_engine(self.database_uri)
62
+
63
+ def get_new_sessionmaker(
64
+ self, engine: sa.engine.Engine | None
65
+ ) -> sa.orm.sessionmaker:
66
+ """
67
+ Returns a new sessionmaker for the provided sqlalchemy engine. If no engine is provided, the
68
+ instance's (lazily-cached) engine is used.
69
+ """
70
+ engine = engine or self.cached_engine
71
+ return get_sessionmaker_for_engine(engine)
72
+
73
+ def get_db(self) -> Iterator[Session]:
74
+ """
75
+ A generator function that yields a sqlalchemy orm session and cleans up the session once resumed after yielding.
76
+
77
+ Can be used directly as a context-manager FastAPI dependency, or yielded from inside a separate dependency.
78
+ """
79
+ yield from _get_db(self.cached_sessionmaker)
80
+
81
+ @contextmanager
82
+ def context_session(self) -> Iterator[Session]:
83
+ """
84
+ A context-manager wrapped version of the `get_db` method.
85
+
86
+ This makes it possible to get a context-managed orm session for the relevant database_uri without
87
+ needing to rely on FastAPI's dependency injection.
88
+
89
+ Usage looks like:
90
+
91
+ session_maker = FastAPISessionMaker(database_uri)
92
+ with session_maker.context_session() as session:
93
+ session.query(...)
94
+ ...
95
+ """
96
+ yield from self.get_db()
97
+
98
+ def reset_cache(self) -> None:
99
+ """
100
+ Resets the engine and sessionmaker caches.
101
+
102
+ After calling this method, the next time you try to use the cached engine or sessionmaker,
103
+ new ones will be created.
104
+ """
105
+ self._cached_engine = None
106
+ self._cached_sessionmaker = None
107
+
108
+
109
+ def get_engine(uri: str) -> sa.engine.Engine:
110
+ """
111
+ Returns a sqlalchemy engine with pool_pre_ping enabled.
112
+
113
+ This function may be updated over time to reflect recommended engine configuration for use with FastAPI.
114
+ """
115
+ return sa.create_engine(uri, pool_pre_ping=True)
116
+
117
+
118
+ def get_sessionmaker_for_engine(engine: sa.engine.Engine) -> sa.orm.sessionmaker:
119
+ """
120
+ Returns a sqlalchemy sessionmaker for the provided engine with recommended configuration settings.
121
+
122
+ This function may be updated over time to reflect recommended sessionmaker configuration for use with FastAPI.
123
+ """
124
+ return sa.orm.sessionmaker(autocommit=False, autoflush=False, bind=engine)
125
+
126
+
127
+ @contextmanager
128
+ def context_session(engine: sa.engine.Engine) -> Iterator[Session]:
129
+ """
130
+ This contextmanager yields a managed session for the provided engine.
131
+
132
+ Usage is similar to `FastAPISessionMaker.context_session`, except that you have to provide the engine to use.
133
+
134
+ A new sessionmaker is created for each call, so the FastAPISessionMaker.context_session
135
+ method may be preferable in performance-sensitive contexts.
136
+ """
137
+ sessionmaker = get_sessionmaker_for_engine(engine)
138
+ yield from _get_db(sessionmaker)
139
+
140
+
141
+ def _get_db(sessionmaker: sa.orm.sessionmaker) -> Iterator[Session]:
142
+ """
143
+ A generator function that yields an ORM session using the provided sessionmaker, and cleans it up when resumed.
144
+ """
145
+ session = sessionmaker()
146
+ try:
147
+ yield session
148
+ session.commit()
149
+ except Exception as exc:
150
+ session.rollback()
151
+ raise exc
152
+ finally:
153
+ session.close()
@@ -0,0 +1,228 @@
1
+ import json
2
+ import typing as t
3
+ from contextlib import contextmanager
4
+
5
+ import pydash
6
+ import sqlalchemy as sa
7
+ import sqlmodel as sm
8
+ from sqlalchemy import Connection, event
9
+ from sqlalchemy.orm import Mapper, declared_attr
10
+ from sqlmodel import Session, SQLModel, select
11
+ from typeid import TypeID
12
+
13
+ from .logger import logger
14
+ from .query_wrapper import QueryWrapper
15
+ from .session_manager import get_session
16
+
17
+
18
+ # TODO this does not seem to work with the latest 2.9.x pydantic and sqlmodel
19
+ # https://github.com/SE-Sustainability-OSS/ecodev-core/blob/main/ecodev_core/sqlmodel_utils.py
20
+ class SQLModelWithValidation(SQLModel):
21
+ """
22
+ Helper class to ease validation in SQLModel classes with table=True
23
+ """
24
+
25
+ @classmethod
26
+ def create(cls, **kwargs):
27
+ """
28
+ Forces validation to take place, even for SQLModel classes with table=True
29
+ """
30
+ return cls(**cls.__bases__[0](**kwargs).model_dump())
31
+
32
+
33
+ class BaseModel(SQLModel):
34
+ """
35
+ Base model class to inherit from so we can hate python less
36
+
37
+ https://github.com/woofz/sqlmodel-basecrud/blob/main/sqlmodel_basecrud/basecrud.py
38
+
39
+ {before,after} hooks are modeled after Rails.
40
+ """
41
+
42
+ # TODO implement actually calling these hooks
43
+
44
+ @classmethod
45
+ def __init_subclass__(cls, **kwargs):
46
+ "Setup automatic sqlalchemy lifecycle events for the class"
47
+
48
+ super().__init_subclass__(**kwargs)
49
+
50
+ def event_wrapper(method_name: str):
51
+ """
52
+ This does smart heavy lifting for us to make sqlalchemy lifecycle events nicer to work with:
53
+
54
+ * Passes the target first to the lifecycle method, so it feels like an instance method
55
+ * Allows as little as a single positional argument, so methods can be simple
56
+ * Removes the need for decorators or anything fancy on the subclass
57
+ """
58
+
59
+ def wrapper(mapper: Mapper, connection: Connection, target: BaseModel):
60
+ if hasattr(cls, method_name):
61
+ method = getattr(cls, method_name)
62
+
63
+ if callable(method):
64
+ arg_count = method.__code__.co_argcount
65
+
66
+ if arg_count == 1: # Just self/cls
67
+ method(target)
68
+ elif arg_count == 2: # Self, mapper
69
+ method(target, mapper)
70
+ elif arg_count == 3: # Full signature
71
+ method(target, mapper, connection)
72
+ else:
73
+ raise TypeError(
74
+ f"Method {method_name} must accept either 1 to 3 arguments, got {arg_count}"
75
+ )
76
+ else:
77
+ logger.warning(
78
+ "SQLModel lifecycle hook found, but not callable hook_name=%s",
79
+ method_name,
80
+ )
81
+
82
+ return wrapper
83
+
84
+ event.listen(cls, "before_insert", event_wrapper("before_insert"))
85
+ event.listen(cls, "before_update", event_wrapper("before_update"))
86
+
87
+ # before_save maps to two type of events
88
+ event.listen(cls, "before_insert", event_wrapper("before_save"))
89
+ event.listen(cls, "before_update", event_wrapper("before_save"))
90
+
91
+ # now, let's handle after_* variants
92
+ event.listen(cls, "after_insert", event_wrapper("after_insert"))
93
+ event.listen(cls, "after_update", event_wrapper("after_update"))
94
+
95
+ # after_save maps to two type of events
96
+ event.listen(cls, "after_insert", event_wrapper("after_save"))
97
+ event.listen(cls, "after_update", event_wrapper("after_save"))
98
+
99
+ # TODO no type check decorator here
100
+ @declared_attr
101
+ def __tablename__(cls) -> str:
102
+ """
103
+ Automatically generates the table name for the model by converting the class name from camel case to snake case.
104
+
105
+ By default, the class is lower cased which makes it harder to read.
106
+
107
+ Many snake_case libraries struggle with snake case for names like LLMCache, which is why we are using a more
108
+ complicated implementation from pydash.
109
+
110
+ https://stackoverflow.com/questions/1175208/elegant-python-function-to-convert-camelcase-to-snake-case
111
+ """
112
+ return pydash.strings.snake_case(cls.__name__)
113
+
114
+ @classmethod
115
+ def select(cls, *args):
116
+ return QueryWrapper[cls](cls, *args)
117
+
118
+ def save(self):
119
+ with get_session() as session:
120
+ if old_session := Session.object_session(self):
121
+ # I was running into an issue where the object was already
122
+ # associated with a session, but the session had been closed,
123
+ # to get around this, you need to remove it from the old one,
124
+ # then add it to the new one (below)
125
+ old_session.expunge(self)
126
+
127
+ session.add(self)
128
+ # NOTE very important method! This triggers sqlalchemy lifecycle hooks automatically
129
+ session.commit()
130
+ session.refresh(self)
131
+
132
+ return self
133
+
134
+ # except IntegrityError:
135
+ # log.quiet(f"{self} already exists in the database.")
136
+ # session.rollback()
137
+
138
+ # TODO shouldn't this be handled by pydantic?
139
+ def json(self, **kwargs):
140
+ return json.dumps(self.dict(), default=str, **kwargs)
141
+
142
+ # TODO should move this to the wrapper
143
+ @classmethod
144
+ def count(cls) -> int:
145
+ """
146
+ Returns the number of records in the database.
147
+ """
148
+ return get_session().exec(sm.select(sm.func.count()).select_from(cls)).one()
149
+
150
+ # TODO throw an error if this field is set on the model
151
+ def is_new(self):
152
+ return not self._sa_instance_state.has_identity
153
+
154
+ @classmethod
155
+ def find_or_create_by(cls, **kwargs):
156
+ """
157
+ Find record or create it with the passed args if it doesn't exist.
158
+ """
159
+
160
+ result = cls.get(**kwargs)
161
+
162
+ if result:
163
+ return result
164
+
165
+ new_model = cls(**kwargs)
166
+ new_model.save()
167
+
168
+ return new_model
169
+
170
+ @classmethod
171
+ def find_or_initialize_by(cls, **kwargs):
172
+ """
173
+ Unfortunately, unlike ruby, python does not have a great lambda story. This makes writing convenience methods
174
+ like this a bit more difficult.
175
+ """
176
+
177
+ result = cls.get(**kwargs)
178
+
179
+ if result:
180
+ return result
181
+
182
+ new_model = cls(**kwargs)
183
+ return new_model
184
+
185
+ # TODO what's super dangerous here is you pass a kwarg which does not map to a specific
186
+ # field it will result in `True`, which will return all records, and not give you any typing
187
+ # errors. Dangerous when iterating on structure quickly
188
+ # TODO can we pass the generic of the superclass in?
189
+ @classmethod
190
+ # def get(cls, *args: sa.BinaryExpression, **kwargs: t.Any):
191
+ def get(cls, *args: t.Any, **kwargs: t.Any):
192
+ """
193
+ Gets a single record from the database. Pass an PK ID or a kwarg to filter by.
194
+ """
195
+
196
+ # special case for getting by ID
197
+ if len(args) == 1 and isinstance(args[0], int):
198
+ # TODO id is hardcoded, not good! Need to dynamically pick the best uid field
199
+ kwargs["id"] = args[0]
200
+ args = []
201
+ elif len(args) == 1 and isinstance(args[0], TypeID):
202
+ kwargs["id"] = args[0]
203
+ args = []
204
+
205
+ statement = select(cls).filter(*args).filter_by(**kwargs)
206
+ return get_session().exec(statement).first()
207
+
208
+ @classmethod
209
+ def all(cls):
210
+ with get_session() as session:
211
+ results = session.exec(sa.sql.select(cls))
212
+
213
+ # TODO do we need this or can we just return results?
214
+ for result in results:
215
+ yield result
216
+
217
+ @classmethod
218
+ def sample(cls):
219
+ """
220
+ Pick a random record from the database.
221
+
222
+ Helpful for testing and console debugging.
223
+ """
224
+
225
+ query = sql.select(cls).order_by(sql.func.random()).limit(1)
226
+
227
+ with get_session() as session:
228
+ return session.exec(query).one()
@@ -0,0 +1,3 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger(__name__)
@@ -0,0 +1,2 @@
1
+ from .timestamps import TimestampsMixin
2
+ from .typeid import TypeIDMixin
@@ -1,10 +1,9 @@
1
1
  from datetime import datetime
2
2
 
3
3
  import sqlalchemy as sa
4
-
5
- # TODO raw sql https://github.com/tiangolo/sqlmodel/discussions/772
6
4
  from sqlmodel import Field
7
5
 
6
+ # TODO raw sql https://github.com/tiangolo/sqlmodel/discussions/772
8
7
  # @classmethod
9
8
  # def select(cls):
10
9
  # with get_session() as session:
@@ -14,11 +13,11 @@ from sqlmodel import Field
14
13
  # yield result
15
14
 
16
15
 
17
- class TimestampMixin:
16
+ class TimestampsMixin:
18
17
  """
19
18
  Simple created at and updated at timestamps. Mix them into your model:
20
19
 
21
- >>> class MyModel(TimestampMixin, SQLModel):
20
+ >>> class MyModel(TimestampsMixin, SQLModel):
22
21
  >>> pass
23
22
 
24
23
  Originally pulled from: https://github.com/tiangolo/sqlmodel/issues/252
@@ -0,0 +1,36 @@
1
+ import uuid
2
+
3
+ from sqlmodel import Column, Field
4
+ from typeid import TypeID
5
+
6
+ from activemodel.types.typeid import TypeIDType
7
+
8
+
9
+ def TypeIDMixin(prefix: str):
10
+ class _TypeIDMixin:
11
+ id: uuid.UUID = Field(
12
+ sa_column=Column(TypeIDType(prefix), primary_key=True),
13
+ default_factory=lambda: TypeID(prefix),
14
+ )
15
+
16
+ return _TypeIDMixin
17
+
18
+
19
+ class TypeIDMixin2:
20
+ """
21
+ Mixin class that adds a TypeID primary key to models.
22
+
23
+
24
+ >>> class MyModel(BaseModel, TypeIDMixin, prefix="xyz", table=True):
25
+ >>> name: str
26
+
27
+ Will automatically have an `id` field with prefix "xyz"
28
+ """
29
+
30
+ def __init_subclass__(cls, *, prefix: str, **kwargs):
31
+ super().__init_subclass__(**kwargs)
32
+
33
+ cls.id: uuid.UUID = Field(
34
+ sa_column=Column(TypeIDType(prefix), primary_key=True),
35
+ default_factory=lambda: TypeID(prefix),
36
+ )
@@ -0,0 +1,2 @@
1
+ from .transaction import database_reset_transaction
2
+ from .truncate import database_reset_truncate
@@ -0,0 +1,51 @@
1
+ from activemodel import SessionManager
2
+
3
+
4
+ def database_reset_transaction():
5
+ """
6
+ Wrap all database interactions for a given test in a nested transaction and roll it back after the test.
7
+
8
+ >>> from activemodel.pytest import database_reset_transaction
9
+ >>> pytest.fixture(scope="function", autouse=True)(database_reset_transaction)
10
+
11
+ References:
12
+
13
+ - https://stackoverflow.com/questions/62433018/how-to-make-sqlalchemy-transaction-rollback-drop-tables-it-created
14
+ - https://aalvarez.me/posts/setting-up-a-sqlalchemy-and-pytest-based-test-suite/
15
+ - https://github.com/nickjj/docker-flask-example/blob/93af9f4fbf185098ffb1d120ee0693abcd77a38b/test/conftest.py#L77
16
+ - https://github.com/caiola/vinhos.com/blob/c47d0a5d7a4bf290c1b726561d1e8f5d2ac29bc8/backend/test/conftest.py#L46
17
+ """
18
+
19
+ engine = SessionManager.get_instance().get_engine()
20
+
21
+ with engine.begin() as connection:
22
+ transaction = connection.begin_nested()
23
+
24
+ SessionManager.get_instance().session_connection = connection
25
+
26
+ try:
27
+ yield
28
+ finally:
29
+ transaction.rollback()
30
+ # TODO is this necessary?
31
+ connection.close()
32
+
33
+
34
+ # TODO unsure if this adds any value beyond the above approach
35
+ # def database_reset_named_truncation():
36
+ # start_truncation_query = """
37
+ # BEGIN;
38
+ # SAVEPOINT test_truncation_savepoint;
39
+ # """
40
+
41
+ # raw_sql_exec(start_truncation_query)
42
+
43
+ # yield
44
+
45
+ # end_truncation_query = """
46
+ # ROLLBACK TO SAVEPOINT test_truncation_savepoint;
47
+ # RELEASE SAVEPOINT test_truncation_savepoint;
48
+ # ROLLBACK;
49
+ # """
50
+
51
+ # raw_sql_exec(end_truncation_query)
@@ -0,0 +1,46 @@
1
+ from sqlmodel import SQLModel
2
+
3
+ from ..logger import logger
4
+ from ..session_manager import get_engine
5
+
6
+
7
+ def database_reset_truncate():
8
+ """
9
+ Transaction is most likely the better way to go, but there are some scenarios where the session override
10
+ logic does not work properly and you need to truncate tables back to their original state.
11
+
12
+ Here's how to do this once at the start of the test:
13
+
14
+ >>> from activemodel.pytest import database_reset_truncation
15
+ >>> def pytest_configure(config):
16
+ >>> database_reset_truncation()
17
+
18
+ Or, if you want to use this as a fixture:
19
+
20
+ >>> pytest.fixture(scope="function")(database_reset_truncation)
21
+ >>> def test_the_thing(database_reset_truncation)
22
+
23
+ This approach has a couple of problems:
24
+
25
+ * You can't run multiple tests in parallel without separate databases
26
+ * If you have important seed data and want to truncate those tables, the seed data will be lost
27
+ """
28
+
29
+ logger.info("truncating database")
30
+
31
+ # TODO get additonal tables to preserve from config
32
+ exception_tables = ["alembic_version"]
33
+
34
+ assert (
35
+ SQLModel.metadata.sorted_tables
36
+ ), "No model metadata. Ensure model metadata is imported before running truncate_db"
37
+
38
+ with get_engine().connect() as connection:
39
+ for table in reversed(SQLModel.metadata.sorted_tables):
40
+ transaction = connection.begin()
41
+
42
+ if table.name not in exception_tables:
43
+ logger.debug("truncating table=%s", table.name)
44
+ connection.execute(table.delete())
45
+
46
+ transaction.commit()
@@ -1,15 +1,7 @@
1
- from typing import Generic, TypeVar
1
+ import sqlmodel
2
2
 
3
- from sqlmodel.sql.expression import SelectOfScalar
4
3
 
5
- WrappedModelType = TypeVar("WrappedModelType")
6
-
7
-
8
- def compile_sql(target: SelectOfScalar):
9
- return str(target.compile(get_engine().connect()))
10
-
11
-
12
- class QueryWrapper(Generic[WrappedModelType]):
4
+ class QueryWrapper[T]:
13
5
  """
14
6
  Make it easy to run queries off of a model
15
7
  """
@@ -20,7 +12,7 @@ class QueryWrapper(Generic[WrappedModelType]):
20
12
 
21
13
  if args:
22
14
  # very naive, let's assume the args are specific select statements
23
- self.target = sql.select(*args).select_from(cls)
15
+ self.target = sqlmodel.sql.select(*args).select_from(cls)
24
16
  else:
25
17
  self.target = sql.select(cls)
26
18
 
@@ -75,7 +67,7 @@ class QueryWrapper(Generic[WrappedModelType]):
75
67
 
76
68
  def sql(self):
77
69
  """
78
- Output the raw SQL of the query
70
+ Output the raw SQL of the query for debugging
79
71
  """
80
72
 
81
73
  return compile_sql(self.target)
@@ -0,0 +1,62 @@
1
+ """
2
+ Class to make managing sessions with SQL Model easy. Also provides a common entrypoint to make it easy to mutate the
3
+ database environment when testing.
4
+ """
5
+
6
+ import typing as t
7
+
8
+ from decouple import config
9
+ from sqlalchemy import Engine
10
+ from sqlmodel import Session, create_engine
11
+
12
+
13
+ class SessionManager:
14
+ _instance: t.ClassVar[t.Optional["SessionManager"]] = None
15
+
16
+ session_connection: str
17
+
18
+ @classmethod
19
+ def get_instance(cls, database_url: str | None = None) -> "SessionManager":
20
+ if cls._instance is None:
21
+ assert (
22
+ database_url is not None
23
+ ), "Database URL required for first initialization"
24
+ cls._instance = cls(database_url)
25
+
26
+ return cls._instance
27
+
28
+ def __init__(self, database_url: str):
29
+ self._database_url = database_url
30
+ self._engine = None
31
+ self.session_connection = None
32
+
33
+ # TODO why is this type not reimported?
34
+ def get_engine(self) -> Engine:
35
+ if not self._engine:
36
+ self._engine = create_engine(
37
+ self._database_url,
38
+ echo=config("ACTIVEMODEL_LOG_SQL", cast=bool, default=False),
39
+ # https://docs.sqlalchemy.org/en/20/core/pooling.html#disconnect-handling-pessimistic
40
+ pool_pre_ping=True,
41
+ # some implementations include `future=True` but it's not required anymore
42
+ )
43
+
44
+ return self._engine
45
+
46
+ def get_session(self):
47
+ if self.session_connection:
48
+ return Session(bind=self.session_connection)
49
+
50
+ return Session(self.get_engine())
51
+
52
+
53
+ def init(database_url: str):
54
+ return SessionManager.get_instance(database_url)
55
+
56
+
57
+ def get_engine():
58
+ return SessionManager.get_instance().get_engine()
59
+
60
+
61
+ def get_session():
62
+ return SessionManager.get_instance().get_session()
@@ -0,0 +1,56 @@
1
+ """
2
+ Lifted from: https://github.com/akhundMurad/typeid-python/blob/main/examples/sqlalchemy.py
3
+ """
4
+
5
+ from typing import Optional
6
+
7
+ from sqlalchemy import types
8
+ from sqlalchemy.util import generic_repr
9
+ from typeid import TypeID
10
+
11
+
12
+ class TypeIDType(types.TypeDecorator):
13
+ """
14
+ A SQLAlchemy TypeDecorator that allows storing TypeIDs in the database.
15
+ The prefix will not be persisted, instead the database-native UUID field will be used.
16
+ At retrieval time a TypeID will be constructed based on the configured prefix and the
17
+ UUID value from the database.
18
+
19
+ Usage:
20
+ # will result in TypeIDs such as "user_01h45ytscbebyvny4gc8cr8ma2"
21
+ id = mapped_column(
22
+ TypeIDType("user"),
23
+ primary_key=True,
24
+ default=lambda: TypeID("user")
25
+ )
26
+ """
27
+
28
+ impl = types.Uuid
29
+ # impl = uuid.UUID
30
+ cache_ok = True
31
+ prefix: Optional[str] = None
32
+
33
+ def __init__(self, prefix: Optional[str], *args, **kwargs):
34
+ self.prefix = prefix
35
+ super().__init__(*args, **kwargs)
36
+
37
+ def __repr__(self) -> str:
38
+ # Customize __repr__ to ensure that auto-generated code e.g. from alembic includes
39
+ # the right __init__ params (otherwise by default prefix will be omitted because
40
+ # uuid.__init__ does not have such an argument).
41
+ # TODO this makes it so inspected code does NOT include the suffix
42
+ return generic_repr(
43
+ self,
44
+ to_inspect=TypeID(self.prefix),
45
+ )
46
+
47
+ def process_bind_param(self, value, dialect):
48
+ if self.prefix is None:
49
+ assert value.prefix is None
50
+ else:
51
+ assert value.prefix == self.prefix
52
+
53
+ return value.uuid
54
+
55
+ def process_result_value(self, value, dialect):
56
+ return TypeID.from_uuid(value, self.prefix)
@@ -0,0 +1,15 @@
1
+ from sqlmodel.sql.expression import SelectOfScalar
2
+
3
+ from activemodel import get_engine
4
+
5
+
6
+ def compile_sql(target: SelectOfScalar):
7
+ dialect = get_engine().dialect
8
+ # TODO I wonder if we could store the dialect to avoid getting an engine reference
9
+ compiled = target.compile(dialect=dialect, compile_kwargs={"literal_binds": True})
10
+ return str(compiled)
11
+
12
+
13
+ def raw_sql_exec(raw_query: str):
14
+ with get_session() as session:
15
+ session.execute(text(raw_query))
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.1
2
+ Name: activemodel
3
+ Version: 0.5.0
4
+ Summary: Make SQLModel more like an a real ORM
5
+ Author-email: Michael Bianco <iloveitaly@gmail.com>
6
+ Project-URL: Repository, https://github.com/iloveitaly/activemodel
7
+ Keywords: sqlmodel,orm,activerecord,activemodel,sqlalchemy
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: pydash>=8.0.4
12
+ Requires-Dist: python-decouple-typed>=3.11.0
13
+ Requires-Dist: sqlmodel>=0.0.22
14
+ Requires-Dist: typeid-python>=0.3.1
15
+
16
+ # ActiveModel: ORM Wrapper for SQLModel
17
+
18
+ No, this isn't *really* [ActiveModel](https://guides.rubyonrails.org/active_model_basics.html). It's just a wrapper around SQLModel that provides a more ActiveRecord-like interface.
19
+
20
+ SQLModel is *not* an ORM. It's a SQL query builder and a schema definition tool.
21
+
22
+ This package provides a thin wrapper around SQLModel that provides a more ActiveRecord-like interface with things like:
23
+
24
+ * Timestamp column mixins
25
+ * Lifecycle hooks
26
+
27
+ ## Limitations
28
+
29
+ ### Validation
30
+
31
+ SQLModel does not currently support pydantic validations (when `table=True`). This is very surprising, but is actually the intended functionality:
32
+
33
+ * https://github.com/fastapi/sqlmodel/discussions/897
34
+ * https://github.com/fastapi/sqlmodel/pull/1041
35
+ * https://github.com/fastapi/sqlmodel/issues/453
36
+ * https://github.com/fastapi/sqlmodel/issues/52#issuecomment-1311987732
37
+
38
+ For validation:
39
+
40
+ * When consuming API data, use a separate shadow model to validate the data with `table=False` and then inherit from that model in a model with `table=True`.
41
+ * When validating ORM data, use SQL Alchemy hooks.
42
+
43
+ <!--
44
+
45
+ This looks neat
46
+ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d2a4/src/my_model/model.py#L155
47
+ schema_extra={
48
+ 'pattern': r'^[a-z0-9_\-\.]+\@[a-z0-9_\-\.]+\.[a-z\.]+$'
49
+ },
50
+
51
+ extra constraints
52
+
53
+ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d2a4/src/my_model/model.py#L424C1-L426C6
54
+ -->
55
+ ## Related Projects
56
+
57
+ * https://github.com/woofz/sqlmodel-basecrud
58
+
59
+ ## Inspiration
60
+
61
+ * https://github.com/peterdresslar/fastapi-sqlmodel-alembic-pg
62
+ * [Albemic instructions](https://github.com/fastapi/sqlmodel/pull/899/files)
63
+ * https://github.com/fastapiutils/fastapi-utils/
64
+ * https://github.com/fastapi/full-stack-fastapi-template
65
+ * https://github.com/DarylStark/my_data/
66
+ * https://github.com/petrgazarov/FastAPI-app/tree/main/fastapi_app
@@ -0,0 +1,23 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ activemodel/__init__.py
5
+ activemodel/_session_manager.py
6
+ activemodel/base_model.py
7
+ activemodel/logger.py
8
+ activemodel/query_wrapper.py
9
+ activemodel/session_manager.py
10
+ activemodel/utils.py
11
+ activemodel.egg-info/PKG-INFO
12
+ activemodel.egg-info/SOURCES.txt
13
+ activemodel.egg-info/dependency_links.txt
14
+ activemodel.egg-info/entry_points.txt
15
+ activemodel.egg-info/requires.txt
16
+ activemodel.egg-info/top_level.txt
17
+ activemodel/mixins/__init__.py
18
+ activemodel/mixins/timestamps.py
19
+ activemodel/mixins/typeid.py
20
+ activemodel/pytest/__init__.py
21
+ activemodel/pytest/transaction.py
22
+ activemodel/pytest/truncate.py
23
+ activemodel/types/typeid.py
@@ -0,0 +1,4 @@
1
+ pydash>=8.0.4
2
+ python-decouple-typed>=3.11.0
3
+ sqlmodel>=0.0.22
4
+ typeid-python>=0.3.1
@@ -1,12 +1,14 @@
1
1
  [project]
2
2
  name = "activemodel"
3
- version = "0.3.0"
3
+ version = "0.5.0"
4
4
  description = "Make SQLModel more like an a real ORM"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  dependencies = [
8
8
  "pydash>=8.0.4",
9
+ "python-decouple-typed>=3.11.0",
9
10
  "sqlmodel>=0.0.22",
11
+ "typeid-python>=0.3.1",
10
12
  ]
11
13
  authors = [{ name = "Michael Bianco", email = "iloveitaly@gmail.com" }]
12
14
  keywords = ["sqlmodel", "orm", "activerecord", "activemodel", "sqlalchemy"]
@@ -1,34 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: activemodel
3
- Version: 0.3.0
4
- Summary: Make SQLModel more like an a real ORM
5
- Author-email: Michael Bianco <iloveitaly@gmail.com>
6
- Project-URL: Repository, https://github.com/iloveitaly/activemodel
7
- Keywords: sqlmodel,orm,activerecord,activemodel,sqlalchemy
8
- Requires-Python: >=3.10
9
- Description-Content-Type: text/markdown
10
- License-File: LICENSE
11
- Requires-Dist: pydash>=8.0.4
12
- Requires-Dist: sqlmodel>=0.0.22
13
-
14
- # ActiveModel: ORM Wrapper for SQLModel
15
-
16
- No, this isn't *really* [ActiveModel](https://guides.rubyonrails.org/active_model_basics.html). It's just a wrapper around SQLModel that provides a more ActiveRecord-like interface.
17
-
18
- SQLModel is *not* an ORM. It's a SQL query builder and a schema definition tool.
19
-
20
- This package provides a thin wrapper around SQLModel that provides a more ActiveRecord-like interface with things like:
21
-
22
- * Timestamp column mixins
23
- * Lifecycle hooks
24
-
25
- ## Related Projects
26
-
27
- * https://github.com/woofz/sqlmodel-basecrud
28
-
29
- ## Inspiration
30
-
31
- * https://github.com/peterdresslar/fastapi-sqlmodel-alembic-pg
32
- * [Albemic instructions](https://github.com/fastapi/sqlmodel/pull/899/files)
33
- * https://github.com/fastapiutils/fastapi-utils/
34
- *
@@ -1,21 +0,0 @@
1
- # ActiveModel: ORM Wrapper for SQLModel
2
-
3
- No, this isn't *really* [ActiveModel](https://guides.rubyonrails.org/active_model_basics.html). It's just a wrapper around SQLModel that provides a more ActiveRecord-like interface.
4
-
5
- SQLModel is *not* an ORM. It's a SQL query builder and a schema definition tool.
6
-
7
- This package provides a thin wrapper around SQLModel that provides a more ActiveRecord-like interface with things like:
8
-
9
- * Timestamp column mixins
10
- * Lifecycle hooks
11
-
12
- ## Related Projects
13
-
14
- * https://github.com/woofz/sqlmodel-basecrud
15
-
16
- ## Inspiration
17
-
18
- * https://github.com/peterdresslar/fastapi-sqlmodel-alembic-pg
19
- * [Albemic instructions](https://github.com/fastapi/sqlmodel/pull/899/files)
20
- * https://github.com/fastapiutils/fastapi-utils/
21
- *
@@ -1,6 +0,0 @@
1
- from .base_model import BaseModel
2
- from .query_wrapper import QueryWrapper
3
- from .timestamps import TimestampMixin
4
-
5
- # TODO need a way to specify the session generator
6
- # TODO need a way to specify the session generator
@@ -1,137 +0,0 @@
1
- import json
2
- import typing as t
3
-
4
- import pydash
5
- import sqlalchemy as sa
6
- from sqlalchemy.orm import declared_attr
7
- from sqlmodel import Session, SQLModel
8
-
9
- from .query_wrapper import QueryWrapper
10
-
11
-
12
- class BaseModel(SQLModel):
13
- """
14
- Base model class to inherit from so we can hate python less
15
-
16
- https://github.com/woofz/sqlmodel-basecrud/blob/main/sqlmodel_basecrud/basecrud.py
17
- """
18
-
19
- # TODO implement actually calling these hooks
20
-
21
- def before_delete(self):
22
- pass
23
-
24
- def after_delete(self):
25
- pass
26
-
27
- def before_save(self):
28
- pass
29
-
30
- def after_save(self):
31
- pass
32
-
33
- def before_update(self):
34
- pass
35
-
36
- def after_update(self):
37
- pass
38
-
39
- @declared_attr
40
- def __tablename__(cls) -> str:
41
- """
42
- Automatically generates the table name for the model by converting the class name from camel case to snake case.
43
-
44
- By default, the class is lower cased which makes it harder to read.
45
-
46
- Many snake_case libraries struggle with snake case for names like LLMCache, which is why we are using a more
47
- complicated implementation from pydash.
48
-
49
- https://stackoverflow.com/questions/1175208/elegant-python-function-to-convert-camelcase-to-snake-case
50
- """
51
- return pydash.strings.snake_case(cls.__name__)
52
-
53
- @classmethod
54
- def select(cls, *args):
55
- return QueryWrapper[cls](cls, *args)
56
-
57
- def save(self):
58
- old_session = Session.object_session(self)
59
- with get_session() as session:
60
- if old_session:
61
- # I was running into an issue where the object was already
62
- # associated with a session, but the session had been closed,
63
- # to get around this, you need to remove it from the old one,
64
- # then add it to the new one (below)
65
- old_session.expunge(self)
66
-
67
- self.before_update()
68
- # self.before_save()
69
-
70
- session.add(self)
71
- session.commit()
72
- session.refresh(self)
73
-
74
- self.after_update()
75
- # self.after_save()
76
-
77
- return self
78
-
79
- # except IntegrityError:
80
- # log.quiet(f"{self} already exists in the database.")
81
- # session.rollback()
82
-
83
- # TODO shouldn't this be handled by pydantic?
84
- def json(self, **kwargs):
85
- return json.dumps(self.dict(), default=str, **kwargs)
86
-
87
- @classmethod
88
- def count(cls):
89
- """
90
- Returns the number of records in the database.
91
- """
92
- # TODO should move this to the wrapper
93
- with get_session() as session:
94
- query = sql.select(sql.func.count()).select_from(cls)
95
- return session.exec(query).one()
96
-
97
- # TODO what's super dangerous here is you pass a kwarg which does not map to a specific
98
- # field it will result in `True`, which will return all records, and not give you any typing
99
- # errors. Dangerous when iterating on structure quickly
100
- # TODO can we pass the generic of the superclass in?
101
- @classmethod
102
- def get(cls, *args: sa.BinaryExpression, **kwargs: t.Any):
103
- """
104
- Gets a single record from the database. Pass an PK ID or a kwarg to filter by.
105
- """
106
-
107
- # special case for getting by ID
108
- if len(args) == 1 and isinstance(args[0], int):
109
- # TODO id is hardcoded, not good! Need to dynamically pick the best uid field
110
- kwargs["id"] = args[0]
111
- args = []
112
-
113
- statement = sql.select(cls).filter(*args).filter_by(**kwargs)
114
- with get_session() as session:
115
- return session.exec(statement).first()
116
-
117
- @classmethod
118
- def all(cls):
119
- with get_session() as session:
120
- results = session.exec(sql.select(cls))
121
-
122
- # TODO do we need this or can we just return results?
123
- for result in results:
124
- yield result
125
-
126
- @classmethod
127
- def sample(cls):
128
- """
129
- Pick a random record from the database.
130
-
131
- Helpful for testing and console debugging.
132
- """
133
-
134
- query = sql.select(cls).order_by(sql.func.random()).limit(1)
135
-
136
- with get_session() as session:
137
- return session.exec(query).one()
@@ -1,34 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: activemodel
3
- Version: 0.3.0
4
- Summary: Make SQLModel more like an a real ORM
5
- Author-email: Michael Bianco <iloveitaly@gmail.com>
6
- Project-URL: Repository, https://github.com/iloveitaly/activemodel
7
- Keywords: sqlmodel,orm,activerecord,activemodel,sqlalchemy
8
- Requires-Python: >=3.10
9
- Description-Content-Type: text/markdown
10
- License-File: LICENSE
11
- Requires-Dist: pydash>=8.0.4
12
- Requires-Dist: sqlmodel>=0.0.22
13
-
14
- # ActiveModel: ORM Wrapper for SQLModel
15
-
16
- No, this isn't *really* [ActiveModel](https://guides.rubyonrails.org/active_model_basics.html). It's just a wrapper around SQLModel that provides a more ActiveRecord-like interface.
17
-
18
- SQLModel is *not* an ORM. It's a SQL query builder and a schema definition tool.
19
-
20
- This package provides a thin wrapper around SQLModel that provides a more ActiveRecord-like interface with things like:
21
-
22
- * Timestamp column mixins
23
- * Lifecycle hooks
24
-
25
- ## Related Projects
26
-
27
- * https://github.com/woofz/sqlmodel-basecrud
28
-
29
- ## Inspiration
30
-
31
- * https://github.com/peterdresslar/fastapi-sqlmodel-alembic-pg
32
- * [Albemic instructions](https://github.com/fastapi/sqlmodel/pull/899/files)
33
- * https://github.com/fastapiutils/fastapi-utils/
34
- *
@@ -1,13 +0,0 @@
1
- LICENSE
2
- README.md
3
- pyproject.toml
4
- activemodel/__init__.py
5
- activemodel/base_model.py
6
- activemodel/query_wrapper.py
7
- activemodel/timestamps.py
8
- activemodel.egg-info/PKG-INFO
9
- activemodel.egg-info/SOURCES.txt
10
- activemodel.egg-info/dependency_links.txt
11
- activemodel.egg-info/entry_points.txt
12
- activemodel.egg-info/requires.txt
13
- activemodel.egg-info/top_level.txt
@@ -1,2 +0,0 @@
1
- pydash>=8.0.4
2
- sqlmodel>=0.0.22
File without changes
File without changes