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,16 @@
1
+ """
2
+ half-orm-gen — API and frontend generation for halfORM projects.
3
+
4
+ Decorate halfORM relation methods with ``@tools.api_*`` and run
5
+ ``half_orm gen`` to produce a ready-to-run application.
6
+
7
+ Quick start::
8
+
9
+ from half_orm_gen import tools
10
+
11
+ class MyTable(MODEL.get_relation_class('schema.my_table')):
12
+
13
+ @tools.api_get('/items/{id: uuid}', guards=['connected'])
14
+ async def get_item(self, request: "Request"):
15
+ ...
16
+ """
@@ -0,0 +1,224 @@
1
+ """
2
+ Generation of Litestar route handlers from @api_* decorated halfORM methods.
3
+ """
4
+
5
+ import importlib
6
+ import inspect
7
+ import re
8
+ from typing import Iterable, Tuple, Type
9
+
10
+ from half_orm.relation import Relation
11
+
12
+ from half_orm_gen import templates as T
13
+
14
+ _RE_PATH_VAR = re.compile(r'\{([^:]*):([^\}]*)\}')
15
+
16
+ _LITESTAR_INTERNAL_PARAMS = {
17
+ 'operation_class', 'operation_id', 'tags', 'summary',
18
+ 'response_description', 'responses', 'deprecated',
19
+ 'cache', 'etag', 'return_dto', 'dto', 'type_encoders',
20
+ 'sync_to_thread', 'opt', 'signature_namespace',
21
+ 'include_in_schema', 'security', 'middleware',
22
+ }
23
+
24
+ _LITESTAR_DEFAULT_VALUES = {
25
+ 'cache': False,
26
+ 'deprecated': False,
27
+ 'sync_to_thread': True,
28
+ 'include_in_schema': True,
29
+ }
30
+
31
+
32
+ def _path_params(api_path: str) -> str:
33
+ parts = [f'{var}: Any' for var, _ in re.findall(_RE_PATH_VAR, api_path)]
34
+ return ', '.join(parts)
35
+
36
+
37
+ def _annotation_str(annotation) -> str:
38
+ """Return a valid Python expression string for a type annotation."""
39
+ if hasattr(annotation, '__name__'):
40
+ mod = getattr(annotation, '__module__', None)
41
+ if mod and mod != 'builtins':
42
+ return f'{mod}.{annotation.__name__}'
43
+ return annotation.__name__
44
+ if hasattr(annotation, '__qualname__'):
45
+ mod = getattr(annotation, '__module__', None)
46
+ if mod and mod != 'builtins':
47
+ return f'{mod}.{annotation.__qualname__}'
48
+ return annotation.__qualname__
49
+ return str(annotation)
50
+
51
+
52
+ def _query_params(signature: inspect.Signature):
53
+ params_decl = []
54
+ params_call = []
55
+ for name, param in signature.parameters.items():
56
+ if name == 'self':
57
+ continue
58
+ params_call.append(name)
59
+ decl = name
60
+ if param.annotation is not inspect.Parameter.empty:
61
+ decl = f'{decl}: {_annotation_str(param.annotation)}'
62
+ if param.default is not inspect.Parameter.empty:
63
+ decl = f'{decl}={param.default!r}'
64
+ params_decl.append(decl)
65
+ return ', '.join(params_decl), ', '.join(params_call)
66
+
67
+
68
+ def _extract_guards(litestar_params: dict) -> list:
69
+ guards = litestar_params.get('guards') or []
70
+ result = []
71
+ for g in guards:
72
+ if hasattr(g, '__name__'):
73
+ result.append(g.__name__)
74
+ elif isinstance(g, str):
75
+ result.append(g)
76
+ else:
77
+ result.append(str(g))
78
+ return result
79
+
80
+
81
+ def _format_litestar_args(
82
+ litestar_params: dict, guards_list: list, method_doc: str, api_version
83
+ ) -> str:
84
+ args = []
85
+
86
+ if 'path' in litestar_params:
87
+ version_prefix = f'/v{api_version}' if api_version is not None else ''
88
+ full_path = f"{version_prefix}{litestar_params['path']}"
89
+ args.append(f'"{full_path}"')
90
+
91
+ kwargs = []
92
+
93
+ if guards_list:
94
+ guards_str = ', '.join(f'guards.{g}' for g in guards_list)
95
+ kwargs.append(f'guards=[{guards_str}]')
96
+
97
+ if litestar_params.get('name'):
98
+ kwargs.append(f'name="{litestar_params["name"]}"')
99
+
100
+ desc_parts = [p for p in [method_doc, f"Guards: {', '.join(guards_list)}" if guards_list else ''] if p]
101
+ if desc_parts:
102
+ kwargs.append(f'description="""{chr(10).join(desc_parts)}"""')
103
+ elif litestar_params.get('description'):
104
+ kwargs.append(f'description="""{litestar_params["description"]}"""')
105
+
106
+ for key, value in litestar_params.items():
107
+ if key in ('path', 'guards', 'name', 'description'):
108
+ continue
109
+ if key in _LITESTAR_INTERNAL_PARAMS:
110
+ continue
111
+ if key in _LITESTAR_DEFAULT_VALUES and value == _LITESTAR_DEFAULT_VALUES[key]:
112
+ continue
113
+ if hasattr(value, '__class__'):
114
+ cls = value.__class__
115
+ if 'Empty' in str(cls) or 'enum' in str(cls).lower():
116
+ continue
117
+ if cls.__module__ not in ('builtins', '__builtin__'):
118
+ continue
119
+ if isinstance(value, str):
120
+ kwargs.append(f'{key}="{value}"')
121
+ elif isinstance(value, bool):
122
+ kwargs.append(f'{key}={value}')
123
+ elif isinstance(value, (int, float)):
124
+ kwargs.append(f'{key}={value}')
125
+ elif value is not None:
126
+ kwargs.append(f'{key}={value!r}')
127
+
128
+ return ', '.join(args + kwargs)
129
+
130
+
131
+ def generate_api_routes(
132
+ classes: Iterable[Tuple[Type[Relation], str]],
133
+ api_version,
134
+ ) -> Tuple[list, list]:
135
+ """Scan @api_* decorated methods and return (blocks, route_handler_names).
136
+
137
+ Also returns the set of (module_str, verb) pairs already covered, so the
138
+ CRUD generator can skip them.
139
+ """
140
+ blocks: list[str] = []
141
+ route_handlers: list[str] = []
142
+ covered: set[tuple] = set() # (module_str, verb) covered by @api_*
143
+
144
+ for relation, _relation_type in classes:
145
+ module_str = relation.__module__
146
+ schema = '.'.join(module_str.split('.')[:-1])
147
+ module_name = module_str.split('.')[-1]
148
+ module_alias = module_str.replace('.', '_')
149
+
150
+ schema_cap = ''.join(p.capitalize() for p in relation._schemaname.split('.'))
151
+
152
+ api_methods = [
153
+ (name, method)
154
+ for name, method in inspect.getmembers(relation, predicate=inspect.isfunction)
155
+ if getattr(method, 'is_api_route', False) and name in relation.__dict__
156
+ ]
157
+
158
+ if not api_methods:
159
+ mod = importlib.import_module(module_str)
160
+ if hasattr(mod, 'API'):
161
+ for func in mod.API:
162
+ alias = str(func).replace('.', '_')
163
+ fname = str(func).split('.')[-1]
164
+ blocks.append(T.DIRECT_API.format(
165
+ module_str=module_str,
166
+ function=fname,
167
+ function_alias=alias,
168
+ ))
169
+ route_handlers.append(alias)
170
+ continue
171
+
172
+ blocks.append(T.IMPORT.format(
173
+ schema=schema,
174
+ module_name=module_name,
175
+ module_alias=module_alias,
176
+ ))
177
+
178
+ for name, method in api_methods:
179
+ litestar_params = method.litestar_params
180
+ metadata = method.metadata
181
+ verb = method.http_method
182
+ guards_list = _extract_guards(litestar_params)
183
+ sig = metadata.get('signature', inspect.signature(method))
184
+ query_params, call_params = _query_params(sig)
185
+ litestar_args = _format_litestar_args(
186
+ litestar_params, guards_list, metadata.get('documentation', ''),
187
+ api_version,
188
+ )
189
+
190
+ full_name = f'{module_alias}_{name}'
191
+ template = T.HTTP.get(verb)
192
+ if template is None:
193
+ print(f' warning: no template for HTTP method {verb} ({module_str}.{name})')
194
+ continue
195
+
196
+ print(f' {verb:6s} {litestar_params.get("path", "")} → {full_name}')
197
+
198
+ blocks.append(template.format(
199
+ full_name = full_name,
200
+ litestar_args= litestar_args,
201
+ dc_name = relation._ho_dataclass_name(),
202
+ module_alias = module_alias,
203
+ class_name = relation.__name__,
204
+ path_params = _path_params(litestar_params.get('path', '')),
205
+ query_params = query_params,
206
+ params = call_params,
207
+ name = name,
208
+ ))
209
+ route_handlers.append(full_name)
210
+ covered.add((module_str, verb))
211
+
212
+ mod = importlib.import_module(module_str)
213
+ if hasattr(mod, 'API'):
214
+ for func in mod.API:
215
+ alias = str(func).replace('.', '_')
216
+ fname = str(func).split('.')[-1]
217
+ blocks.append(T.DIRECT_API.format(
218
+ module_str=module_str,
219
+ function=fname,
220
+ function_alias=alias,
221
+ ))
222
+ route_handlers.append(alias)
223
+
224
+ return blocks, route_handlers, covered
@@ -0,0 +1,170 @@
1
+ """
2
+ CLI extension for half-orm-gen.
3
+
4
+ Registers the ``gen`` sub-command group under the ``half_orm`` CLI::
5
+
6
+ half_orm gen api
7
+ half_orm gen frontend
8
+ """
9
+
10
+ import sys
11
+ from pathlib import Path
12
+ import click
13
+ from half_orm.cli_utils import create_and_register_extension
14
+
15
+ _VERSION_FILE = Path('api') / '.api_version'
16
+
17
+
18
+ def _read_api_version() -> int:
19
+ try:
20
+ return int(_VERSION_FILE.read_text().strip())
21
+ except (FileNotFoundError, ValueError):
22
+ return 0
23
+
24
+
25
+ def _write_api_version(version: int) -> None:
26
+ _VERSION_FILE.parent.mkdir(parents=True, exist_ok=True)
27
+ _VERSION_FILE.write_text(str(version) + '\n')
28
+
29
+
30
+ def add_commands(main_group):
31
+ """Required entry point for halfORM extensions."""
32
+
33
+ @create_and_register_extension(main_group, sys.modules[__name__])
34
+ def gen():
35
+ """Generate a Litestar API and frontend backoffice from a halfORM project."""
36
+ pass
37
+
38
+ @gen.command('api')
39
+ @click.option(
40
+ '--dry-run', is_flag=True, default=False,
41
+ help='Print what would be generated without writing any file.',
42
+ )
43
+ @click.option(
44
+ '--bump', is_flag=True, default=False,
45
+ help='Bump the API version to N+1 (asks for confirmation).',
46
+ )
47
+ @click.option('--litestar', 'framework', flag_value='litestar',
48
+ help='Generate a Litestar app.')
49
+ @click.option('--fastapi', 'framework', flag_value='fastapi',
50
+ help='Generate a FastAPI app (no @api_* support).')
51
+ def api(dry_run, bump, framework):
52
+ """Generate api/app.py from CRUD_ACCESS and @api_* decorated methods.
53
+
54
+ The API version is read from api/.api_version (default: 0).
55
+ Use --bump to move to N+1; the new value is saved for future runs.
56
+ To revert a mistaken bump: git checkout api/.api_version.
57
+
58
+ Must be run from inside a half-orm-dev project directory.
59
+ On first run, missing scaffolding files (guards.py, custom/) are
60
+ created automatically and are never overwritten on subsequent runs.
61
+ """
62
+ try:
63
+ from half_orm_dev.repo import Repo
64
+ except ImportError:
65
+ click.echo(
66
+ 'Error: half_orm_dev is not installed. '
67
+ 'Install it with: pip install half-orm-dev',
68
+ err=True,
69
+ )
70
+ sys.exit(1)
71
+
72
+ try:
73
+ repo = Repo()
74
+ except Exception as exc:
75
+ click.echo(
76
+ f'Error: could not load the halfORM project ({exc}).\n'
77
+ 'Make sure you are inside a half-orm-dev project directory.',
78
+ err=True,
79
+ )
80
+ sys.exit(1)
81
+
82
+ if not framework:
83
+ click.echo('Error: specify --litestar or --fastapi.', err=True)
84
+ sys.exit(1)
85
+
86
+ api_version = _read_api_version()
87
+
88
+ if bump:
89
+ next_version = api_version + 1
90
+ click.confirm(
91
+ f'Bump API version from v{api_version} to v{next_version}?',
92
+ abort=True,
93
+ )
94
+ _write_api_version(next_version)
95
+ api_version = next_version
96
+
97
+ if dry_run:
98
+ click.echo(
99
+ f'[dry-run] would generate api/app.py ({framework}) for project: {repo.name}'
100
+ f' (v{api_version})'
101
+ )
102
+ return
103
+
104
+ from half_orm_gen.generate import GenApi
105
+ click.echo(f'Generating {framework} API for project: {repo.name} (v{api_version})')
106
+ GenApi(repo, api_version=api_version, framework=framework)
107
+ if framework == 'litestar':
108
+ click.echo('\nTo run: litestar --app api.app:application run --reload')
109
+ else:
110
+ click.echo('\nTo run: uvicorn api.app:application --reload')
111
+
112
+ @gen.command('frontend')
113
+ @click.option('--svelte', 'framework', flag_value='svelte',
114
+ help='Generate a SvelteKit 5 application.')
115
+ @click.option('--angular', 'framework', flag_value='angular',
116
+ help='Generate an Angular 22 application (signal-based).')
117
+ @click.option('--output', default=None,
118
+ help='Output directory (default: frontend/<framework>).')
119
+ def frontend(framework, output):
120
+ """Generate a frontend backoffice from CRUD_ACCESS introspection.
121
+
122
+ Produces a complete SvelteKit or Angular application with Tailwind CSS,
123
+ per-resource List/CreateForm/DetailView components in generated/,
124
+ admin-only route pages, and a minimal JWT login.
125
+
126
+ Must be run from inside a half-orm-dev project directory.
127
+ """
128
+ try:
129
+ from half_orm_dev.repo import Repo
130
+ except ImportError:
131
+ click.echo(
132
+ 'Error: half_orm_dev is not installed. '
133
+ 'Install it with: pip install half-orm-dev',
134
+ err=True,
135
+ )
136
+ sys.exit(1)
137
+
138
+ try:
139
+ repo = Repo()
140
+ except Exception as exc:
141
+ click.echo(
142
+ f'Error: could not load the halfORM project ({exc}).\n'
143
+ 'Make sure you are inside a half-orm-dev project directory.',
144
+ err=True,
145
+ )
146
+ sys.exit(1)
147
+
148
+ api_version = _read_api_version()
149
+ output_dir = Path(output) if output else Path('frontend') / framework
150
+
151
+ if not framework:
152
+ click.echo('Error: specify --svelte or --angular.', err=True)
153
+ sys.exit(1)
154
+ if framework == 'svelte':
155
+ from half_orm_gen.gen_app.svelte import SvelteAppGenerator
156
+ generator = SvelteAppGenerator()
157
+ elif framework == 'angular':
158
+ from half_orm_gen.gen_app.angular import AngularAppGenerator
159
+ generator = AngularAppGenerator()
160
+ else:
161
+ click.echo(f'Error: unknown framework "{framework}".', err=True)
162
+ sys.exit(1)
163
+
164
+ from half_orm_gen.gen_app import GenApp
165
+ click.echo(f'Generating {framework} application → {output_dir}')
166
+ GenApp(repo, generator=generator, output_dir=output_dir, api_version=api_version)
167
+ if framework == 'svelte':
168
+ click.echo(f'\nTo run: cd {output_dir} && npm install && npm run dev')
169
+ elif framework == 'angular':
170
+ click.echo(f'\nTo run: cd {output_dir} && npm install && npm start')