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.
- half_orm_gen/__init__.py +16 -0
- half_orm_gen/api_routes.py +224 -0
- half_orm_gen/cli_extension.py +170 -0
- half_orm_gen/crud_routes.py +505 -0
- half_orm_gen/gen_app/__init__.py +26 -0
- half_orm_gen/gen_app/angular.py +1727 -0
- half_orm_gen/gen_app/svelte.py +1336 -0
- half_orm_gen/gen_store/__init__.py +34 -0
- half_orm_gen/gen_store/base.py +88 -0
- half_orm_gen/gen_store/svelte.py +282 -0
- half_orm_gen/generate.py +120 -0
- half_orm_gen/scaffold.py +37 -0
- half_orm_gen/scaffolding/api_init.py +1 -0
- half_orm_gen/scaffolding/custom_authorization.py +36 -0
- half_orm_gen/scaffolding/custom_init.py +1 -0
- half_orm_gen/scaffolding/custom_middlewares_init.py +18 -0
- half_orm_gen/scaffolding/custom_routes.py +18 -0
- half_orm_gen/scaffolding/guards.py +40 -0
- half_orm_gen/scaffolding/roles_core.py +62 -0
- half_orm_gen/templates.py +454 -0
- half_orm_gen/templates_fastapi.py +264 -0
- half_orm_gen/tools.py +66 -0
- half_orm_gen/version.txt +1 -0
- half_orm_gen-1.0.0a1.dist-info/METADATA +73 -0
- half_orm_gen-1.0.0a1.dist-info/RECORD +29 -0
- half_orm_gen-1.0.0a1.dist-info/WHEEL +5 -0
- half_orm_gen-1.0.0a1.dist-info/licenses/AUTHORS +3 -0
- half_orm_gen-1.0.0a1.dist-info/licenses/LICENSE +674 -0
- half_orm_gen-1.0.0a1.dist-info/top_level.txt +1 -0
half_orm_gen/__init__.py
ADDED
|
@@ -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')
|