mongo-entity-orm 0.1.3__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: mongo-entity-orm
3
- Version: 0.1.3
3
+ Version: 0.2.0
4
4
  Summary: A MongoDB ORM for Python
5
5
  License: MIT
6
6
  Keywords: mongodb,orm,mongo,async
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "mongo-entity-orm"
7
- version = "0.1.3"
7
+ version = "0.2.0"
8
8
  description = "A MongoDB ORM for Python"
9
9
  readme = "README.md"
10
10
  authors = ["Juraj Bezdek <juraj.bezdek@gmail.com>"]
@@ -44,6 +44,16 @@ class Config:
44
44
  return os.environ.get("MONGODB_INDEX_AUTOAPPLY", "never")
45
45
 
46
46
 
47
+ def _build_sync_client(uri: str) -> MongoClient:
48
+ # TLS is inferred from the URI (mongodb+srv:// / ?tls=true); we only relax
49
+ # certificate validation. Keeps plain mongodb://localhost working.
50
+ return MongoClient(uri, tlsAllowInvalidCertificates=True)
51
+
52
+
53
+ def _build_async_client(uri: str) -> AsyncIOMotorClient:
54
+ return AsyncIOMotorClient(uri, tlsAllowInvalidCertificates=True)
55
+
56
+
47
57
  def encode_tenant_id(tenant_id, id):
48
58
  return str(uuid5(NAMESPACE_URL, f"{id}/{tenant_id}"))
49
59
 
@@ -57,29 +67,79 @@ class Repository:
57
67
  all_entities: List[Type["BaseEntity"]] = []
58
68
 
59
69
  def __init__(self, mongo_db: MongoDatabase = None) -> None:
