activemodel 0.10.0__py3-none-any.whl → 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
activemodel/base_model.py CHANGED
@@ -196,12 +196,13 @@ class BaseModel(SQLModel):
196
196
  "convenience method to avoid having to write .select().where() in order to add conditions"
197
197
  return cls.select().where(*args)
198
198
 
199
+ # TODO we should add an instance method for this as well
199
200
  @classmethod
200
201
  def upsert(
201
202
  cls,
202
203
  data: dict[str, t.Any],
203
204
  unique_by: str | list[str],
204
- ) -> None:
205
+ ) -> t.Self:
205
206
  """
206
207
  This method will insert a new record if it doesn't exist, or update the existing record if it does.
207
208
 
@@ -1,6 +1,7 @@
1
1
  from sqlmodel import Column, Field
2
2
  from typeid import TypeID
3
3
 
4
+ from activemodel.types import typeid_patch # noqa: F401
4
5
  from activemodel.types.typeid import TypeIDType
5
6
 
6
7
  # global list of prefixes to ensure uniqueness
@@ -8,6 +9,10 @@ _prefixes: list[str] = []
8
9
 
9
10
 
10
11
  def TypeIDMixin(prefix: str):
12
+ """
13
+ Mixin that adds a TypeID primary key field to a SQLModel. Specify the prefix to use for the TypeID.
14
+ """
15
+
11
16
  # make sure duplicate prefixes are not used!
12
17
  # NOTE this will cause issues on code reloads
13
18
  assert prefix
@@ -23,9 +28,12 @@ def TypeIDMixin(prefix: str):
23
28
  TypeIDType(prefix),
24
29
  primary_key=True,
25
30
  nullable=False,
31
+ # default on the sa_column level ensures that an ID is generated when creating a new record, even when
32
+ # raw SQLAlchemy operations are used instead of activemodel operations
26
33
  default=lambda: TypeID(prefix),
27
34
  ),
28
- # default_factory=lambda: TypeID(prefix),
35
+ # add a database comment to document the prefix, since it's not stored in the DB otherwise
36
+ description=f"TypeID with prefix: {prefix}",
29
37
  )
30
38
 
31
39
  _prefixes.append(prefix)
@@ -47,7 +47,7 @@ class SessionManager:
47
47
  "singleton instance of SessionManager"
48
48
 
49
49
  session_connection: Connection | None
50
- "optionally specify a specific session connection to use for all get_session() calls, useful for testing"
50
+ "optionally specify a specific session connection to use for all get_session() calls, useful for testing and migrations"
51
51
 
52
52
  @classmethod
53
53
  def get_instance(cls, database_url: str | None = None) -> "SessionManager":
@@ -82,6 +82,8 @@ class SessionManager:
82
82
  return self._engine
83
83
 
84
84
  def get_session(self):
85
+ "get a new database session, respecting any globally set sessions"
86
+
85
87
  if gsession := _session_context.get():
86
88
 
87
89
  @contextlib.contextmanager
@@ -103,23 +105,37 @@ def init(database_url: str):
103
105
 
104
106
 
105
107
  def get_engine():
108
+ "alias to get the database engine without importing SessionManager"
106
109
  return SessionManager.get_instance().get_engine()
107
110
 
108
111
 
109
112
  def get_session():
113
+ "alias to get a database session without importing SessionManager"
110
114
  return SessionManager.get_instance().get_session()
111
115
 
112
116
 
113
- # contextvars must be at the top-level of a module! You will not get a warning if you don't do this.
114
- # ContextVar is implemented in C, so it's very special and is both thread-safe and asyncio safe. This variable gives us
115
- # a place to persist a session to use globally across the application.
116
117
  _session_context = contextvars.ContextVar[Session | None](
117
118
  "session_context", default=None
118
119
  )
120
+ """
121
+ This is a VERY important ContextVar, it sets a global session to be used across all ActiveModel operations by default
122
+ and ensures get_session() uses this session as well.
123
+
124
+ contextvars must be at the top-level of a module! You will not get a warning if you don't do this.
125
+ ContextVar is implemented in C, so it's very special and is both thread-safe and asyncio safe. This variable gives us
126
+ a place to persist a session to use globally across the application.
127
+ """
119
128
 
120
129
 
121
130
  @contextlib.contextmanager
122
131
  def global_session():
132
+ """
133
+ Generate a session shared across all activemodel calls.
134
+
135
+ Alternatively, you can pass a session to use globally into the context manager, which is helpful for migrations
136
+ and testing.
137
+ """
138
+
123
139
  if _session_context.get() is not None:
124
140
  raise RuntimeError("global session already set")
125
141
 
@@ -2,7 +2,6 @@
2
2
  Lifted from: https://github.com/akhundMurad/typeid-python/blob/main/examples/sqlalchemy.py
3
3
  """
