half-orm-gen 1.0.0a1__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.
@@ -0,0 +1,62 @@
1
+ """
2
+ Role composition decorators for half-orm-litestar.
3
+
4
+ Two decorators are provided:
5
+
6
+ - @authorize_and(role_name): both the decorated function AND the named role
7
+ must return True (use for role refinement: "permanent IS a membre").
8
+
9
+ - @authorize_or(role_name): either the decorated function OR the named role
10
+ must return True (use for alternative access paths).
11
+
12
+ Example::
13
+
14
+ # api/roles/permanent.py
15
+ from api.roles.core import authorize_and
16
+
17
+ @authorize_and("membre")
18
+ async def authorize(path_params, jwt_payload):
19
+ return jwt_payload.is_permanent
20
+ """
21
+
22
+ import importlib
23
+ from functools import wraps
24
+ from typing import Awaitable, Callable
25
+
26
+ AuthorizeFunc = Callable[[dict, object], Awaitable[bool]]
27
+
28
+
29
+ def authorize_and(role_name: str) -> Callable[[AuthorizeFunc], AuthorizeFunc]:
30
+ """Compose authorization with AND logic.
31
+
32
+ The named role's authorize() must return True AND the decorated function
33
+ must return True.
34
+ """
35
+ def decorator(func: AuthorizeFunc) -> AuthorizeFunc:
36
+ @wraps(func)
37
+ async def wrapper(path_params: dict, jwt_payload) -> bool:
38
+ role_module = importlib.import_module(f"api.roles.{role_name}")
39
+ return (
40
+ await role_module.authorize(path_params, jwt_payload)
41
+ and await func(path_params, jwt_payload)
42
+ )
43
+ return wrapper
44
+ return decorator
45
+
46
+
47
+ def authorize_or(role_name: str) -> Callable[[AuthorizeFunc], AuthorizeFunc]:
48
+ """Compose authorization with OR logic.
49
+
50
+ The named role's authorize() returning True OR the decorated function
51
+ returning True is sufficient.
52
+ """
53
+ def decorator(func: AuthorizeFunc) -> AuthorizeFunc:
54
+ @wraps(func)
55
+ async def wrapper(path_params: dict, jwt_payload) -> bool:
56
+ role_module = importlib.import_module(f"api.roles.{role_name}")
57
+ return (
58
+ await role_module.authorize(path_params, jwt_payload)
59
+ or await func(path_params, jwt_payload)
60
+ )
61
+ return wrapper
62
+ return decorator
@@ -0,0 +1,454 @@
1
+ """
2
+ Template strings for the generated api/app.py.
3
+ """
4
+
5
+ FRAMEWORK = 'litestar'
6
+
7
+ HEADER = """\
8
+ # This file is generated by `half_orm litestar generate`. Do not edit by hand.
9
+ from typing import Union, Any, List, TypedDict
10
+ import datetime
11
+ import uuid
12
+ import os
13
+ import sys
14
+ import typing
15
+ from typing import Optional
16
+
17
+ cur_dir = os.path.dirname(os.path.abspath(__file__))
18
+ sys.path.insert(0, cur_dir)
19
+ par_dir = os.path.join(cur_dir, os.path.pardir)
20
+ sys.path.insert(0, par_dir)
21
+
22
+ from litestar import Request, Litestar, get, post, patch, put, delete, Response, MediaType
23
+ from litestar.exceptions import HTTPException
24
+ from litestar.status_codes import HTTP_500_INTERNAL_SERVER_ERROR
25
+ from litestar.logging import LoggingConfig
26
+ from litestar.openapi import OpenAPIConfig
27
+ from dataclasses import dataclass, field
28
+
29
+ from {module} import ho_baseclasses
30
+ from {module} import ho_typeddicts
31
+ from {module} import MODEL
32
+ from api import guards
33
+
34
+ try:
35
+ from api.custom.routes import routes
36
+ except ImportError:
37
+ routes = []
38
+
39
+ try:
40
+ from api.custom.middlewares import middlewares
41
+ except ImportError:
42
+ middlewares = []
43
+
44
+ try:
45
+ from api.custom.middlewares.authorization import Authorization
46
+ _auth_middleware = [Authorization]
47
+ except ImportError:
48
+ _auth_middleware = []
49
+
50
+ logging_config = LoggingConfig(
51
+ loggers={{
52
+ "app": {{
53
+ "level": "ERROR",
54
+ "handlers": ["queue_listener"],
55
+ }}
56
+ }}
57
+ )
58
+
59
+ """
60
+
61
+ FOOTER = '''
62
+ _HO_WARN = """
63
+ ======================================================================
64
+ halfORM DEV HELPERS ACTIVE — NOT FOR PRODUCTION
65
+ ======================================================================
66
+ /ho_roles : exposes all declared roles (no authentication)
67
+ /ho_access : exposes the full access map filtered by role
68
+ _get_roles : bearer token used directly as a role name
69
+ (no signature verification)
70
+ ho_dev : super-role with full access to all resources
71
+ (Authorization: Bearer ho_dev)
72
+
73
+ Replace the Authorization middleware with a real JWT implementation
74
+ before deploying to production.
75
+ ======================================================================
76
+ """
77
+
78
+ _HO_WARN_SHOWN = False
79
+
80
+ async def _ho_startup() -> None:
81
+ global _HO_WARN_SHOWN
82
+ import sys
83
+ await MODEL.aconnect()
84
+ if MODEL._production_mode:
85
+ raise RuntimeError(
86
+ "halfORM DEV HELPERS are active (ho_roles, ho_access, _get_roles fallback). "
87
+ "These routes and the bearer-token-as-role fallback are not safe for production. "
88
+ "Secure or remove them before deploying."
89
+ )
90
+ if not _HO_WARN_SHOWN:
91
+ print(_HO_WARN, file=sys.stderr, flush=True)
92
+ _HO_WARN_SHOWN = True
93
+
94
+ application = Litestar(
95
+ route_handlers=[{route_handlers}] + routes,
96
+ middleware=_auth_middleware + middlewares,
97
+ logging_config=logging_config,
98
+ on_startup=[_ho_startup],{openapi_config}
99
+ )
100
+ '''
101
+
102
+ OPENAPI_CONFIG = """
103
+ openapi_config=OpenAPIConfig(title="{title}", version="{version}"),"""
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # @api_* route templates
107
+ # ---------------------------------------------------------------------------
108
+
109
+ IMPORT = "\nfrom {schema} import {module_name} as {module_alias}\n"
110
+
111
+ GET = """
112
+ @get({litestar_args})
113
+ async def {full_name}({query_params}) -> typing.List[ho_baseclasses.{dc_name}]:
114
+ return await {module_alias}.{class_name}().{name}({params})
115
+ """
116
+
117
+ POST = """
118
+ @post({litestar_args})
119
+ async def {full_name}({query_params}) -> ho_baseclasses.{dc_name}:
120
+ return await {module_alias}.{class_name}().{name}({params})
121
+ """
122
+
123
+ PATCH = """
124
+ @patch({litestar_args})
125
+ async def {full_name}(request: "Request", data: ho_baseclasses.{dc_name}) -> ho_baseclasses.{dc_name}:
126
+ return await {module_alias}.{class_name}().{name}({params})
127
+ """
128
+
129
+ PUT = """
130
+ @put({litestar_args})
131
+ async def {full_name}(request: "Request", {path_params}) -> ho_baseclasses.{dc_name}:
132
+ return await {module_alias}.{class_name}().{name}({params})
133
+ """
134
+
135
+ DELETE = """
136
+ @delete({litestar_args})
137
+ async def {full_name}(request: "Request", {path_params}) -> None:
138
+ return await {module_alias}.{class_name}().{name}({params})
139
+ """
140
+
141
+ DIRECT_API = """
142
+ from {module_str} import {function} as {function_alias}
143
+ """
144
+
145
+ HTTP = {
146
+ 'GET': GET,
147
+ 'POST': POST,
148
+ 'PATCH': PATCH,
149
+ 'PUT': PUT,
150
+ 'DELETE': DELETE,
151
+ }
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # Auto-CRUD templates
155
+ # ---------------------------------------------------------------------------
156
+
157
+ CRUD_HELPERS = """
158
+ def _get_roles(request):
159
+ '''Return authorized roles: from middleware state, or bearer token as role (dev fallback).'''
160
+ roles = getattr(request.state, 'authorized_roles', None)
161
+ if roles is not None:
162
+ return roles
163
+ token = request.headers.get('Authorization', '').removeprefix('Bearer ').strip()
164
+ return [token] if token else ['public']
165
+
166
+
167
+ def _get_role_filter(crud_access, verb, authorized_roles):
168
+ '''Return mandatory filter kwargs for authorized roles (row-level security).'''
169
+ role_map = crud_access.get(verb, {})
170
+ combined = {}
171
+ for role in authorized_roles:
172
+ rv = role_map.get(role)
173
+ if isinstance(rv, dict) and 'filter' in rv:
174
+ combined.update(rv['filter'])
175
+ return combined
176
+
177
+
178
+ def _effective_out_fields(crud_access, verb, authorized_roles, api_excluded=None):
179
+ api_excluded = api_excluded or []
180
+ if 'ho_dev' in authorized_roles:
181
+ return [] # ho_dev: unrestricted access to all fields
182
+ role_map = crud_access.get(verb, {})
183
+ get_map = crud_access.get('GET', {})
184
+ fields = []
185
+ for role in authorized_roles:
186
+ if role not in role_map:
187
+ return None # role not authorized for this verb
188
+ rv = role_map[role]
189
+ if isinstance(rv, dict):
190
+ if 'out' in rv:
191
+ out = rv['out']
192
+ else:
193
+ get_rv = get_map.get(role)
194
+ out = get_rv if not isinstance(get_rv, dict) else get_rv.get('out')
195
+ else:
196
+ out = rv
197
+ if out is None:
198
+ return [] # role authorized, all fields
199
+ fields.extend(out)
200
+ return [f for f in dict.fromkeys(fields) if f not in api_excluded]
201
+
202
+
203
+ def _effective_in_fields(crud_access, verb, authorized_roles, api_excluded=None):
204
+ api_excluded = api_excluded or []
205
+ role_map = crud_access.get(verb, {})
206
+ fields = []
207
+ for role in authorized_roles:
208
+ rv = role_map.get(role)
209
+ if rv is None or not isinstance(rv, dict):
210
+ return []
211
+ in_val = rv.get('in')
212
+ if in_val is None:
213
+ return []
214
+ fields.extend(in_val)
215
+ return [f for f in dict.fromkeys(fields) if f not in api_excluded]
216
+
217
+
218
+ def get_access_map():
219
+ result = {}
220
+ for resource, verbs in _STATIC_ACCESS_MAP.items():
221
+ entry = {}
222
+ for verb, roles in verbs.items():
223
+ entry[verb] = dict(roles)
224
+ result[resource] = entry
225
+ if not MODEL._production_mode:
226
+ for resource, ho_verbs in _HO_DEV_MAP.items():
227
+ entry = result.setdefault(resource, {})
228
+ for verb, ho_val in ho_verbs.items():
229
+ verb_entry = dict(entry.get(verb, {}))
230
+ verb_entry['ho_dev'] = ho_val
231
+ entry[verb] = verb_entry
232
+ return result
233
+
234
+
235
+ def _filter_access_for_roles(access_map, authorized_roles):
236
+ result = {}
237
+ for resource, verbs in access_map.items():
238
+ resource_entry = {}
239
+ for verb, roles in verbs.items():
240
+ if verb == 'DELETE':
241
+ if any(r in roles and roles[r] == 'allowed' for r in authorized_roles):
242
+ resource_entry[verb] = True
243
+ else:
244
+ active = {r: roles[r] for r in authorized_roles if r in roles}
245
+ if not active:
246
+ continue
247
+ if verb == 'GET':
248
+ out = []
249
+ for v in active.values():
250
+ out.extend(v.get('out', []))
251
+ resource_entry[verb] = {'out': list(dict.fromkeys(out))}
252
+ else:
253
+ in_f, out_f = [], []
254
+ for v in active.values():
255
+ in_f.extend(v.get('in', []))
256
+ out_f.extend(v.get('out', []))
257
+ resource_entry[verb] = {
258
+ 'in': list(dict.fromkeys(in_f)),
259
+ 'out': list(dict.fromkeys(out_f)),
260
+ }
261
+ if resource_entry:
262
+ result[resource] = resource_entry
263
+ return result
264
+
265
+ """
266
+
267
+ CRUD_TYPEDICT = """
268
+ class {class_name}(TypedDict, total=False):
269
+ {fields}
270
+ """
271
+
272
+ CRUD_MODULE_IMPORT = "\nfrom {schema} import {module_name} as {module_alias}\n"
273
+
274
+ HO_ACCESS_ROUTE = (
275
+ '\n_STATIC_ACCESS_MAP = {json_str}\n'
276
+ '\n_ACCESS_MAP = get_access_map()\n\n'
277
+ '@get("{version_prefix}/ho_access", guards=[guards.public])\n'
278
+ 'async def _crud_access_map(request: Request) -> dict:\n'
279
+ ' authorized_roles = _get_roles(request)\n'
280
+ ' return _filter_access_for_roles(_ACCESS_MAP, authorized_roles)\n'
281
+ )
282
+
283
+ HO_ROLES_ROUTE = (
284
+ '\n_ROLES = {roles_json}\n\n'
285
+ '@get("{version_prefix}/ho_roles", guards=[guards.public])\n'
286
+ 'async def _crud_roles_list(request: Request) -> list:\n'
287
+ ' return _ROLES\n'
288
+ )
289
+
290
+
291
+ def typedict_block(class_name: str, field_names: list, all_fields: dict) -> str:
292
+ from half_orm_gen.crud_routes import _py_type_str
293
+ lines = [f'class {class_name}(TypedDict, total=False):']
294
+ valid = [(f, all_fields[f]) for f in field_names if f in all_fields]
295
+ if not valid:
296
+ lines.append(' pass')
297
+ else:
298
+ for fname, fobj in valid:
299
+ lines.append(f' {fname}: Optional[{_py_type_str(fobj.py_type)}]')
300
+ return '\n'.join(lines)
301
+
302
+ WS_HELPERS = """
303
+ import json as _json
304
+ from litestar import WebSocket, websocket
305
+
306
+ class _ConnectionManager:
307
+ def __init__(self):
308
+ self._sockets: set = set()
309
+
310
+ async def connect(self, socket: WebSocket) -> None:
311
+ await socket.accept()
312
+ self._sockets.add(socket)
313
+
314
+ def disconnect(self, socket: WebSocket) -> None:
315
+ self._sockets.discard(socket)
316
+
317
+ async def broadcast(self, message: dict) -> None:
318
+ dead = set()
319
+ for s in set(self._sockets):
320
+ try:
321
+ await s.send_data(_json.dumps(message, default=str))
322
+ except Exception:
323
+ dead.add(s)
324
+ self._sockets -= dead
325
+
326
+ _manager = _ConnectionManager()
327
+
328
+ @websocket("{version_prefix}/ws")
329
+ async def _ws_handler(socket: WebSocket) -> None:
330
+ await _manager.connect(socket)
331
+ try:
332
+ while True:
333
+ await socket.receive_data(mode="text")
334
+ except Exception:
335
+ pass
336
+ finally:
337
+ _manager.disconnect(socket)
338
+ """
339
+
340
+ CRUD_GET_LIST = """
341
+ @get("{path}", description="{access_description}")
342
+ async def {handler_name}(
343
+ request: Request,
344
+ {filter_params} fields: Optional[List[str]] = None,
345
+ limit: Optional[int] = None,
346
+ offset: Optional[int] = None,
347
+ ) -> list[{out_typedict}]:
348
+ api_excluded = getattr({module_alias}, 'API_EXCLUDED_FIELDS', [])
349
+ roles = _get_roles(request)
350
+ filter_kwargs = {{{filter_dict}}}
351
+ role_filter = _get_role_filter(getattr({module_alias}, 'CRUD_ACCESS', {{}}), "GET", roles)
352
+ authorized = _effective_out_fields(getattr({module_alias}, 'CRUD_ACCESS', {{}}), "GET", roles, api_excluded)
353
+ if authorized is None:
354
+ return []
355
+ if fields:
356
+ projection = [f for f in fields if not authorized or f in authorized]
357
+ else:
358
+ projection = authorized
359
+ return await {module_alias}.{class_name}(**{{**filter_kwargs, **role_filter}}).ho_aselect(
360
+ *projection, limit=limit, offset=offset
361
+ )
362
+ """
363
+
364
+ CRUD_GET_ONE = """
365
+ @get("{path}/{{id: {pk_path_type}}}", description="{access_description}")
366
+ async def {handler_name}_get(
367
+ request: Request,
368
+ id: {pk_py_type},
369
+ ) -> {out_typedict}:
370
+ api_excluded = getattr({module_alias}, 'API_EXCLUDED_FIELDS', [])
371
+ roles = _get_roles(request)
372
+ role_filter = _get_role_filter(getattr({module_alias}, 'CRUD_ACCESS', {{}}), "GET", roles)
373
+ authorized = _effective_out_fields(getattr({module_alias}, 'CRUD_ACCESS', {{}}), "GET", roles, api_excluded)
374
+ if authorized is None:
375
+ raise HTTPException(status_code=403)
376
+ rows = await {module_alias}.{class_name}({pk_instance_filter}, **role_filter).ho_aselect(*authorized)
377
+ if not rows:
378
+ raise HTTPException(status_code=404)
379
+ return rows[0]
380
+ """
381
+
382
+ CRUD_POST = """
383
+ @post("{path}", description="{access_description}")
384
+ async def {handler_name}_create(
385
+ request: Request,
386
+ data: {in_typedict},
387
+ ) -> {out_typedict}:
388
+ api_excluded = getattr({module_alias}, 'API_EXCLUDED_FIELDS', [])
389
+ in_fields = _effective_in_fields(getattr({module_alias}, 'CRUD_ACCESS', {{}}), "POST", _get_roles(request), api_excluded)
390
+ payload = {{k: v for k, v in dict(data).items() if v is not None and (not in_fields or k in in_fields)}}
391
+ result = await {module_alias}.{class_name}(**payload).ho_ainsert()
392
+ await _manager.broadcast({{"event": "create", "resource": "{resource}", "id": {pk_broadcast_expr}}})
393
+ return result
394
+ """
395
+
396
+ CRUD_PUT = """
397
+ @put("{path}/{{id: {pk_path_type}}}", description="{access_description}")
398
+ async def {handler_name}_update(
399
+ request: Request,
400
+ id: {pk_py_type},
401
+ data: {in_typedict},
402
+ ) -> {out_typedict}:
403
+ api_excluded = getattr({module_alias}, 'API_EXCLUDED_FIELDS', [])
404
+ in_fields = _effective_in_fields(getattr({module_alias}, 'CRUD_ACCESS', {{}}), "PUT", _get_roles(request), api_excluded)
405
+ payload = {{k: v for k, v in dict(data).items() if v is not None and (not in_fields or k in in_fields)}}
406
+ authorized = _effective_out_fields(getattr({module_alias}, 'CRUD_ACCESS', {{}}), "PUT", _get_roles(request), api_excluded)
407
+ result = await {module_alias}.{class_name}({pk_instance_filter}).ho_aupdate(*(authorized or ['*']), **payload)
408
+ if not result:
409
+ raise HTTPException(status_code=404)
410
+ await _manager.broadcast({{"event": "update", "resource": "{resource}", "id": id}})
411
+ return result[0]
412
+ """
413
+
414
+ CRUD_DELETE = """
415
+ @delete("{path}/{{id: {pk_path_type}}}", description="{access_description}")
416
+ async def {handler_name}_delete(
417
+ request: Request,
418
+ id: {pk_py_type},
419
+ ) -> None:
420
+ await _ws_broadcast_cascade(
421
+ {module_alias}.{class_name}({pk_instance_filter}), "{resource}", id
422
+ )
423
+ result = await {module_alias}.{class_name}({pk_instance_filter}).ho_adelete('*')
424
+ if not result:
425
+ raise HTTPException(status_code=404)
426
+ await _manager.broadcast({{"event": "delete", "resource": "{resource}", "id": id}})
427
+ """
428
+
429
+ WS_CASCADE_HELPER = """
430
+ _WS_RMAP: dict = {{
431
+ {resource_entries}
432
+ }}
433
+
434
+ async def _ws_broadcast_cascade(inst, resource: str, pk_val, _seen: set | None = None) -> None:
435
+ if _seen is None:
436
+ _seen = set()
437
+ _key = (resource, str(pk_val))
438
+ if _key in _seen:
439
+ return
440
+ _seen.add(_key)
441
+ for _fk in inst._ho_fkeys.values():
442
+ if not _fk.is_reverse or len(_fk.fk_names) != 1:
443
+ continue
444
+ _fk_field = _fk.fk_names[0]
445
+ _fqtn = _fk.remote['fqtn']
446
+ _r = f"{{_fqtn[0].replace('.', '_')}}/{{_fqtn[1]}}"
447
+ if _r not in _WS_RMAP:
448
+ continue
449
+ _cls, _pk = _WS_RMAP[_r]
450
+ for _row in await _cls(**{{_fk_field: pk_val}}).ho_aselect(_pk):
451
+ _rid = _row[_pk]
452
+ await _ws_broadcast_cascade(_cls(**{{_pk: _rid}}), _r, _rid, _seen)
453
+ await _manager.broadcast({{"event": "delete", "resource": _r, "id": _rid}})
454
+ """