60
- if mongo_db:
61
- self.mongo_db = mongo_db
62
- else:
63
- mongodb_uri = Config.MONGODB_URI()
64
- mongo_db_name = Config.MONGO_DATABASE_NAME()
65
- if not mongodb_uri and not mongo_db_name:
66
- raise ValueError(
67
- "MONGODB_URI and MONGO_DATABASE_NAME environment variables are not set"
68
- )
69
- if not mongodb_uri:
70
- raise ValueError("MONGODB_URI environment variable is not set")
71
- if not mongo_db_name:
72
- raise ValueError("MONGO_DATABASE_NAME environment variable is not set")
73
-
74
- self.mongo_client = MongoClient(
75
- mongodb_uri, tlsAllowInvalidCertificates=True
76
- )
77
- self.async_mongo_client = AsyncIOMotorClient(
78
- mongodb_uri, tls=True, tlsAllowInvalidCertificates=True
70
+ # Nothing connects here. Clients are created lazily on first use so that
71
+ # importing the package / decorating entities has no network or env
72
+ # side effects (e.g. before load_dotenv() has run).
73
+ self._mongo_db = mongo_db
74
+ self._mongo_client = None
75
+ # One async client per running event loop. A motor client binds to the
76
+ # loop it first runs on, so it must not be reused across loops.
77
+ self._async_clients = {}
78
+
79
+ def _connect_sync(self) -> None:
80
+ if self._mongo_client is not None:
81
+ return
82
+ mongodb_uri = Config.MONGODB_URI()
83
+ mongo_db_name = Config.MONGO_DATABASE_NAME()
84
+ if not mongodb_uri and not mongo_db_name:
85
+ raise ValueError(
86
+ "MONGODB_URI and MONGO_DATABASE_NAME environment variables are not set"
79
87
  )
80
- self.mongo_client.admin.command("ping")
81
- self.mongo_db = self.mongo_client[str(mongo_db_name)]
82
- self.async_mongo_db = self.async_mongo_client[str(mongo_db_name)]
88
+ if not mongodb_uri:
89
+ raise ValueError("MONGODB_URI environment variable is not set")
90
+ if not mongo_db_name:
91
+ raise ValueError("MONGO_DATABASE_NAME environment variable is not set")
92
+
93
+ self._mongo_client = _build_sync_client(mongodb_uri)
94
+ self._mongo_client.admin.command("ping")
95
+ self._mongo_db = self._mongo_client[str(mongo_db_name)]
96
+
97
+ @property
98
+ def mongo_db(self) -> MongoDatabase:
99
+ if self._mongo_db is None:
100
+ self._connect_sync()
101
+ return self._mongo_db
102
+
103
+ @property
104
+ def mongo_client(self) -> MongoClient:
105
+ # If a db was injected, reuse its client instead of connecting via env.
106
+ if self._mongo_client is None and self._mongo_db is not None:
107
+ return self._mongo_db.client
108
+ if self._mongo_client is None:
109
+ self._connect_sync()
110
+ return self._mongo_client
111
+
112
+ def get_async_client(self) -> AsyncIOMotorClient:
113
+ loop = asyncio.get_running_loop()
114
+ client = self._async_clients.get(loop)
115
+ if client is None:
116
+ # Drop clients whose loop has been closed (e.g. between asyncio.run
117
+ # calls or pytest cases) so the cache doesn't grow unbounded.
118
+ for dead in [l for l in list(self._async_clients) if l.is_closed()]:
119
+ stale = self._async_clients.pop(dead, None)
120
+ if stale is not None:
121
+ try:
122
+ stale.close()
123
+ except Exception:
124
+ pass
125
+ client = _build_async_client(Config.MONGODB_URI())
126
+ self._async_clients[loop] = client
127
+ return client
128
+
129
+ def get_async_db(self):
130
+ return self.get_async_client()[str(Config.MONGO_DATABASE_NAME())]
131
+
132
+ def reset_async_client(self) -> None:
133
+ try:
134
+ loop = asyncio.get_running_loop()
135
+ except RuntimeError:
136
+ return
137
+ stale = self._async_clients.pop(loop, None)
138
+ if stale is not None:
139
+ try:
140
+ stale.close()
141
+ except Exception:
142
+ pass
83
143
 
84
144
 
85
145
  REPOSITORY = Repository()
@@ -115,6 +175,19 @@ class IndexSpec(TypedDict, total=False):
115
175
  weights: Dict[str, int]
116
176
 
117
177
 
178
+ class _CollectionProperty:
179
+ """Descriptor that resolves the (sync) pymongo collection lazily.
180
+
181
+ Kept so that ``Entity.collection`` / ``instance.collection`` keep working for
182
+ external callers, while never binding a concrete collection (and thus never
183
+ connecting) at decoration/import time. Works for both class and instance
184
+ access.
185
+ """
186
+
187
+ def __get__(self, obj, objtype=None):
188
+ return (objtype or type(obj)).get_collection()
189
+
190
+
118
191
  class BaseEntity(BaseModel):
119
192
  id: str = Field(
120
193
  default_factory=lambda: str(uuid4()),
@@ -129,7 +202,9 @@ class BaseEntity(BaseModel):
129
202
  __indexes__: Optional[List[IndexSpec]] = None
130
203
 
131
204
  def __init__(self, **data) -> None:
132
- if not hasattr(self.__class__, "collection"):
205
+ # Check __collection_name__ (a plain attr set by @entity) rather than
206
+ # `collection` (now a lazy descriptor whose access would connect).
207
+ if not hasattr(self.__class__, "__collection_name__"):
133
208
  raise Exception(
134
209
  "collection is not defined... did you forget to decorate the class with @metadata_entity?"
135
210
  )
@@ -183,27 +258,22 @@ class BaseEntity(BaseModel):
183
258
  callbacks.append(callback)
184
259
 
185
260
  def _get_self_collection(self) -> Collection:
186
- return self.collection
261
+ return self.get_collection()
187
262
 
188
263
  @classmethod
189
264
  def get_collection(cls) -> Collection:
190
- collection = None
191
- _cls = cls
192
- while _cls and collection is None:
193
- collection = getattr(cls, "collection", None)
194
- if collection is not None:
195
- return collection
196
- _cls = _cls.__base__ if issubclass(_cls.__base__, BaseEntity) else None
265
+ # Read the name first so an undecorated class raises AttributeError
266
+ # before REPOSITORY.mongo_db would trigger a connection.
267
+ name = cls.__collection_name__
268
+ return REPOSITORY.mongo_db[name]
197
269
 
198
270
  @classmethod
199
- def get_async_collection(cls, force_refresh=True) -> AsyncIOMotorCollection:
200
- if cls.acollection is None or force_refresh:
201
- # if the collection has not been initialized in wrong event loop, it needs to be reinitialized
202
- mongo_client = AsyncIOMotorClient(Config.MONGODB_URI())
203
- db = mongo_client[str(Config.MONGO_DATABASE_NAME())]
204
- cls.acollection = db[cls.__collection_name__]
205
-
206
- return cls.acollection
271
+ def get_async_collection(cls, force_refresh: bool = False) -> AsyncIOMotorCollection:
272
+ # The async client is cached per running event loop (see Repository).
273
+ # force_refresh drops the current loop's client and rebuilds it.
274
+ if force_refresh:
275
+ REPOSITORY.reset_async_client()
276
+ return REPOSITORY.get_async_db()[cls.__collection_name__]
207
277
 
208
278
  def get_id(self) -> str:
209
279
  return self.id
@@ -315,7 +385,7 @@ class BaseEntity(BaseModel):
315
385
  if tenant_id != "*":
316
386
  filter["tenant_id"] = tenant_id
317
387
 
318
- return cls.collection.delete_one(filter).deleted_count == 1
388
+ return cls.get_collection().delete_one(filter).deleted_count == 1
319
389
 
320
390
  @classmethod
321
391
  def get(
@@ -330,7 +400,7 @@ class BaseEntity(BaseModel):
330
400
  filter["tenant_id"] = tenant_id
331
401
  if namespace:
332
402
  filter["namespace"] = namespace
333
- data = cls.collection.find_one(filter)
403
+ data = cls.get_collection().find_one(filter)
334
404
  if not data and raise_not_found:
335
405
  raise Exception(
336
406
  f"Entity {cls.__name__} with id {id} not found for tenant {tenant_id}"
@@ -391,7 +461,7 @@ class BaseEntity(BaseModel):
391
461
  if limit:
392
462
  extra["limit"] = limit
393
463
 
394
- return cls.collection.count_documents(_filter, **extra)
464
+ return cls.get_collection().count_documents(_filter, **extra)
395
465
 
396
466
  @classmethod
397
467
  async def acount(cls, tenant_id: str = "*", filter: dict = None, limit=None) -> int:
@@ -589,18 +659,12 @@ def entity(
589
659
  collection_name: str, version_field: str = None, tracked_fields: List[str] = None
590
660
  ):
591
661
  def decorator(cls: Type[T]) -> Type[T]:
592
- if Config.MONGODB_URI() is None:
593
- raise Exception(
594
- "MONGODB_URI is not set in the environment variables. Please set it to connect to MongoDB."
595
- )
596
- if Config.MONGO_DATABASE_NAME() is None:
597
- raise Exception(
598
- "MONGO_DATABASE_NAME is not set in the environment variables. Please set it to connect to MongoDB."
599
- )
600
662
  cls.__collection_name__ = collection_name
601
- cls.collection = REPOSITORY.mongo_db[collection_name]
663
+ # Lazy: resolves REPOSITORY.mongo_db[collection_name] on access, so
664
+ # decoration opens no connection and needs no env yet (env is validated
665
+ # at first DB use). Env-less imports before load_dotenv() are safe.
666
+ cls.collection = _CollectionProperty()
602
667
 
603
- cls.acollection = None
604
668
  if "id" in cls.model_fields:
605
669
  cls.has_id = True
606
670
  else:
@@ -17,11 +17,11 @@ def _get_index_hash(collection_name: str, spec: "IndexSpec") -> str:
17
17
  # Normalize keys to a stable tuple representation
18
18
  if isinstance(keys, dict):
19
19
  # Keep order as provided in dict
20
- norm_keys = tuple((str(k), float(v)) for k, v in keys.items())
20
+ norm_keys = tuple((str(k), str(v)) for k, v in keys.items())
21
21
  elif isinstance(keys, list):
22
- norm_keys = tuple((str(k), float(v)) for k, v in keys)
22
+ norm_keys = tuple((str(k), str(v)) for k, v in keys)
23
23
  else:
24
- norm_keys = ((str(keys), 1.0),)
24
+ norm_keys = ((str(keys), "1.0"),)
25
25
 
26
26
  # Create a stable representation of the spec including important options
27
27
  relevant_parts = {
@@ -48,10 +48,10 @@ def index_exists(collection: Collection, spec: "IndexSpec") -> bool:
48
48
  elif isinstance(keys, list):
49
49
  target_key = keys
50
50
  else:
51
- target_key = [(keys, 1)]
51
+ target_key = [(keys, "1.0")]
52
52
 
53
- # Normalize values to floats for comparison
54
- target_key = [(str(k), float(v)) for k, v in target_key]
53
+ # Normalize values to strings for comparison
54
+ target_key = [(str(k), str(v)) for k, v in target_key]
55
55
 
56
56
  existing_indexes = collection.index_information()
57
57
 
@@ -59,9 +59,9 @@ def index_exists(collection: Collection, spec: "IndexSpec") -> bool:
59
59
  # info['key'] can be a list of tuples or a SON/dict object
60
60
  raw_existing_key = info["key"]
61
61
  if hasattr(raw_existing_key, "items"):
62
- existing_key = [(str(k), float(v)) for k, v in raw_existing_key.items()]
62
+ existing_key = [(str(k), str(v)) for k, v in raw_existing_key.items()]
63
63
  else:
64
- existing_key = [(str(k), float(v)) for k, v in raw_existing_key]
64
+ existing_key = [(str(k), str(v)) for k, v in raw_existing_key]
65
65
 
66
66
  if existing_key == target_key:
67
67
  return True