half-orm 1.0.0rc15__tar.gz → 1.0.0rc17__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 (40) hide show
  1. {half_orm-1.0.0rc15/half_orm.egg-info → half_orm-1.0.0rc17}/PKG-INFO +1 -1
  2. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/model.py +85 -11
  3. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/pg_meta.py +17 -2
  4. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/relation.py +35 -1
  5. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/transaction.py +93 -0
  6. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/utils.py +12 -2
  7. half_orm-1.0.0rc17/half_orm/version.txt +1 -0
  8. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17/half_orm.egg-info}/PKG-INFO +1 -1
  9. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/pyproject.toml +1 -1
  10. half_orm-1.0.0rc15/half_orm/version.txt +0 -1
  11. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/AUTHORS +0 -0
  12. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/LICENSE +0 -0
  13. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/README.md +0 -0
  14. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/__init__.py +0 -0
  15. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/__main__.py +0 -0
  16. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/cli.py +0 -0
  17. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/cli_utils.py +0 -0
  18. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/field.py +0 -0
  19. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/field_errors.py +0 -0
  20. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/fkey.py +0 -0
  21. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/hotest.py +0 -0
  22. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/migrations/BREAKING_CHANGES-1.0.0.md +0 -0
  23. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/migrations/__init__.py +0 -0
  24. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/model_errors.py +0 -0
  25. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/null.py +0 -0
  26. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/py.typed +0 -0
  27. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/relation_errors.py +0 -0
  28. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/relation_factory.py +0 -0
  29. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/sql_adapter.py +0 -0
  30. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/sql_ast.py +0 -0
  31. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm/testing.py +0 -0
  32. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm.egg-info/SOURCES.txt +0 -0
  33. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm.egg-info/dependency_links.txt +0 -0
  34. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm.egg-info/entry_points.txt +0 -0
  35. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm.egg-info/requires.txt +0 -0
  36. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/half_orm.egg-info/top_level.txt +0 -0
  37. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/setup.cfg +0 -0
  38. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/test/test_cli.py +0 -0
  39. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/test/test_main.py +0 -0
  40. {half_orm-1.0.0rc15 → half_orm-1.0.0rc17}/test/test_sql_ast.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: half_orm
3
- Version: 1.0.0rc15
3
+ Version: 1.0.0rc17
4
4
  Summary: A database-first ORM for PostgreSQL
5
5
  Author-email: Joël Maïzi <joel.maizi@collorg.org>
6
6
  License: GPL-3.0
@@ -53,6 +53,28 @@ def _sql_to_json_type(sql_type: str) -> str:
53
53
  base = sql_type.lstrip('_')
54
54
  return _SQL_TO_JSON.get(base, 'string')
55
55
 
56
+ def _normalize_with_half_orm_meta(value):
57
+ """Normalize the `with_half_orm_meta` constructor argument.
58
+
59
+ Accepts:
60
+ - True / False: expose every / no half_orm_meta.* relation.
61
+ - a comma-separated string, or any other iterable of strings, of
62
+ fully-qualified dotted relation names (e.g.
63
+ "half_orm_meta.identity.user") — an explicit opt-in allowlist.
64
+ Note the schema itself may contain dots (it is everything before
65
+ the LAST dot), but since `classes()`/`desc()` compare against the
66
+ same "schema.relname" join, no split is actually needed here.
67
+
68
+ Returns True, False, or a frozenset of dotted names.
69
+ """
70
+ if value is True or value is False:
71
+ return value
72
+ if isinstance(value, str):
73
+ names = {v.strip() for v in value.split(',') if v.strip()}
74
+ else:
75
+ names = {str(v).strip() for v in value if str(v).strip()}
76
+ return frozenset(names) if names else False
77
+
56
78
  register = register_class
57
79
 
58
80
  class Model:
@@ -71,6 +93,17 @@ class Model:
71
93
 
72
94
  ``name`` is the only mandatory key when using peer authentication.
73
95
  scope (str | None): package name used to resolve registered subclasses.