4
4
 
5
- from typing import Optional
6
5
  from uuid import UUID
7
6
 
8
7
  from pydantic import (
@@ -19,25 +18,32 @@ from activemodel.errors import TypeIDValidationError
19
18
  class TypeIDType(types.TypeDecorator):
20
19
  """
21
20
  A SQLAlchemy TypeDecorator that allows storing TypeIDs in the database.
22
- The prefix will not be persisted, instead the database-native UUID field will be used.
23
- At retrieval time a TypeID will be constructed based on the configured prefix and the
24
- UUID value from the database.
25
-
26
- Usage:
27
- # will result in TypeIDs such as "user_01h45ytscbebyvny4gc8cr8ma2"
28
- id = mapped_column(
29
- TypeIDType("user"),
30
- primary_key=True,
31
- default=lambda: TypeID("user")
32
- )
21
+
22
+ The prefix will not be persisted to the database, instead the database-native UUID field will be used.
23
+ At retrieval time a TypeID will be constructed (in python) based on the configured prefix and the UUID
24
+ value from the database.
25
+
26
+ For example:
27
+
28
+ >>> id = mapped_column(
29
+ >>> TypeIDType("user"),
30
+ >>> primary_key=True,
31
+ >>> default=lambda: TypeID("user")
32
+ >>> )
33
+
34
+ Will result in TypeIDs such as "user_01h45ytscbebyvny4gc8cr8ma2". There's a mixin provided to make it easy
35
+ to add a `id` pk field to your model with a specific prefix.
33
36
  """
34
37
 
38
+ # TODO are we sure we wouldn't use TypeID here?
35
39
  impl = types.Uuid
40
+ # TODO why the types version?
36
41
  # impl = uuid.UUID
42
+
37
43
  cache_ok = True
38
- prefix: Optional[str] = None
44
+ prefix: str
39
45
 
40
- def __init__(self, prefix: Optional[str], *args, **kwargs):
46
+ def __init__(self, prefix: str, *args, **kwargs):
41
47
  self.prefix = prefix
42
48
  super().__init__(*args, **kwargs)
43
49
 
@@ -90,6 +96,8 @@ class TypeIDType(types.TypeDecorator):
90
96
  raise ValueError("Unexpected input type")
91
97
 
92
98
  def process_result_value(self, value, dialect):
99
+ "convert a raw UUID, without a prefix, to a TypeID with the correct prefix"
100
+
93
101
  if value is None:
94
102
  return None
95
103
 
@@ -123,13 +131,19 @@ class TypeIDType(types.TypeDecorator):
123
131
  - https://github.com/alice-biometrics/petisco/blob/b01ef1b84949d156f73919e126ed77aa8e0b48dd/petisco/base/domain/model/uuid.py#L50
124
132
  """
125
133
 
134
+ def convert_from_string(value: str | TypeID) -> TypeID:
135
+ if isinstance(value, TypeID):
136
+ return value
137
+
138
+ return TypeID.from_string(value)
139
+
126
140
  from_uuid_schema = core_schema.chain_schema(
127
141
  [
128
142
  # TODO not sure how this is different from the UUID schema, should try it out.
129
143
  # core_schema.is_instance_schema(TypeID),
130
144
  # core_schema.uuid_schema(),
131
145
  core_schema.no_info_plain_validator_function(
132
- TypeID.from_string,
146
+ convert_from_string,
133
147
  json_schema_input_schema=core_schema.str_schema(),
134
148
  ),
135
149
  ]
@@ -151,11 +165,10 @@ class TypeIDType(types.TypeDecorator):
151
165
  # )
152
166
  # },
153
167
  python_schema=core_schema.union_schema([from_uuid_schema]),
154
- serialization=core_schema.plain_serializer_function_ser_schema(
155
- lambda x: str(x)
156
- ),
168
+ serialization=core_schema.plain_serializer_function_ser_schema(str),
157
169
  )
158
170
 
171
+ # TODO I have a feeling that the `serialization` param in the above method solves this for us.
159
172
  @classmethod
160
173
  def __get_pydantic_json_schema__(
161
174
  cls, schema: CoreSchema, handler: GetJsonSchemaHandler
@@ -164,18 +177,18 @@ class TypeIDType(types.TypeDecorator):
164
177
  Called when generating the openapi schema. This overrides the `function-plain` type which
165
178
  is generated by the `no_info_plain_validator_function`.
166
179
 
167
- This logis seems to be a hot part of the codebase, so I'd expect this to break as pydantic
180
+ This logic seems to be a hot part of the codebase, so I'd expect this to break as pydantic
168
181
  fastapi continue to evolve.
169
182
 
170
183
  Note that this method can return multiple types. A return value can be as simple as:
171
184
 
172
- {"type": "string"}
185
+ >>> {"type": "string"}
173
186
 
174
187
  Or, you could return a more specific JSON schema type:
175
188
 
176
- core_schema.uuid_schema()
189
+ >>> core_schema.uuid_schema()
177
190
 
178
- The problem with using something like uuid_schema is the specifi patterns
191
+ The problem with using something like uuid_schema is the specific patterns
179
192
 
180
193
  https://github.com/BeanieODM/beanie/blob/2190cd9d1fc047af477d5e6897cc283799f54064/beanie/odm/fields.py#L153
181
194
  """
@@ -0,0 +1,22 @@
1
+ from typing import Any, Type
2
+
3
+ from pydantic import GetCoreSchemaHandler
4
+ from pydantic_core import CoreSchema, core_schema
5
+
6
+ from typeid import TypeID
7
+
8
+
9
+ @classmethod
10
+ def get_pydantic_core_schema(
11
+ cls: Type[TypeID], source_type: Any, handler: GetCoreSchemaHandler
12
+ ) -> CoreSchema:
13
+ return core_schema.union_schema(
14
+ [
15
+ core_schema.str_schema(),
16
+ core_schema.is_instance_schema(cls),
17
+ ],
18
+ serialization=core_schema.plain_serializer_function_ser_schema(str),
19
+ )
20
+
21
+
22
+ TypeID.__get_pydantic_core_schema__ = get_pydantic_core_schema
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: activemodel
3
- Version: 0.10.0
3
+ Version: 0.11.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>
@@ -110,14 +110,14 @@ index 0d07420..a63631c 100644
110
110
  # Use forward slashes (/) also on windows to provide an os agnostic path
111
111
  -script_location = .
112
112
  +script_location = migrations
113
-
113
+
114
114
  # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
115
115
  # Uncomment the line below if you want the files to be prepended with date and time
116
116
  # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
117
117
  # for all available tokens
118
118
  # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
119
119
  +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(rev)s_%%(slug)s
120
-
120
+
121
121
  # sys.path path, will be prepended to sys.path if present.
122
122
  # defaults to the current working directory.
123
123
  diff --git i/test/migrations/env.py w/test/migrations/env.py
@@ -129,12 +129,12 @@ index 36112a3..a1e15c2 100644
129
129
  +# isort: off
130
130
  +
131
131
  from logging.config import fileConfig
132
-
132
+
133
133
  from sqlalchemy import engine_from_config
134
134
  @@ -14,11 +17,17 @@ config = context.config
135
135
  if config.config_file_name is not None:
136
136
  fileConfig(config.config_file_name)
137
-
137
+
138
138
  +from sqlmodel import SQLModel
139
139
  +from test.models import *
140
140
  +from test.utils import database_url
@@ -147,7 +147,7 @@ index 36112a3..a1e15c2 100644
147
147
  # target_metadata = mymodel.Base.metadata
148
148
  -target_metadata = None
149
149
  +target_metadata = SQLModel.metadata
150
-
150
+
151
151
  # other values from the config, defined by the needs of env.py,
152
152
  # can be acquired:
153
153
  diff --git i/test/migrations/script.py.mako w/test/migrations/script.py.mako
@@ -155,13 +155,13 @@ index fbc4b07..9dc78bb 100644
155
155
  --- i/test/migrations/script.py.mako
156
156
  +++ w/test/migrations/script.py.mako
157
157
  @@ -9,6 +9,8 @@ from typing import Sequence, Union
158
-
158
+
159
159
  from alembic import op
160
160
  import sqlalchemy as sa
161
161
  +import sqlmodel
162
162
  +import activemodel
163
163
  ${imports if imports else ""}
164
-
164
+
165
165
  # revision identifiers, used by Alembic.
166
166
  ```
167
167
 
@@ -178,7 +178,7 @@ This tool is added to all `BaseModel`s and makes it easy to write SQL queries. S
178
178
 
179
179
  ### Easy Database Sessions
180
180
 
181
- I hate the idea f
181
+ I hate the idea f
182
182
 
183
183
  * Behavior should be intuitive and easy to understand. If you run `save()`, it should save, not stick the save in a transaction.
184
184
  * Don't worry about dead sessions. This makes it easy to lazy-load computed properties and largely eliminates the need to think about database sessions.
@@ -186,7 +186,7 @@ I hate the idea f
186
186
  There are a couple of thorny problems we need to solve for here:
187
187
 
188
188
  * In-memory fastapi servers are not the same as a uvicorn server, which is threaded *and* uses some sort of threadpool model for handling async requests. I don't claim to understand the entire implementation. For global DB session state (a) we can't use global variables (b) we can't use thread-local variables.
189
- *
189
+ *
190
190
 
191
191
  https://github.com/tomwojcik/starlette-context
192
192
 
@@ -202,8 +202,11 @@ https://github.com/tomwojcik/starlette-context
202
202
  SQLModel & SQLAlchemy are tricky. Here are some useful internal tricks:
203
203
 
204
204
  * `__sqlmodel_relationships__` is where any `RelationshipInfo` objects are stored. This is used to generate relationship fields on the object.
205
- * `ModelClass.relationship_name.property.local_columns`
205
+ * `ModelClass.relationship_name.property.local_columns`
206
206
  * Get cached fields from a model `object_state(instance).dict.get(field_name)`
207
+ * Set the value on a field, without marking it as dirty `attributes.set_committed_value(instance, field_name, val)`
208
+ * Is a model dirty `instance_state(instance).modified`
209
+ * `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.
207
210
 
208
211
  ### TypeID
209
212
 
@@ -1,24 +1,25 @@
1
1
  activemodel/__init__.py,sha256=q_lHQyIM70ApvjduTo9GtenQjJXsfYZsAAquD_51kF4,137
2
- activemodel/base_model.py,sha256=VSItKKMxP-g-en_v16VnR9W6ueSLlWqmxn7I2-TgpGk,16646
2
+ activemodel/base_model.py,sha256=2lUcmOxS1i1K9qdKEVjI9vLpbFltUlh-cAfvGPYbXlI,16709
3
3
  activemodel/celery.py,sha256=L1vKcO_HoPA5ZCfsXjxgPpDUMYDuoQMakGA9rppN7Lo,897
4
4
  activemodel/errors.py,sha256=wycWYmk9ws4TZpxvTdtXVy2SFESb8NqKgzdivBoF0vw,115
5
5
  activemodel/get_column_from_field_patch.py,sha256=wAEDm_ZvSqyJwfgkXVpxsevw11hd-7VLy7zuJG8Ak7Y,4986
6
6
  activemodel/logger.py,sha256=vU7QiGSy_AJuJFmClUocqIJ-Ltku_8C24ZU8L6fLJR0,53
7
7
  activemodel/query_wrapper.py,sha256=rNdvueppMse2MIi-RafTEC34GPGRal_wqH2CzhmlWS8,2520
8
- activemodel/session_manager.py,sha256=ltmUyBsYCNNddoilLWrh3HX9QY9eQSZiRsyFf0awevs,4835
8
+ activemodel/session_manager.py,sha256=9Yb5sPOUginIC7M0oB3dvxkhaidX2iKGFpV3lpXkKzw,5454
9
9
  activemodel/utils.py,sha256=g17UqkphzTmb6YdpmYwT1TM00eDiXXuWn39-xNiu0AA,2112
10
10
  activemodel/mixins/__init__.py,sha256=05EQl2u_Wgf_wkly-GTaTsR7zWpmpKcb96Js7r_rZTw,160
11
11
  activemodel/mixins/pydantic_json.py,sha256=0pprGZA95BGZL4WOh--NJcvxLWey4YW85lLk4GGTjFM,3530
12
12
  activemodel/mixins/soft_delete.py,sha256=Ax4mGsQI7AVTE8c4GiWxpyB_W179-dDct79GtjP0owU,461
13
13
  activemodel/mixins/timestamps.py,sha256=Q-IFljeVVJQqw3XHdOi7dkqzefiVg1zhJvq_bldpmjg,992
14
- activemodel/mixins/typeid.py,sha256=WBZwnryF2QkI1ki0fW-jEbE8cIqMIldwkaeJdGT01S4,841
14
+ activemodel/mixins/typeid.py,sha256=VjhORJ-wf3HT43DMmez6MmZTWjH7fb5c-7Qcdwgdiqg,1331
15
15
  activemodel/pytest/__init__.py,sha256=W9KKQHbPkyq0jrMXaiL8hG2Nsbjy_LN9HhvgGm8W_7g,98
16
16
  activemodel/pytest/transaction.py,sha256=ln-3N5tXHT0fqy6a8m_NIYg5AXAeA2hDuftQtFxNqi4,2600
17
17
  activemodel/pytest/truncate.py,sha256=IGiPLkUm2yyOKww6c6CKcVbwi2xAAFBopx9q2ABfu8w,1582
18
18
  activemodel/types/__init__.py,sha256=y5fiGVtPJxGEhuf-TvyrkhM2yaKRcIWo6XAx-CFFjM8,31
19
- activemodel/types/typeid.py,sha256=1xB79DGIC5-P-PcLpeZW9Ed_WjFOmmVW1yl2Q3pPJis,7250
20
- activemodel-0.10.0.dist-info/METADATA,sha256=rin3Edbj6CFW8-cj6fcL6S6GU8a1Qn0Sl9tGOLM-_rw,9652
21
- activemodel-0.10.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
22
- activemodel-0.10.0.dist-info/entry_points.txt,sha256=YLX62TP_hR-n3HMBkdBex4W7XRiyOtIPkwy22puIjjQ,61
23
- activemodel-0.10.0.dist-info/licenses/LICENSE,sha256=L8mmpX47rB-xtJ_HsK0zpfO6viEjxbLYGn70BMp8os4,1071
24
- activemodel-0.10.0.dist-info/RECORD,,
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,,