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,505 @@
1
+ """
2
+ Auto-CRUD route generation from CRUD_ACCESS + halfORM introspection.
3
+
4
+ For each relation module that defines CRUD_ACCESS, generates Litestar
5
+ route handlers for the verbs declared, skipping any verb already covered
6
+ by an @api_* decorated method.
7
+ """
8
+
9
+ import importlib
10
+ import json
11
+ import pprint
12
+ from typing import Iterable, Tuple, Type
13
+
14
+ from half_orm.relation import Relation
15
+
16
+ from half_orm_gen import templates as T
17
+ from half_orm_gen.api_routes import _annotation_str
18
+
19
+
20
+ _LITESTAR_PATH_TYPE_MAP = {
21
+ 'uuid.UUID': 'uuid',
22
+ 'int': 'int',
23
+ 'str': 'str',
24
+ 'float': 'float',
25
+ 'decimal.Decimal': 'decimal',
26
+ 'datetime.date': 'date',
27
+ 'datetime.datetime': 'datetime',
28
+ 'datetime.time': 'time',
29
+ 'datetime.timedelta': 'timedelta',
30
+ }
31
+
32
+
33
+ def _py_type_str(py_type) -> str:
34
+ return _annotation_str(py_type)
35
+
36
+
37
+ def _path_type_str(py_type) -> str:
38
+ return _LITESTAR_PATH_TYPE_MAP.get(_py_type_str(py_type), 'str')
39
+
40
+
41
+ def _instance(relation):
42
+ return relation()
43
+
44
+
45
+ def _pk_info(relation) -> list[tuple[str, str, str]]:
46
+ """Return [(field_name, litestar_path_type, py_type_str), ...] for all PK columns.
47
+ Returns [] for relations with no PK (views, etc.).
48
+ """
49
+ pkey = getattr(_instance(relation), '_ho_pkey', {})
50
+ return [(name, _path_type_str(obj.py_type), _py_type_str(obj.py_type))
51
+ for name, obj in pkey.items()]
52
+
53
+
54
+ def _simple_pk(relation) -> Tuple[str, str, str] | None:
55
+ """Return (pk_field_name, litestar_path_type, py_type_str) for single-column PKs only."""
56
+ cols = _pk_info(relation)
57
+ return cols[0] if len(cols) == 1 else None
58
+
59
+
60
+ def _filter_params_str(all_fields: dict) -> Tuple[str, str]:
61
+ """Return (filter_params_block, filter_dict_str) for query-param filters."""
62
+ lines = []
63
+ dict_items = []
64
+ for fname, fobj in all_fields.items():
65
+ type_str = _py_type_str(fobj.py_type)
66
+ lines.append(f' {fname}: Optional[{type_str}] = None,\n')
67
+ dict_items.append(f"'{fname}': {fname}")
68
+ return ''.join(lines), ', '.join(dict_items)
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # CRUD_ACCESS parsing helpers
73
+ # ---------------------------------------------------------------------------
74
+
75
+ def _resolved_out(crud_access: dict, verb: str, role: str):
76
+ """Return the 'out' field list (or None) for role/verb, resolving GET inheritance."""
77
+ rv = crud_access.get(verb, {}).get(role)
78
+ if verb in ('GET', 'DELETE'):
79
+ return rv # sugar form: value IS out (or None = all)
80
+ if not isinstance(rv, dict):
81
+ return None
82
+ if 'out' in rv:
83
+ return rv['out']
84
+ # inherit from GET
85
+ get_rv = crud_access.get('GET', {}).get(role)
86
+ return get_rv if not isinstance(get_rv, dict) else get_rv.get('out')
87
+
88
+
89
+ def _resolved_in(crud_access: dict, verb: str, role: str):
90
+ """Return the 'in' field list (or None = all fields) for role/verb."""
91
+ rv = crud_access.get(verb, {}).get(role)
92
+ if not isinstance(rv, dict):
93
+ return None
94
+ return rv.get('in')
95
+
96
+
97
+ def _gen_out_fields(crud_access: dict, verb: str, api_excluded: list, all_field_names: list) -> list:
98
+ """Union of out fields across all roles for a verb (generation-time, for TypedDicts)."""
99
+ collected = []
100
+ for role in crud_access.get(verb, {}):
101
+ out = _resolved_out(crud_access, verb, role)
102
+ if out is None:
103
+ return [f for f in all_field_names if f not in api_excluded]
104
+ collected.extend(out)
105
+ seen = set()
106
+ result = []
107
+ for f in collected:
108
+ if f not in seen and f not in api_excluded and f in all_field_names:
109
+ seen.add(f)
110
+ result.append(f)
111
+ return result
112
+
113
+
114
+ def _gen_in_fields(crud_access: dict, verb: str, pk_field: str,
115
+ api_excluded: list, all_field_names: list,
116
+ pk_has_default: bool = True) -> list:
117
+ """Union of in fields across all roles for a verb, minus excluded fields.
118
+ PK is excluded only when pk_has_default is True (DB generates it).
119
+ For PUT the PK is always excluded (it comes from the URL path).
120
+ """
121
+ exclude_pk = pk_field if pk_has_default else None
122
+ collected = []
123
+ for role in crud_access.get(verb, {}):
124
+ in_val = _resolved_in(crud_access, verb, role)
125
+ if in_val is None:
126
+ return [f for f in all_field_names if f != exclude_pk and f not in api_excluded]
127
+ collected.extend(in_val)
128
+ seen = set()
129
+ result = []
130
+ for f in collected:
131
+ if f not in seen and f not in api_excluded and f != exclude_pk and f in all_field_names:
132
+ seen.add(f)
133
+ result.append(f)
134
+ return result
135
+
136
+
137
+ def _typedict_block(class_name: str, field_names: list, all_fields: dict) -> str:
138
+ """Return a TypedDict class definition string."""
139
+ lines = [f'class {class_name}(TypedDict, total=False):']
140
+ valid = [(f, all_fields[f]) for f in field_names if f in all_fields]
141
+ if not valid:
142
+ lines.append(' pass')
143
+ else:
144
+ for fname, fobj in valid:
145
+ lines.append(f' {fname}: Optional[{_py_type_str(fobj.py_type)}]')
146
+ return '\n'.join(lines)
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # /access endpoint payload builder
151
+ # ---------------------------------------------------------------------------
152
+
153
+ def _build_access_entry(
154
+ crud_access: dict,
155
+ api_excluded: list,
156
+ all_names: list,
157
+ covered: set,
158
+ module_str: str,
159
+ ) -> dict:
160
+ """Build the access map entry for one relation (used by GET /access)."""
161
+ entry = {}
162
+ for verb in ('GET', 'POST', 'PUT', 'DELETE'):
163
+ if (module_str, verb) in covered:
164
+ continue
165
+ roles = crud_access.get(verb)
166
+ if not roles:
167
+ continue
168
+ verb_entry = {}
169
+ for role, rv in roles.items():
170
+ if verb == 'GET':
171
+ out = rv if not isinstance(rv, dict) else rv.get('out')
172
+ verb_entry[role] = {
173
+ 'out': (
174
+ [f for f in all_names if f not in api_excluded]
175
+ if out is None else
176
+ [f for f in out if f not in api_excluded]
177
+ )
178
+ }
179
+ elif verb == 'DELETE':
180
+ verb_entry[role] = 'allowed'
181
+ else: # POST / PUT
182
+ in_val = _resolved_in(crud_access, verb, role)
183
+ out = _resolved_out(crud_access, verb, role)
184
+ verb_entry[role] = {
185
+ 'in': (
186
+ [f for f in all_names if f not in api_excluded]
187
+ if in_val is None else
188
+ [f for f in in_val if f not in api_excluded]
189
+ ),
190
+ 'out': (
191
+ [f for f in all_names if f not in api_excluded]
192
+ if out is None else
193
+ [f for f in out if f not in api_excluded]
194
+ ),
195
+ }
196
+ if verb_entry:
197
+ entry[verb] = verb_entry
198
+ return entry
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # OpenAPI description
203
+ # ---------------------------------------------------------------------------
204
+
205
+ def _access_description(crud_access: dict, verb: str) -> str:
206
+ """Format CRUD_ACCESS role/field info for a verb as an OpenAPI description."""
207
+ roles = crud_access.get(verb, {})
208
+ if not roles:
209
+ return ""
210
+ lines = ["**Access**"]
211
+ for role, rv in roles.items():
212
+ if verb == 'GET':
213
+ if rv is None:
214
+ lines.append(f"- {role}: all fields")
215
+ else:
216
+ lines.append(f"- {role}: {', '.join(rv)}")
217
+ elif verb == 'DELETE':
218
+ lines.append(f"- {role}: allowed")
219
+ else:
220
+ # POST / PUT — {"in": ..., "out": ...}
221
+ parts = []
222
+ in_val = rv.get('in') if isinstance(rv, dict) else None
223
+ parts.append("in=all" if in_val is None else f"in=[{', '.join(in_val)}]")
224
+ out = _resolved_out(crud_access, verb, role)
225
+ parts.append("out=all" if out is None else f"out=[{', '.join(out)}]")
226
+ lines.append(f"- {role}: {', '.join(parts)}")
227
+ return "\\n".join(lines)
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Validation
232
+ # ---------------------------------------------------------------------------
233
+
234
+ def _validate_crud_access(crud_access: dict, module_str: str) -> None:
235
+ valid_verbs = {'GET', 'POST', 'PUT', 'PATCH', 'DELETE'}
236
+ for verb, roles in crud_access.items():
237
+ if verb not in valid_verbs:
238
+ print(f' WARNING {module_str}.CRUD_ACCESS: unknown verb "{verb}"')
239
+ continue
240
+ if verb == 'DELETE':
241
+ for role, rv in roles.items():
242
+ if isinstance(rv, dict):
243
+ print(f' WARNING {module_str}.CRUD_ACCESS["DELETE"]["{role}"]: '
244
+ f'dict form has no effect on DELETE (no request body) — use None')
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Main generation
249
+ # ---------------------------------------------------------------------------
250
+
251
+ def generate_crud_routes(
252
+ classes: Iterable[Tuple[Type[Relation], str]],
253
+ api_version,
254
+ covered: set,
255
+ templates=None,
256
+ ) -> Tuple[list, list]:
257
+ """Generate auto-CRUD blocks for all relations that define CRUD_ACCESS.
258
+
259
+ Skips verbs already covered by @api_* (present in *covered* set).
260
+ Returns (blocks, route_handler_names).
261
+ """
262
+ if templates is None:
263
+ templates = T
264
+
265
+ decl_blocks: list[str] = [] # imports + typedicts
266
+ handler_blocks: list[str] = [] # route handlers
267
+ route_handlers: list[str] = []
268
+ access_map: dict = {}
269
+ ho_dev_map: dict = {}
270
+ roles: set[str] = {'ho_dev'}
271
+ crud_resource_map: list[tuple] = [] # (resource, module_alias, class_name, pk_field)
272
+
273
+ version_prefix = f'/v{api_version}' if api_version is not None else ''
274
+
275
+ for relation, _relation_type in classes:
276
+ module_str = relation.__module__
277
+ schema = '.'.join(module_str.split('.')[:-1])
278
+ module_name = module_str.split('.')[-1]
279
+ module_alias = module_str.replace('.', '_')
280
+
281
+ try:
282
+ mod = importlib.import_module(module_str)
283
+ except ImportError as exc:
284
+ print(f' WARNING: cannot import {module_str}: {exc}')
285
+ continue
286
+
287
+ crud_access = getattr(mod, 'CRUD_ACCESS', None)
288
+ if not crud_access:
289
+ crud_access = {'GET': {}, 'POST': {}, 'PUT': {}, 'DELETE': {}}
290
+
291
+ _validate_crud_access(crud_access, module_str)
292
+
293
+ for verb_roles in crud_access.values():
294
+ if isinstance(verb_roles, dict):
295
+ roles.update(verb_roles.keys())
296
+
297
+ api_excluded = getattr(mod, 'API_EXCLUDED_FIELDS', [])
298
+ kind = getattr(relation, '_ho_kind', 'Table')
299
+ is_table = kind == 'Table'
300
+ schema_name = relation._t_fqrn[1]
301
+ table_name = relation._t_fqrn[2]
302
+ base_path = f'{version_prefix}/{schema_name}/{table_name}'
303
+ resource = f'{schema_name}/{table_name}'
304
+ handler_prefix = f'_crud_{module_alias}'
305
+ pk_cols = _pk_info(relation)
306
+ pk_info = pk_cols # truthy iff non-empty
307
+
308
+ if len(pk_cols) == 1:
309
+ pk_field, pk_path_type, pk_py_type = pk_cols[0]
310
+ pk_instance_filter = f'{pk_field}=id'
311
+ pk_broadcast_expr = f'result.get("{pk_field}")'
312
+ elif len(pk_cols) > 1:
313
+ pk_field = pk_cols[0][0] # first field; used in WS cascade map
314
+ pk_path_type = 'str'
315
+ pk_py_type = 'str'
316
+ _pk_names = [f for f, _, _ in pk_cols]
317
+ pk_instance_filter = f"**dict(zip({_pk_names!r}, id.split('::')))"
318
+ pk_broadcast_expr = f"'::'.join(str(result.get(f, '')) for f in {_pk_names!r})"
319
+ else:
320
+ pk_field = pk_path_type = pk_py_type = pk_instance_filter = pk_broadcast_expr = None
321
+
322
+ instance = _instance(relation)
323
+ all_fields = getattr(instance, '_ho_fields', {})
324
+ all_names = list(all_fields.keys())
325
+
326
+ decl_blocks.append(templates.CRUD_MODULE_IMPORT.format(
327
+ schema=schema,
328
+ module_name=module_name,
329
+ module_alias=module_alias,
330
+ ))
331
+
332
+ # Out TypedDict / Pydantic model (driven by GET, used for all return types)
333
+ out_class = f'_Out_{module_alias}'
334
+ out_names = _gen_out_fields(crud_access, 'GET', api_excluded, all_names)
335
+ if not out_names:
336
+ out_names = [f for f in all_names if f not in api_excluded]
337
+ decl_blocks.append('\n' + templates.typedict_block(out_class, out_names, all_fields) + '\n')
338
+
339
+ filter_params, filter_dict = _filter_params_str(all_fields)
340
+ get_desc = _access_description(crud_access, 'GET')
341
+
342
+ # GET list
343
+ if (module_str, 'GET') not in covered and 'GET' in crud_access:
344
+ handler_name = f'{handler_prefix}_list'
345
+ handler_blocks.append(templates.CRUD_GET_LIST.format(
346
+ path=base_path,
347
+ handler_name=handler_name,
348
+ filter_params=filter_params,
349
+ filter_dict=filter_dict,
350
+ module_alias=module_alias,
351
+ class_name=relation.__name__,
352
+ out_typedict=out_class,
353
+ access_description=get_desc,
354
+ ))
355
+ route_handlers.append(handler_name)
356
+
357
+ # GET /{pk}
358
+ if pk_info and (module_str, 'GET') not in covered and 'GET' in crud_access:
359
+ handler_name = f'{handler_prefix}_get'
360
+ handler_blocks.append(templates.CRUD_GET_ONE.format(
361
+ path=base_path,
362
+ handler_name=handler_prefix,
363
+ pk_instance_filter=pk_instance_filter,
364
+ pk_path_type=pk_path_type,
365
+ pk_py_type=pk_py_type,
366
+ module_alias=module_alias,
367
+ class_name=relation.__name__,
368
+ out_typedict=out_class,
369
+ access_description=get_desc,
370
+ ))
371
+ route_handlers.append(handler_name)
372
+
373
+ # Write verbs — tables only
374
+ if is_table and pk_info:
375
+ pk_has_default = bool(
376
+ pk_field and all_fields.get(pk_field) and
377
+ all_fields[pk_field].has_default_value is not None
378
+ )
379
+ if (module_str, 'POST') not in covered and 'POST' in crud_access:
380
+ post_in_class = f'_In_{module_alias}_post'
381
+ post_in_names = _gen_in_fields(crud_access, 'POST', pk_field, api_excluded, all_names,
382
+ pk_has_default)
383
+ if not post_in_names:
384
+ post_in_names = [f for f in all_names
385
+ if (f != pk_field or not pk_has_default) and f not in api_excluded]
386
+ decl_blocks.append('\n' + templates.typedict_block(post_in_class, post_in_names, all_fields) + '\n')
387
+ handler_name = f'{handler_prefix}_create'
388
+ handler_blocks.append(templates.CRUD_POST.format(
389
+ path=base_path,
390
+ handler_name=handler_prefix,
391
+ module_alias=module_alias,
392
+ class_name=relation.__name__,
393
+ in_typedict=post_in_class,
394
+ out_typedict=out_class,
395
+ access_description=_access_description(crud_access, 'POST'),
396
+ resource=resource,
397
+ pk_broadcast_expr=pk_broadcast_expr,
398
+ ))
399
+ route_handlers.append(handler_name)
400
+
401
+ if (module_str, 'PUT') not in covered and 'PUT' in crud_access:
402
+ put_in_class = f'_In_{module_alias}_put'
403
+ put_in_names = _gen_in_fields(crud_access, 'PUT', pk_field, api_excluded, all_names)
404
+ if not put_in_names:
405
+ put_in_names = [f for f in all_names if f != pk_field and f not in api_excluded]
406
+ decl_blocks.append('\n' + templates.typedict_block(put_in_class, put_in_names, all_fields) + '\n')
407
+ handler_name = f'{handler_prefix}_update'
408
+ handler_blocks.append(templates.CRUD_PUT.format(
409
+ path=base_path,
410
+ handler_name=handler_prefix,
411
+ pk_instance_filter=pk_instance_filter,
412
+ pk_path_type=pk_path_type,
413
+ pk_py_type=pk_py_type,
414
+ module_alias=module_alias,
415
+ class_name=relation.__name__,
416
+ in_typedict=put_in_class,
417
+ out_typedict=out_class,
418
+ access_description=_access_description(crud_access, 'PUT'),
419
+ resource=resource,
420
+ ))
421
+ route_handlers.append(handler_name)
422
+
423
+ if (module_str, 'DELETE') not in covered and 'DELETE' in crud_access:
424
+ handler_name = f'{handler_prefix}_delete'
425
+ handler_blocks.append(templates.CRUD_DELETE.format(
426
+ path=base_path,
427
+ handler_name=handler_prefix,
428
+ pk_instance_filter=pk_instance_filter,
429
+ pk_path_type=pk_path_type,
430
+ pk_py_type=pk_py_type,
431
+ module_alias=module_alias,
432
+ class_name=relation.__name__,
433
+ access_description=_access_description(crud_access, 'DELETE'),
434
+ resource=resource,
435
+ ))
436
+ route_handlers.append(handler_name)
437
+ crud_resource_map.append((resource, module_alias, relation.__name__, pk_field))
438
+
439
+ # Accumulate access map entry
440
+ map_key = f'{schema_name}/{table_name}'
441
+ entry = _build_access_entry(crud_access, api_excluded, all_names, covered, module_str)
442
+ if entry:
443
+ access_map[map_key] = entry
444
+
445
+ # ho_dev_map: full access entry for every generated resource
446
+ out_all = [f for f in all_names if f not in api_excluded]
447
+ ho_dev_entry: dict = {}
448
+ if 'GET' in crud_access and (module_str, 'GET') not in covered:
449
+ ho_dev_entry['GET'] = {'out': out_all}
450
+ if is_table and pk_info:
451
+ _pk_names_set = {f for f, _, _ in pk_cols}
452
+ in_all = [f for f in all_names if f not in api_excluded and f not in _pk_names_set]
453
+ if 'POST' in crud_access and (module_str, 'POST') not in covered:
454
+ ho_dev_entry['POST'] = {'in': in_all, 'out': out_all}
455
+ if 'PUT' in crud_access and (module_str, 'PUT') not in covered:
456
+ ho_dev_entry['PUT'] = {'in': in_all, 'out': out_all}
457
+ if 'DELETE' in crud_access and (module_str, 'DELETE') not in covered:
458
+ ho_dev_entry['DELETE'] = 'allowed'
459
+ if ho_dev_entry:
460
+ ho_dev_map[map_key] = ho_dev_entry
461
+
462
+ # Assemble: decl_blocks first, then WS helpers, then route handlers
463
+ blocks = decl_blocks
464
+
465
+ # WebSocket push endpoint (defines _manager)
466
+ if hasattr(templates, 'WS_HELPERS'):
467
+ blocks.append(templates.WS_HELPERS.format(version_prefix=version_prefix))
468
+ if getattr(templates, 'FRAMEWORK', 'litestar') == 'litestar':
469
+ route_handlers.append('_ws_handler')
470
+
471
+ # Cascade broadcast helper (uses _manager, must come after WS_HELPERS)
472
+ if hasattr(templates, 'WS_CASCADE_HELPER') and crud_resource_map:
473
+ resource_entries = '\n'.join(
474
+ f' "{res}": ({mod}.{cls}, "{pk}"),'
475
+ for res, mod, cls, pk in crud_resource_map
476
+ )
477
+ blocks.append(templates.WS_CASCADE_HELPER.format(
478
+ resource_entries=resource_entries,
479
+ ))
480
+
481
+ blocks.extend(handler_blocks)
482
+
483
+ # /ho_roles endpoint — static list of all roles present in CRUD_ACCESS
484
+ if roles:
485
+ blocks.append(
486
+ templates.HO_ROLES_ROUTE.format(
487
+ roles_json=json.dumps(sorted(roles)),
488
+ version_prefix=version_prefix,
489
+ )
490
+ )
491
+ route_handlers.append('_crud_roles_list')
492
+
493
+ # /ho_access endpoint — filtered by the caller's authorized_roles
494
+ if ho_dev_map or access_map:
495
+ blocks.append(f'\n_HO_DEV_MAP = {pprint.pformat(ho_dev_map)}\n')
496
+ json_str = json.dumps(access_map, indent=4)
497
+ blocks.append(
498
+ templates.HO_ACCESS_ROUTE.format(
499
+ json_str=json_str,
500
+ version_prefix=version_prefix,
501
+ )
502
+ )
503
+ route_handlers.append('_crud_access_map')
504
+
505
+ return blocks, route_handlers
@@ -0,0 +1,26 @@
1
+ """
2
+ Frontend application scaffold generator for halfORM/Litestar projects.
3
+ """
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ class GenApp:
9
+ """
10
+ Generate a throwaway frontend application from CRUD_ACCESS introspection.
11
+
12
+ Parameters
13
+ ----------
14
+ repo:
15
+ A ``half_orm_dev.repo.Repo`` instance.
16
+ generator:
17
+ A framework-specific generator instance (e.g. SvelteAppGenerator).
18
+ output_dir:
19
+ Directory where the application will be written.
20
+ api_version:
21
+ Integer API version (used to build route prefixes).
22
+ """
23
+
24
+ def __init__(self, repo, *, generator, output_dir: Path, api_version=None):
25
+ classes = list(repo.model.classes())
26
+ generator.generate(classes, api_version, output_dir)