96
+ with_half_orm_meta (bool | str | Iterable[str]): controls whether
97
+ "half_orm_meta.*" relations (half_orm_dev's own metadata schemas)
98
+ are exposed through :meth:`classes`/:meth:`desc`. ``False``
99
+ (default): none are exposed. ``True``: all of them are. A
100
+ comma-separated string, or any other iterable of strings, of
101
+ fully-qualified dotted relation names (e.g.
102
+ ``"half_orm_meta.identity.user"``): only those specific
103
+ relations are exposed — an explicit opt-in allowlist. Exposed
104
+ half_orm_meta relations are always returned as a generic
105
+ :meth:`get_relation_class` instance, never imported from a
106
+ generated package module.
74
107
 
75
108
  Raises:
76
109
  MissingConfigFile: if the configuration file is not found.
@@ -80,12 +113,13 @@ class Model:
80
113
  __deja_vu = {}
81
114
  _classes_ = {}
82
115
  __sql_trace = False
83
- def __init__(self, config_file: None, scope: str=None):
116
+ def __init__(self, config_file: None, scope: str=None, with_half_orm_meta=False):
84
117
  self._dbinfo = {}
85
118
  self._production_mode = True
86
119
  self.__load_config(config_file)
87
120
  self._scope = scope and scope.split('.')[0]
88
121
  self.__thread_local = threading.local()
122
+ self.__with_half_orm_meta = _normalize_with_half_orm_meta(with_half_orm_meta)
89
123
  self.__schema_generation = 0
90
124
  self.__aconn = None
91
125
  self.__connect()
@@ -168,10 +202,11 @@ class Model:
168
202
  conn.adapters.register_dumper(Null, NullDumper)
169
203
  conn.adapters.register_dumper(Field, FieldDumper)
170
204
  conn.adapters.register_dumper(dict, JsonbDumper)
171
- self.__pg_meta = pg_meta.PgMeta(conn, reload)
205
+ self.__pg_meta = pg_meta.PgMeta(conn, self.__with_half_orm_meta, reload)
172
206
  if reload:
173
207
  self.__schema_generation += 1
174
208
  self._classes_[self._dbname] = {}
209
+ self.__deja_vu[self.__dbname] = self
175
210
  self.__thread_local.conn = conn
176
211
  self.__thread_local.schema_generation = self.__schema_generation
177
212
  if self.__dbname not in self.__class__.__deja_vu:
@@ -388,16 +423,42 @@ class Model:
388
423
  return self.__aconn
389
424
 
390
425
  async def _aexecute_query(self, query, values=None):
391
- """Internal async query executor — no crud_only check. Called by Relation.__aexecute."""
426
+ """Internal async query executor — no crud_only check. Called by Relation.__aexecute.
427
+
428
+ Mirrors _connection's sync auto-reconnect (see that property's
429
+ docstring: "Connection dropped unexpectedly (conn.closed):
430
+ reconnects automatically"). The async connection is only ever
431
+ opened once, explicitly, via aconnect() (a plain property can't
432
+ await a reconnect the way _connection does) — so without this, a
433
+ connection that goes idle-closed between requests (Postgres or an
434
+ intermediate proxy dropping it) breaks every ho_a* call until the
435
+ process restarts. Reconnect proactively if already flagged closed,
436
+ or by reconnecting once and retrying if the drop is only surfaced
437
+ when the query itself is attempted.
438
+ """
392
439
  values = self._unwrap_values(values)
393
- cursor = self._aconnection.cursor(row_factory=dict_row)
394
- try:
395
- await cursor.execute(query, values)
396
- except psycopg.Error as exc:
440
+
441
+ def _log_error():
397
442
  vals = ''
398
443
  if not self._production_mode:
399
444
  vals = f"values: {values}\n"
400
445
  utils.error(f"Query execution failed:\nquery: {query}\n{vals}")
446
+
447
+ if self.__aconn is not None and self.__aconn.closed:
448
+ await self.aconnect()
449
+ try:
450
+ cursor = self._aconnection.cursor(row_factory=dict_row)
451
+ await cursor.execute(query, values)
452
+ except (psycopg.OperationalError, psycopg.InterfaceError):
453
+ await self.aconnect()
454
+ try:
455
+ cursor = self._aconnection.cursor(row_factory=dict_row)
456
+ await cursor.execute(query, values)
457
+ except psycopg.Error as exc:
458
+ _log_error()
459
+ raise exc
460
+ except psycopg.Error as exc:
461
+ _log_error()
401
462
  raise exc
402
463
  return cursor
403
464
 
@@ -722,10 +783,23 @@ class Model:
722
783
  package_name = relation[1][0]
723
784
  module_name = ".".join(relation[1][1:])
724
785
  if module_name.find('half_orm_meta') == 0:
725
- continue
726
- class_name = pg_meta.camel_case(relation[1][-1])
727
- module = importlib.import_module(f".{module_name}", package_name)
728
- yield getattr(module, class_name), relation[0]
786
+ allow = self.__with_half_orm_meta
787
+ if allow is True or (allow and module_name in allow):
788
+ yield self.get_relation_class(module_name), relation[0]
789
+ else:
790
+ # getattr() on the freshly imported module reads the actual
791
+ # (possibly `@register`-ed) class straight from its namespace,
792
+ # regardless of the model._classes_ registry — which reload
793
+ # (reconnect(reload=True)) clears without re-running already
794
+ # imported modules, so get_relation_class() alone could go
795
+ # stale. Fall back to it only when there's no generated
796
+ # package to import from, or its conventional class is missing.
797
+ try:
798
+ class_name = pg_meta.camel_case(relation[1][-1])
799
+ module = importlib.import_module(f".{module_name}", package_name)
800
+ yield getattr(module, class_name), relation[0]
801
+ except (ModuleNotFoundError, AttributeError):
802
+ yield self.get_relation_class(module_name), relation[0]
729
803
 
730
804
  @property
731
805
  def sql_trace(self) -> bool:
@@ -207,18 +207,32 @@ class PgMeta:
207
207
  meta (_Meta): A singleton instance of the `_Meta` class.
208
208
  """
209
209
  meta = _Meta()
210
- def __init__(self, connection, reload=False):
210
+ def __init__(self, connection, with_half_orm_meta, reload=False):
211
211
  """Initializes a new instance of the `PgMeta` class.
212
212
 
213
213
  Args:
214
214
  connection (psycopg.extensions.connection): A connection object to a PostgreSQL database.
215
+ with_half_orm_meta (bool | frozenset[str]): already-normalized value
216
+ from Model — True (expose all half_orm_meta.* relations),
217
+ False (expose none), or a frozenset of allowed
218
+ "schema.relname" dotted names (opt-in allowlist).
215
219
  reload (bool, optional): A flag indicating whether to reload the metadata from the database. \
216
220
  Defaults to False.
217
221
  """
218
222
  self.__dbname = connection.info.get_parameters()['dbname']
223
+ self.__with_half_orm_meta = with_half_orm_meta
219
224
  if not PgMeta.meta.deja_vu(self.__dbname) or reload:
220
225
  self.__load_metadata(connection)
221
226
 
227
+ def __meta_allowed(self, schema, relname):
228
+ """True if this half_orm_meta.* relation should be exposed by desc()."""
229
+ allow = self.__with_half_orm_meta
230
+ if allow is True:
231
+ return True
232
+ if not allow:
233
+ return False
234
+ return f'{schema}.{relname}' in allow
235
+
222
236
  def metadata(self, dbname):
223
237
  """Retrieves the metadata for the specified database name.
224
238
 
@@ -354,7 +368,8 @@ class PgMeta:
354
368
  ret_val = []
355
369
  entry = self.metadata(dbname)['byname']
356
370
  for key in entry:
357
- if key[1].find('half_orm_meta') == 0: continue
371
+ if key[1].find('half_orm_meta') == 0 and not self.__meta_allowed(key[1], key[2]):
372
+ continue
358
373
  inh = []
359
374
  tablekind = entry[key]['tablekind']
360
375
  if entry[key]['inherits']:
@@ -31,7 +31,7 @@ from keyword import iskeyword
31
31
  import psycopg
32
32
  from psycopg.rows import dict_row
33
33
  from half_orm import relation_errors
34
- from half_orm.transaction import Transaction
34
+ from half_orm.transaction import Transaction, AsyncTransaction
35
35
  from half_orm.field import Field
36
36
  from half_orm import utils
37
37
  from half_orm.sql_ast import (
@@ -2061,6 +2061,40 @@ def transaction(fct):
2061
2061
  return wrapper
2062
2062
 
2063
2063
 
2064
+ def atransaction(fct):
2065
+ """Async decorator that wraps a Relation coroutine method in a database transaction.
2066
+
2067
+ Async counterpart of :func:`transaction`: drives the model's async
2068
+ connection (see :class:`~half_orm.transaction.AsyncTransaction`)
2069
+ instead of the sync one. The decorated method must be a coroutine
2070
+ function and use ``ho_a*`` methods for its INSERT / UPDATE / DELETE
2071
+ operations — ``await model.aconnect()`` must have been called first.
2072
+
2073
+ Nested ``@atransaction`` calls use PostgreSQL savepoints: a failure in
2074
+ an inner method rolls back only that inner scope.
2075
+
2076
+ Example:
2077
+ transaction decorator usage:
2078
+ ```python
2079
+ from half_orm.relation import atransaction
2080
+
2081
+ @register
2082
+ class Author(blog.get_relation_class('blog.author')):
2083
+ @atransaction
2084
+ async def apublish_many(self, posts):
2085
+ for title, content in posts:
2086
+ await self.post_rfk(title=title, content=content).ho_ainsert()
2087
+ ```
2088
+
2089
+ *New in version 0.18.0.*
2090
+ """
2091
+ @wraps(fct)
2092
+ async def wrapper(self, *args, **kwargs):
2093
+ async with AsyncTransaction(self._ho_model):
2094
+ return await fct(self, *args, **kwargs)
2095
+ return wrapper
2096
+
2097
+
2064
2098
  def ho_list(*relations):
2065
2099
  """Combine relations into a single OR predicate, avoiding N SQL queries.
2066
2100
 
@@ -104,3 +104,96 @@ class Transaction:
104
104
 
105
105
  def is_set(self):
106
106
  return self.__transaction.get('level', 0) > 0
107
+
108
+
109
+ class AsyncTransaction:
110
+ """Async context manager for atomic database operations.
111
+
112
+ Async counterpart of :class:`Transaction`: drives the model's async
113
+ connection (``model._aconnection``, set up via ``await
114
+ model.aconnect()``) instead of the sync one. Wraps one or more
115
+ ``ho_a*`` operations in a single transaction: commits on success, rolls
116
+ back on exception. Transactions are tracked per-model-instance (keyed
117
+ by ``id(model)``) — unlike :class:`Transaction`, there is no per-thread
118
+ isolation, since asyncio concurrency is task-based rather than
119
+ thread-based and a model's async connection is only ever driven from
120
+ one place at a time regardless of which thread runs the event loop.
121
+
122
+ Nested ``async with AsyncTransaction(model)`` blocks use PostgreSQL
123
+ savepoints automatically: an exception in an inner block rolls back
124
+ only that inner block, leaving the outer transaction intact.
125
+
126
+ Args:
127
+ model (Model): the :class:`~half_orm.model.Model` instance whose
128
+ async connection should be used.
129
+
130
+ Example:
131
+ Atomic insert of two related rows:
132
+ ```python
133
+ from half_orm.transaction import AsyncTransaction
134
+
135
+ async with AsyncTransaction(blog):
136
+ alice = await Author(
137
+ first_name='Alice', last_name='Martin',
138
+ email='alice@example.com',
139
+ ).ho_ainsert()
140
+ await Post(
141
+ title='First post', content='Hello world',
142
+ author_id=alice['id'],
143
+ ).ho_ainsert()
144
+ # both rows are committed, or neither is
145
+ ```
146
+
147
+ *New in version 0.18.0.*
148
+ """
149
+
150
+ __transactions: dict = {}
151
+
152
+ def __call__(self, model):
153
+ transactions = self.__class__.__transactions
154
+ self.__id = id(model)
155
+ self.__transaction = None
156
+ if self.__id not in transactions:
157
+ transactions[self.__id] = {
158
+ 'level': 0, 'model': model,
159
+ 'sp_counter': 0, 'sp_stack': [],
160
+ }
161
+ self.__transaction = transactions[self.__id]
162
+
163
+ __init__ = __call__
164
+
165
+ async def __aenter__(self):
166
+ conn = self.__transaction['model']._aconnection
167
+ if conn.autocommit:
168
+ await conn.set_autocommit(False)
169
+ if self.__transaction['level'] > 0:
170
+ self.__transaction['sp_counter'] += 1
171
+ sp_name = f'sp_{self.__transaction["sp_counter"]}'
172
+ self.__transaction['sp_stack'].append(sp_name)
173
+ async with conn.cursor() as cur:
174
+ await cur.execute(f'SAVEPOINT {sp_name}')
175
+ self.__transaction['level'] += 1
176
+
177
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
178
+ self.__transaction['level'] -= 1
179
+ conn = self.__transaction['model']._aconnection
180
+ if self.__transaction['level'] > 0:
181
+ sp_name = self.__transaction['sp_stack'].pop()
182
+ async with conn.cursor() as cur:
183
+ if exc_type is not None:
184
+ await cur.execute(f'ROLLBACK TO SAVEPOINT {sp_name}')
185
+ await cur.execute(f'RELEASE SAVEPOINT {sp_name}')
186
+ else:
187
+ try:
188
+ await conn.commit()
189
+ await conn.set_autocommit(True)
190
+ except psycopg.Error:
191
+ await conn.rollback()
192
+ return False
193
+
194
+ @property
195
+ def level(self):
196
+ return self.__transaction.get('level')
197
+
198
+ def is_set(self):
199
+ return self.__transaction.get('level', 0) > 0
@@ -45,15 +45,25 @@ def write(file_, data, mode='w'):
45
45
  with open(file_, mode=mode, encoding='utf-8') as text_io_wrapper:
46
46
  return text_io_wrapper.write(data)
47
47
 
48
- def error(msg: str, exit_code: int=None):
48
+ def error(msg: str, exit_code: int=None, show_caller: bool=False):
49
49
  "Write error message on stderr and exit if exit is not None"
50
50
  sys.stderr.write(f'{Color.bold("half-orm ERROR")}: {Color.red(msg)}')
51
+ if show_caller:
52
+ caller = get_caller_info()
53
+ if caller:
54
+ sys.stderr.write(f' {caller["filename"]}:{caller["lineno"]}, in {caller["function"]}\n'
55
+ f' {caller["code_context"]}\n')
51
56
  if exit_code:
52
57
  sys.exit(exit_code)
53
58
 
54
- def warning(msg: str, context="half-orm"):
59
+ def warning(msg: str, context="half-orm", show_caller: bool=False):
55
60
  "Write warning message on stderr"
56
61
  sys.stderr.write(f'{Color.bold(context + " WARNING")}: {msg}')
62
+ if show_caller:
63
+ caller = get_caller_info()
64
+ if caller:
65
+ sys.stderr.write(f' {caller["filename"]}:{caller["lineno"]}, in {caller["function"]}\n'
66
+ f' {caller["code_context"]}\n')
57
67
 
58
68
  class TraceDepth: #pragma: no coverage
59
69
  "Trace dept class"
@@ -0,0 +1 @@
1
+ 1.0.0rc17
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: half_orm
3
- Version: 1.0.0rc15
3
+ Version: 1.0.0rc17
4
4
  Summary: A database-first ORM for PostgreSQL
5
5
  Author-email: Joël Maïzi <joel.maizi@collorg.org>
6
6
  License: GPL-3.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta:__legacy__"
4
4
 
5
5
  [project]
6
6
  name = "half_orm"
7
- version = "1.0.0rc15"
7
+ version = "1.0.0rc17"
8
8
  description = "A database-first ORM for PostgreSQL"
9
9
  readme = "README.md"
10
10
  license = { text = "GPL-3.0" }
@@ -1 +0,0 @@
1
- 1.0.0rc15
File without changes
File without changes
File without changes
File without changes