activemodel 0.3.0__py3-none-any.whl → 0.5.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/__init__.py +1 -5
- activemodel/_session_manager.py +153 -0
- activemodel/base_model.py +133 -42
- activemodel/logger.py +3 -0
- activemodel/mixins/__init__.py +2 -0
- activemodel/{timestamps.py → mixins/timestamps.py} +3 -4
- activemodel/mixins/typeid.py +36 -0
- activemodel/pytest/__init__.py +2 -0
- activemodel/pytest/transaction.py +51 -0
- activemodel/pytest/truncate.py +46 -0
- activemodel/query_wrapper.py +4 -12
- activemodel/session_manager.py +62 -0
- activemodel/types/typeid.py +56 -0
- activemodel/utils.py +15 -0
- activemodel-0.5.0.dist-info/METADATA +66 -0
- activemodel-0.5.0.dist-info/RECORD +20 -0
- activemodel-0.3.0.dist-info/METADATA +0 -34
- activemodel-0.3.0.dist-info/RECORD +0 -10
- {activemodel-0.3.0.dist-info → activemodel-0.5.0.dist-info}/LICENSE +0 -0
- {activemodel-0.3.0.dist-info → activemodel-0.5.0.dist-info}/WHEEL +0 -0
- {activemodel-0.3.0.dist-info → activemodel-0.5.0.dist-info}/entry_points.txt +0 -0
- {activemodel-0.3.0.dist-info → activemodel-0.5.0.dist-info}/top_level.txt +0 -0
activemodel/__init__.py
CHANGED
|
@@ -1,6 +1,2 @@
|
|
|
1
1
|
from .base_model import BaseModel
|
|
2
|
-
from .
|
|
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
|
|
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()
|
activemodel/base_model.py
CHANGED
|
@@ -1,41 +1,102 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import typing as t
|
|
3
|
+
from contextlib import contextmanager
|
|
3
4
|
|
|
4
5
|
import pydash
|
|
5
6
|
import sqlalchemy as sa
|
|
6
|
-
|
|
7
|
-
from
|
|
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
|
|
8
12
|
|
|
13
|
+
from .logger import logger
|
|
9
14
|
from .query_wrapper import QueryWrapper
|
|
15
|
+
from .session_manager import get_session
|
|
10
16
|
|
|
11
17
|
|
|
12
|
-
|
|
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):
|
|
13
21
|
"""
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
https://github.com/woofz/sqlmodel-basecrud/blob/main/sqlmodel_basecrud/basecrud.py
|
|
22
|
+
Helper class to ease validation in SQLModel classes with table=True
|
|
17
23
|
"""
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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())
|
|
23
31
|
|
|
24
|
-
def after_delete(self):
|
|
25
|
-
pass
|
|
26
32
|
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
class BaseModel(SQLModel):
|
|
34
|
+
"""
|
|
35
|
+
Base model class to inherit from so we can hate python less
|
|
29
36
|
|
|
30
|
-
|
|
31
|
-
pass
|
|
37
|
+
https://github.com/woofz/sqlmodel-basecrud/blob/main/sqlmodel_basecrud/basecrud.py
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
{before,after} hooks are modeled after Rails.
|
|
40
|
+
"""
|
|
35
41
|
|
|
36
|
-
|
|
37
|
-
pass
|
|
42
|
+
# TODO implement actually calling these hooks
|
|
38
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
|
|
39
100
|
@declared_attr
|
|
40
101
|
def __tablename__(cls) -> str:
|
|
41
102
|
"""
|
|
@@ -55,51 +116,79 @@ class BaseModel(SQLModel):
|
|
|
55
116
|
return QueryWrapper[cls](cls, *args)
|
|
56
117
|
|
|
57
118
|
def save(self):
|
|
58
|
-
old_session = Session.object_session(self)
|
|
59
119
|
with get_session() as session:
|
|
60
|
-
if old_session:
|
|
120
|
+
if old_session := Session.object_session(self):
|
|
61
121
|
# I was running into an issue where the object was already
|
|
62
122
|
# associated with a session, but the session had been closed,
|
|
63
123
|
# to get around this, you need to remove it from the old one,
|
|
64
124
|
# then add it to the new one (below)
|
|
65
125
|
old_session.expunge(self)
|
|
66
126
|
|
|
67
|
-
self.before_update()
|
|
68
|
-
# self.before_save()
|
|
69
|
-
|
|
70
127
|
session.add(self)
|
|
128
|
+
# NOTE very important method! This triggers sqlalchemy lifecycle hooks automatically
|
|
71
129
|
session.commit()
|
|
72
130
|
session.refresh(self)
|
|
73
131
|
|
|
74
|
-
|
|
75
|
-
# self.after_save()
|
|
76
|
-
|
|
77
|
-
return self
|
|
132
|
+
return self
|
|
78
133
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
134
|
+
# except IntegrityError:
|
|
135
|
+
# log.quiet(f"{self} already exists in the database.")
|
|
136
|
+
# session.rollback()
|
|
82
137
|
|
|
83
138
|
# TODO shouldn't this be handled by pydantic?
|
|
84
139
|
def json(self, **kwargs):
|
|
85
140
|
return json.dumps(self.dict(), default=str, **kwargs)
|
|
86
141
|
|
|
142
|
+
# TODO should move this to the wrapper
|
|
87
143
|
@classmethod
|
|
88
|
-
def count(cls):
|
|
144
|
+
def count(cls) -> int:
|
|
89
145
|
"""
|
|
90
146
|
Returns the number of records in the database.
|
|
91
147
|
"""
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
96
184
|
|
|
97
185
|
# TODO what's super dangerous here is you pass a kwarg which does not map to a specific
|
|
98
186
|
# field it will result in `True`, which will return all records, and not give you any typing
|
|
99
187
|
# errors. Dangerous when iterating on structure quickly
|
|
100
188
|
# TODO can we pass the generic of the superclass in?
|
|
101
189
|
@classmethod
|
|
102
|
-
def get(cls, *args: sa.BinaryExpression, **kwargs: t.Any):
|
|
190
|
+
# def get(cls, *args: sa.BinaryExpression, **kwargs: t.Any):
|
|
191
|
+
def get(cls, *args: t.Any, **kwargs: t.Any):
|
|
103
192
|
"""
|
|
104
193
|
Gets a single record from the database. Pass an PK ID or a kwarg to filter by.
|
|
105
194
|
"""
|
|
@@ -109,15 +198,17 @@ class BaseModel(SQLModel):
|
|
|
109
198
|
# TODO id is hardcoded, not good! Need to dynamically pick the best uid field
|
|
110
199
|
kwargs["id"] = args[0]
|
|
111
200
|
args = []
|
|
201
|
+
elif len(args) == 1 and isinstance(args[0], TypeID):
|
|
202
|
+
kwargs["id"] = args[0]
|
|
203
|
+
args = []
|
|
112
204
|
|
|
113
|
-
statement =
|
|
114
|
-
|
|
115
|
-
return session.exec(statement).first()
|
|
205
|
+
statement = select(cls).filter(*args).filter_by(**kwargs)
|
|
206
|
+
return get_session().exec(statement).first()
|
|
116
207
|
|
|
117
208
|
@classmethod
|
|
118
209
|
def all(cls):
|
|
119
210
|
with get_session() as session:
|
|
120
|
-
results = session.exec(sql.select(cls))
|
|
211
|
+
results = session.exec(sa.sql.select(cls))
|
|
121
212
|
|
|
122
213
|
# TODO do we need this or can we just return results?
|
|
123
214
|
for result in results:
|
activemodel/logger.py
ADDED
|
@@ -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
|
|
16
|
+
class TimestampsMixin:
|
|
18
17
|
"""
|
|
19
18
|
Simple created at and updated at timestamps. Mix them into your model:
|
|
20
19
|
|
|
21
|
-
>>> class MyModel(
|
|
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,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()
|
activemodel/query_wrapper.py
CHANGED
|
@@ -1,15 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
import sqlmodel
|
|
2
2
|
|
|
3
|
-
from sqlmodel.sql.expression import SelectOfScalar
|
|
4
3
|
|
|
5
|
-
|
|
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)
|
activemodel/utils.py
ADDED
|
@@ -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,20 @@
|
|
|
1
|
+
activemodel/__init__.py,sha256=lO75TeJeSm7spmP4P1_Z5oIXBHlL7kKjr1LS2Z4dNUA,109
|
|
2
|
+
activemodel/_session_manager.py,sha256=ojFIpK_fl1-JZsj98zn-zp4RIK1_eyDl-Ga4F1XXbVw,5249
|
|
3
|
+
activemodel/base_model.py,sha256=y8gLmowqk950AELE67u69YJAT9pJSmm67apjOd1g2yo,8081
|
|
4
|
+
activemodel/logger.py,sha256=vU7QiGSy_AJuJFmClUocqIJ-Ltku_8C24ZU8L6fLJR0,53
|
|
5
|
+
activemodel/query_wrapper.py,sha256=zqaA3yAHPTfGLrkzZ6ge6byedV497C7YG1QJe_4ah7Q,2100
|
|
6
|
+
activemodel/session_manager.py,sha256=zU4Eu3YeKSlZrYEBkN70XAHJOHka40Rl4qMBZvEgNwA,1822
|
|
7
|
+
activemodel/utils.py,sha256=dY6wvAS2RCqMVMQfnpXGJxgWRBet0jV5ZXxGIRX1iRw,476
|
|
8
|
+
activemodel/mixins/__init__.py,sha256=VAVLc96oSSultP0BNAlaE3ZBNGlcuVD0JjS1FMCno7k,72
|
|
9
|
+
activemodel/mixins/timestamps.py,sha256=Q-IFljeVVJQqw3XHdOi7dkqzefiVg1zhJvq_bldpmjg,992
|
|
10
|
+
activemodel/mixins/typeid.py,sha256=0TOJ74As9JL4WVvlRm0yuZB49xHAcX0n1fzwKpgdpys,894
|
|
11
|
+
activemodel/pytest/__init__.py,sha256=W9KKQHbPkyq0jrMXaiL8hG2Nsbjy_LN9HhvgGm8W_7g,98
|
|
12
|
+
activemodel/pytest/transaction.py,sha256=rrsoHnbu79kNdnI5fZeOZr5hzrLB-cQH10MueQp5jV4,1670
|
|
13
|
+
activemodel/pytest/truncate.py,sha256=BdltCtLQNPDgRSxpBnGYGSjB_7DAceV5kHdQ_vLrw74,1583
|
|
14
|
+
activemodel/types/typeid.py,sha256=rcr9tSiu5rowD_WOcF4zzBpEUy2izmYEPteDQgCIbhs,1799
|
|
15
|
+
activemodel-0.5.0.dist-info/LICENSE,sha256=L8mmpX47rB-xtJ_HsK0zpfO6viEjxbLYGn70BMp8os4,1071
|
|
16
|
+
activemodel-0.5.0.dist-info/METADATA,sha256=4nieriFioXBxTPOIuxul3RXzNMwKPiDgSYX4KMkwSbQ,2429
|
|
17
|
+
activemodel-0.5.0.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
|
18
|
+
activemodel-0.5.0.dist-info/entry_points.txt,sha256=YLX62TP_hR-n3HMBkdBex4W7XRiyOtIPkwy22puIjjQ,61
|
|
19
|
+
activemodel-0.5.0.dist-info/top_level.txt,sha256=JCMUN_seFIi6GXtnTQRWfxXDx6Oj1uok8qapQWbWKDM,12
|
|
20
|
+
activemodel-0.5.0.dist-info/RECORD,,
|
|
@@ -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,10 +0,0 @@
|
|
|
1
|
-
activemodel/__init__.py,sha256=WWLMytAi2VO3-HdySf4IpbeSCPMlVVO_GGQlQNA6UWU,216
|
|
2
|
-
activemodel/base_model.py,sha256=fk_g6jC1GCkAFo78UYjTONyFE8iYQH1bfvxdWG-_178,4200
|
|
3
|
-
activemodel/query_wrapper.py,sha256=JiYN8EN9_gXpKrFw348gs4QixwtrEN50fJF-3uv5Gmg,2319
|
|
4
|
-
activemodel/timestamps.py,sha256=8odUxQ1c0OouPAVioMTkD277w6S28Pk17pwCsaxKgww,991
|
|
5
|
-
activemodel-0.3.0.dist-info/LICENSE,sha256=L8mmpX47rB-xtJ_HsK0zpfO6viEjxbLYGn70BMp8os4,1071
|
|
6
|
-
activemodel-0.3.0.dist-info/METADATA,sha256=PrnCUuQVLSuCQ-tbwEMzHXrqMHq9MHTNYVgvRh9uXng,1174
|
|
7
|
-
activemodel-0.3.0.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
|
8
|
-
activemodel-0.3.0.dist-info/entry_points.txt,sha256=YLX62TP_hR-n3HMBkdBex4W7XRiyOtIPkwy22puIjjQ,61
|
|
9
|
-
activemodel-0.3.0.dist-info/top_level.txt,sha256=JCMUN_seFIi6GXtnTQRWfxXDx6Oj1uok8qapQWbWKDM,12
|
|
10
|
-
activemodel-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|