lenapi 0.1.0__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.
- lenapi/__init__.py +79 -0
- lenapi/application.py +636 -0
- lenapi/authorization.py +96 -0
- lenapi/background.py +395 -0
- lenapi/cli.py +731 -0
- lenapi/database.py +84 -0
- lenapi/dependencies.py +12 -0
- lenapi/logging.py +13 -0
- lenapi/middleware.py +144 -0
- lenapi/model.py +54 -0
- lenapi/models.py +1052 -0
- lenapi/mongodb.py +262 -0
- lenapi/openapi.py +181 -0
- lenapi/parameters.py +8 -0
- lenapi/payments.py +543 -0
- lenapi/request.py +177 -0
- lenapi/response.py +91 -0
- lenapi/security.py +665 -0
- lenapi/security_store.py +193 -0
- lenapi/service.py +350 -0
- lenapi-0.1.0.dist-info/METADATA +959 -0
- lenapi-0.1.0.dist-info/RECORD +25 -0
- lenapi-0.1.0.dist-info/WHEEL +5 -0
- lenapi-0.1.0.dist-info/entry_points.txt +2 -0
- lenapi-0.1.0.dist-info/top_level.txt +1 -0
lenapi/cli.py
ADDED
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
"""Command line interface for lenApi projects."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import csv
|
|
5
|
+
from importlib import import_module
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import secrets
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import xml.etree.ElementTree as ET
|
|
14
|
+
from .logging import logger
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
18
|
+
DEFAULT_PORT = 8009
|
|
19
|
+
DEFAULT_APP = "main:app"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
PROJECT_FILES = {
|
|
23
|
+
"__init__.py": "",
|
|
24
|
+
".gitignore": "__pycache__/\n.pytest_cache/\n*.py[cod]\n.env\nlenapi.db\nmedia/\n.venv/\nbuild/\ndist/\n*.egg-info/\n",
|
|
25
|
+
"settings.py": '''"""Project settings for lenApp."""
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
BASE_DIR = Path(__file__).resolve().parent
|
|
31
|
+
API_TITLE = "lenApp"
|
|
32
|
+
API_VERSION = "0.1.0"
|
|
33
|
+
DOCS_URL = "/docs/"
|
|
34
|
+
OPENAPI_URL = "/openapi.json"
|
|
35
|
+
# Limit incoming request bodies to 10 MiB; set to None to disable.
|
|
36
|
+
MAX_REQUEST_BODY_SIZE = 10 * 1024 * 1024
|
|
37
|
+
# Maximum time allowed to receive an incoming body; set to None to disable.
|
|
38
|
+
REQUEST_BODY_TIMEOUT_SECONDS = 30
|
|
39
|
+
# Shared storage for IP flags, sessions, opaque tokens, and HMAC replay checks.
|
|
40
|
+
# Choose "database" (requires migration), "redis", or "memory" (development only).
|
|
41
|
+
SECURITY_STORAGE = "database"
|
|
42
|
+
REDIS_URL = "redis://127.0.0.1:6379/0"
|
|
43
|
+
CACHE_BACKEND = "memory" # "memory", "database", or "redis".
|
|
44
|
+
# runserver uses these values; CLI flags override them.
|
|
45
|
+
HOST = os.getenv("LENAPI_HOST", "127.0.0.1")
|
|
46
|
+
PORT = int(os.getenv("LENAPI_PORT", "8009"))
|
|
47
|
+
DATABASE_URL = os.getenv("LENAPI_DATABASE_URL", f"sqlite:///{BASE_DIR / 'lenapi.db'}")
|
|
48
|
+
ASYNC_DATABASE_URL = os.getenv("LENAPI_ASYNC_DATABASE_URL")
|
|
49
|
+
MONGODB_URI = os.getenv("MONGODB_URI")
|
|
50
|
+
MONGODB_DATABASE = os.getenv("MONGODB_DATABASE")
|
|
51
|
+
MEDIA_ROOT = BASE_DIR / "media"
|
|
52
|
+
MEDIA_URL = "/media/"
|
|
53
|
+
# Choose one primary method: "jwt", "api_key", "basic_login", "opaque_bearer", or "session".
|
|
54
|
+
# Session can also be added to selected routes; add HMAC/OIDC with Depends(...).
|
|
55
|
+
AUTHENTICATION = "jwt"
|
|
56
|
+
BASIC_AUTH_FIELD = "username" # Basic login field on User: username, email, phone, etc.
|
|
57
|
+
API_KEY = "__LENAPI_GENERATED_API_KEY__"
|
|
58
|
+
# Keep this generated key private. Rotate it to invalidate existing tokens.
|
|
59
|
+
SECRET_KEY = "__LENAPI_GENERATED_SECRET_KEY__"
|
|
60
|
+
TOKEN = {
|
|
61
|
+
"SECRET_KEY": SECRET_KEY,
|
|
62
|
+
"LIFETIME_SECONDS": 3600,
|
|
63
|
+
"REFRESH_LIFETIME_SECONDS": 2592000,
|
|
64
|
+
"ISSUER": API_TITLE,
|
|
65
|
+
}
|
|
66
|
+
''',
|
|
67
|
+
"main.py": '''"""Application entry point."""
|
|
68
|
+
|
|
69
|
+
from lenapi import Depends, LenApi, check_password, create_authenticator, hash_api_key, make_api_key, make_password
|
|
70
|
+
from app import models # Import models so migrations can discover them.
|
|
71
|
+
import settings
|
|
72
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
73
|
+
|
|
74
|
+
app = LenApi()
|
|
75
|
+
|
|
76
|
+
if settings.AUTHENTICATION == "basic_login":
|
|
77
|
+
identifier_field = getattr(settings, "BASIC_AUTH_FIELD", "username")
|
|
78
|
+
if identifier_field not in models.User.__table__.columns:
|
|
79
|
+
raise ValueError(f"BASIC_AUTH_FIELD '{identifier_field}' is not a field on User")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def authenticate_user(username, password):
|
|
83
|
+
user = models.User.get(username=username).get()
|
|
84
|
+
if user is not None and check_password(password, user.password):
|
|
85
|
+
return str(user.id)
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def authenticate_api_key(api_key):
|
|
90
|
+
if not api_key:
|
|
91
|
+
return None
|
|
92
|
+
user = models.User.get(api_key=hash_api_key(api_key)).get()
|
|
93
|
+
return {"user_id": str(user.id), "auth_method": "api_key"} if user is not None else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
authenticator = create_authenticator(
|
|
97
|
+
settings, authenticate=authenticate_user, authenticate_api_key=authenticate_api_key,
|
|
98
|
+
security_store=app.security_store,
|
|
99
|
+
)
|
|
100
|
+
if settings.AUTHENTICATION == "jwt":
|
|
101
|
+
app.include_router(authenticator.router(authenticate_user))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class UserRegistration(BaseModel):
|
|
105
|
+
username: str = Field(min_length=1, max_length=120)
|
|
106
|
+
email: str = Field(pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$", max_length=254)
|
|
107
|
+
first_name: str = Field(default="", max_length=150)
|
|
108
|
+
last_name: str = Field(default="", max_length=150)
|
|
109
|
+
password: str = Field(min_length=8, max_length=128)
|
|
110
|
+
confirm_password: str = Field(min_length=8, max_length=128)
|
|
111
|
+
|
|
112
|
+
@model_validator(mode="after")
|
|
113
|
+
def passwords_must_match(self):
|
|
114
|
+
if self.password != self.confirm_password:
|
|
115
|
+
raise ValueError("password and confirm_password must match")
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class UserPublic(BaseModel):
|
|
120
|
+
model_config = ConfigDict(from_attributes=True)
|
|
121
|
+
|
|
122
|
+
id: int
|
|
123
|
+
username: str
|
|
124
|
+
email: str | None
|
|
125
|
+
first_name: str
|
|
126
|
+
last_name: str
|
|
127
|
+
api_key: str | None = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@app.post("/auth/register", response_model=UserPublic)
|
|
131
|
+
def register_user(data: UserRegistration):
|
|
132
|
+
api_key = make_api_key() if settings.AUTHENTICATION == "api_key" else None
|
|
133
|
+
user = models.User.create(
|
|
134
|
+
username=data.username,
|
|
135
|
+
email=data.email,
|
|
136
|
+
first_name=data.first_name,
|
|
137
|
+
last_name=data.last_name,
|
|
138
|
+
password=make_password(data.password),
|
|
139
|
+
api_key=hash_api_key(api_key) if api_key is not None else None,
|
|
140
|
+
)
|
|
141
|
+
return {"id": user.id, "username": user.username, "email": user.email,
|
|
142
|
+
"first_name": user.first_name, "last_name": user.last_name, "api_key": api_key}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.get("/auth/profile")
|
|
146
|
+
def auth_profile(identity=Depends(authenticator)):
|
|
147
|
+
if isinstance(identity, dict) and "sub" in identity:
|
|
148
|
+
return {"user_id": identity["sub"]}
|
|
149
|
+
return {"identity": identity}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@app.get("/")
|
|
153
|
+
def home():
|
|
154
|
+
return {"message": "lenApp is running"}
|
|
155
|
+
''',
|
|
156
|
+
"app/__init__.py": '''"""Application package."""
|
|
157
|
+
''',
|
|
158
|
+
"app/models.py": '''"""Define database models here."""
|
|
159
|
+
|
|
160
|
+
from lenapi import model
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class User(model.Model):
|
|
164
|
+
username = model.StringField(max_length=120, unique=True)
|
|
165
|
+
email = model.StringField(max_length=254, unique=True, nullable=True)
|
|
166
|
+
first_name = model.StringField(max_length=150, default="")
|
|
167
|
+
last_name = model.StringField(max_length=150, default="")
|
|
168
|
+
password = model.StringField(max_length=255)
|
|
169
|
+
api_key = model.StringField(max_length=64, unique=True, nullable=True)
|
|
170
|
+
''',
|
|
171
|
+
"lenora.py": '''"""Project-local command launcher."""
|
|
172
|
+
|
|
173
|
+
import sys
|
|
174
|
+
from pathlib import Path
|
|
175
|
+
|
|
176
|
+
project_dir = Path(__file__).resolve().parent
|
|
177
|
+
sys.path[:] = [entry for entry in sys.path if Path(entry or ".").resolve() != project_dir]
|
|
178
|
+
sys.path.insert(0, str(project_dir.parent))
|
|
179
|
+
from lenapi.cli import main
|
|
180
|
+
sys.path.insert(0, str(project_dir))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
main()
|
|
185
|
+
''',
|
|
186
|
+
"alembic.ini": '''[alembic]
|
|
187
|
+
script_location = %(here)s/migrations
|
|
188
|
+
prepend_sys_path = .
|
|
189
|
+
sqlalchemy.url = sqlite:///lenapi.db
|
|
190
|
+
|
|
191
|
+
[loggers]
|
|
192
|
+
keys = root,sqlalchemy,alembic
|
|
193
|
+
|
|
194
|
+
[handlers]
|
|
195
|
+
keys = console
|
|
196
|
+
|
|
197
|
+
[formatters]
|
|
198
|
+
keys = generic
|
|
199
|
+
|
|
200
|
+
[logger_root]
|
|
201
|
+
level = WARN
|
|
202
|
+
handlers = console
|
|
203
|
+
qualname =
|
|
204
|
+
|
|
205
|
+
[logger_sqlalchemy]
|
|
206
|
+
level = WARN
|
|
207
|
+
handlers =
|
|
208
|
+
qualname = sqlalchemy.engine
|
|
209
|
+
|
|
210
|
+
[logger_alembic]
|
|
211
|
+
level = INFO
|
|
212
|
+
handlers = console
|
|
213
|
+
qualname = alembic
|
|
214
|
+
|
|
215
|
+
[handler_console]
|
|
216
|
+
class = StreamHandler
|
|
217
|
+
args = (sys.stderr,)
|
|
218
|
+
level = NOTSET
|
|
219
|
+
formatter = generic
|
|
220
|
+
|
|
221
|
+
[formatter_generic]
|
|
222
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
223
|
+
datefmt = %H:%M:%S
|
|
224
|
+
''',
|
|
225
|
+
"migrations/env.py": '''"""Alembic environment for lenApp models."""
|
|
226
|
+
|
|
227
|
+
from importlib import import_module
|
|
228
|
+
|
|
229
|
+
from alembic import context
|
|
230
|
+
from sqlalchemy import create_engine, inspect
|
|
231
|
+
|
|
232
|
+
from lenapi import Model
|
|
233
|
+
|
|
234
|
+
config = context.config
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def load_app():
|
|
238
|
+
app_path = config.attributes.get("app_import_string", "main:app")
|
|
239
|
+
module_name, _, attribute_path = app_path.partition(":")
|
|
240
|
+
app = import_module(module_name)
|
|
241
|
+
for name in attribute_path.split(".") if attribute_path else []:
|
|
242
|
+
app = getattr(app, name)
|
|
243
|
+
return app
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def run_migrations_offline():
|
|
247
|
+
app = load_app()
|
|
248
|
+
context.configure(
|
|
249
|
+
url=app.database.engine.url.render_as_string(hide_password=False),
|
|
250
|
+
target_metadata=Model.metadata,
|
|
251
|
+
literal_binds=True,
|
|
252
|
+
dialect_opts={"paramstyle": "named"},
|
|
253
|
+
compare_type=True,
|
|
254
|
+
)
|
|
255
|
+
with context.begin_transaction():
|
|
256
|
+
context.run_migrations()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def run_migrations_online():
|
|
260
|
+
app = load_app()
|
|
261
|
+
engine = app.database.engine
|
|
262
|
+
|
|
263
|
+
def configure_and_run(connection):
|
|
264
|
+
context.configure(
|
|
265
|
+
connection=connection,
|
|
266
|
+
target_metadata=Model.metadata,
|
|
267
|
+
compare_type=True,
|
|
268
|
+
render_as_batch=connection.dialect.name == "sqlite",
|
|
269
|
+
)
|
|
270
|
+
with context.begin_transaction():
|
|
271
|
+
context.run_migrations()
|
|
272
|
+
|
|
273
|
+
supplied_connection = config.attributes.get("connection")
|
|
274
|
+
if supplied_connection is not None:
|
|
275
|
+
configure_and_run(supplied_connection)
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
with engine.connect() as connection:
|
|
279
|
+
existing_tables = set(inspect(connection).get_table_names()) - {"alembic_version"}
|
|
280
|
+
if connection.in_transaction():
|
|
281
|
+
connection.commit()
|
|
282
|
+
if (config.attributes.get("lenapi_initial_revision", False) and existing_tables
|
|
283
|
+
and connection.dialect.name == "sqlite"):
|
|
284
|
+
with create_engine("sqlite://").connect() as empty_connection:
|
|
285
|
+
configure_and_run(empty_connection)
|
|
286
|
+
else:
|
|
287
|
+
configure_and_run(connection)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
if context.is_offline_mode():
|
|
291
|
+
run_migrations_offline()
|
|
292
|
+
else:
|
|
293
|
+
run_migrations_online()
|
|
294
|
+
''',
|
|
295
|
+
"migrations/script.py.mako": '''"""${message}
|
|
296
|
+
|
|
297
|
+
Revision ID: ${up_revision}
|
|
298
|
+
Revises: ${down_revision | comma,n}
|
|
299
|
+
Create Date: ${create_date}
|
|
300
|
+
"""
|
|
301
|
+
from typing import Sequence, Union
|
|
302
|
+
|
|
303
|
+
from alembic import op
|
|
304
|
+
import sqlalchemy as sa
|
|
305
|
+
${imports if imports else ""}
|
|
306
|
+
|
|
307
|
+
revision: str = ${repr(up_revision)}
|
|
308
|
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
|
309
|
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
310
|
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def upgrade() -> None:
|
|
314
|
+
${upgrades if upgrades else "pass"}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def downgrade() -> None:
|
|
318
|
+
${downgrades if downgrades else "pass"}
|
|
319
|
+
''',
|
|
320
|
+
"migrations/versions/__init__.py": "",
|
|
321
|
+
"migrations/versions/.gitkeep": "",
|
|
322
|
+
"README.md": '''# lenApp
|
|
323
|
+
|
|
324
|
+
Project settings are in `settings.py`; models belong in `app/models.py` and routes in `main.py`.
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
python lenora.py migration
|
|
328
|
+
python lenora.py runserver
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
The development server uses port 8009. Open `/docs/` for Swagger UI. Run `python lenora.py run_test` to run `test.py` and any generated endpoint tests.
|
|
332
|
+
''',
|
|
333
|
+
"test.py": '''"""Starter smoke tests for the application."""
|
|
334
|
+
|
|
335
|
+
from main import app
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_root_route_is_registered():
|
|
339
|
+
assert any(route.path == "/" and "GET" in route.methods for route in app.routes)
|
|
340
|
+
''',
|
|
341
|
+
"pytest.ini": '''[pytest]
|
|
342
|
+
python_files = test.py test_*.py *_test.py
|
|
343
|
+
''',
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _main() -> None:
|
|
348
|
+
parser = argparse.ArgumentParser(
|
|
349
|
+
prog=Path(sys.argv[0]).name,
|
|
350
|
+
description="lenApi project tools",
|
|
351
|
+
epilog=(
|
|
352
|
+
"Examples:\n"
|
|
353
|
+
" python lenora.py startproject myproject\n"
|
|
354
|
+
" python lenora.py runserver --port 8010\n"
|
|
355
|
+
" python lenora.py migration --message add_users\n\n"
|
|
356
|
+
"For flags and arguments for a command, run: "
|
|
357
|
+
"python lenora.py <command> --help"
|
|
358
|
+
),
|
|
359
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
360
|
+
)
|
|
361
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
362
|
+
runserver = commands.add_parser("runserver", help="run the ASGI development server")
|
|
363
|
+
runserver.add_argument("--app", default=DEFAULT_APP, help=f"ASGI app import string (default: {DEFAULT_APP})")
|
|
364
|
+
runserver.add_argument("--host", help="host to bind (default: project settings or 127.0.0.1)")
|
|
365
|
+
runserver.add_argument("--port", type=int, help="port to bind (default: project settings or 8009)")
|
|
366
|
+
reload_options = runserver.add_mutually_exclusive_group()
|
|
367
|
+
reload_options.add_argument("--reload", dest="reload", action="store_true", help="reload when project files change (default)")
|
|
368
|
+
reload_options.add_argument("--no-reload", dest="reload", action="store_false", help="disable automatic reload")
|
|
369
|
+
runserver.set_defaults(reload=True)
|
|
370
|
+
|
|
371
|
+
startproject = commands.add_parser("startproject", help="create a new lenApi project")
|
|
372
|
+
startproject.add_argument("name", nargs="?", default="lenApp", help="project folder name (default: lenApp)")
|
|
373
|
+
|
|
374
|
+
migration = commands.add_parser("migration", help="generate and apply a migration from model changes")
|
|
375
|
+
migration.add_argument("--message", "-m", default="schema update", help="migration description")
|
|
376
|
+
migration.add_argument("--app", default=DEFAULT_APP, help=f"ASGI app import string (default: {DEFAULT_APP})")
|
|
377
|
+
migrate = commands.add_parser("migrate", help="apply pending migrations to the database")
|
|
378
|
+
migrate.add_argument("--app", default=DEFAULT_APP, help=f"ASGI app import string (default: {DEFAULT_APP})")
|
|
379
|
+
|
|
380
|
+
generate_test = commands.add_parser("generate_test", help="generate pytest endpoint coverage from the app routes")
|
|
381
|
+
generate_test.add_argument("--app", default=DEFAULT_APP, help=f"ASGI app import string (default: {DEFAULT_APP})")
|
|
382
|
+
run_test = commands.add_parser("run_test", help="run pytest and list the API endpoints covered by generated tests")
|
|
383
|
+
run_test.add_argument("--app", default=DEFAULT_APP, help=f"ASGI app import string (default: {DEFAULT_APP})")
|
|
384
|
+
run_test.add_argument("paths", nargs="*", help="optional pytest files or directories")
|
|
385
|
+
run_test_output = commands.add_parser(
|
|
386
|
+
"run_test_output", help="run pytest in the terminal and write a CSV test report"
|
|
387
|
+
)
|
|
388
|
+
run_test_output.add_argument("--app", default=DEFAULT_APP, help=f"ASGI app import string (default: {DEFAULT_APP})")
|
|
389
|
+
run_test_output.add_argument(
|
|
390
|
+
"--output", default="outputs/lenapi_pytest_results.csv",
|
|
391
|
+
help="CSV report path (default: outputs/lenapi_pytest_results.csv)",
|
|
392
|
+
)
|
|
393
|
+
run_test_output.add_argument("paths", nargs="*", help="optional pytest files or directories")
|
|
394
|
+
|
|
395
|
+
args = parser.parse_args()
|
|
396
|
+
|
|
397
|
+
if args.command == "runserver":
|
|
398
|
+
project_settings = None
|
|
399
|
+
if (Path.cwd() / "settings.py").is_file():
|
|
400
|
+
project_settings = import_module("settings")
|
|
401
|
+
args.host = args.host or os.getenv("LENAPI_HOST") or getattr(project_settings, "HOST", DEFAULT_HOST)
|
|
402
|
+
args.port = args.port or int(os.getenv("LENAPI_PORT", getattr(project_settings, "PORT", DEFAULT_PORT)))
|
|
403
|
+
|
|
404
|
+
if args.command == "startproject":
|
|
405
|
+
if not args.name.isidentifier():
|
|
406
|
+
parser.error("project name must be a valid Python package name")
|
|
407
|
+
project_dir = Path.cwd() / args.name
|
|
408
|
+
if project_dir.exists():
|
|
409
|
+
parser.error(f"{project_dir} already exists")
|
|
410
|
+
try:
|
|
411
|
+
for relative_path, contents in PROJECT_FILES.items():
|
|
412
|
+
destination = project_dir / relative_path
|
|
413
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
414
|
+
if relative_path == "settings.py":
|
|
415
|
+
contents = contents.replace(
|
|
416
|
+
"__LENAPI_GENERATED_SECRET_KEY__", secrets.token_urlsafe(48)
|
|
417
|
+
)
|
|
418
|
+
contents = contents.replace(
|
|
419
|
+
"__LENAPI_GENERATED_API_KEY__", secrets.token_urlsafe(32)
|
|
420
|
+
)
|
|
421
|
+
destination.write_text(contents, encoding="utf-8")
|
|
422
|
+
except OSError as exc:
|
|
423
|
+
import shutil
|
|
424
|
+
shutil.rmtree(project_dir, ignore_errors=True)
|
|
425
|
+
parser.error(f"could not create project: {exc}")
|
|
426
|
+
print(f"Created lenApi project in {project_dir}")
|
|
427
|
+
print(f"Next: cd {args.name} && python lenora.py migration && python lenora.py runserver")
|
|
428
|
+
return
|
|
429
|
+
|
|
430
|
+
if args.command in {"generate_test", "run_test", "run_test_output"}:
|
|
431
|
+
module_name, separator, attribute_path = args.app.partition(":")
|
|
432
|
+
if not separator or not module_name:
|
|
433
|
+
parser.error("--app must use the form package.module:app")
|
|
434
|
+
app = import_module(module_name)
|
|
435
|
+
for attribute in attribute_path.split("."):
|
|
436
|
+
app = getattr(app, attribute)
|
|
437
|
+
if not hasattr(app, "routes") or not hasattr(app, "websocket_routes"):
|
|
438
|
+
parser.error(f"{args.app} is not a lenApi application")
|
|
439
|
+
|
|
440
|
+
http_endpoints = sorted(
|
|
441
|
+
(method.lower(), route.path)
|
|
442
|
+
for route in app.routes
|
|
443
|
+
for method in route.methods
|
|
444
|
+
)
|
|
445
|
+
websocket_endpoints = sorted(route.path for route in app.websocket_routes)
|
|
446
|
+
if args.command == "generate_test":
|
|
447
|
+
test_dir = Path.cwd() / "tests"
|
|
448
|
+
test_dir.mkdir(parents=True, exist_ok=True)
|
|
449
|
+
destination = test_dir / "test_generated_endpoints.py"
|
|
450
|
+
websocket_test_source = (
|
|
451
|
+
'''@pytest.mark.parametrize("path", WEBSOCKET_ENDPOINTS)
|
|
452
|
+
def test_websocket_endpoint_is_registered(path):
|
|
453
|
+
app = load_app()
|
|
454
|
+
assert any(route.path == path for route in app.websocket_routes)
|
|
455
|
+
'''
|
|
456
|
+
if websocket_endpoints else ""
|
|
457
|
+
)
|
|
458
|
+
generated_source = f'''"""Generated endpoint registration and OpenAPI coverage. Regenerate with python lenora.py generate_test."""
|
|
459
|
+
|
|
460
|
+
from importlib import import_module
|
|
461
|
+
|
|
462
|
+
import pytest
|
|
463
|
+
|
|
464
|
+
from lenapi.openapi import build_openapi
|
|
465
|
+
|
|
466
|
+
APP_IMPORT_STRING = {args.app!r}
|
|
467
|
+
HTTP_ENDPOINTS = {http_endpoints!r}
|
|
468
|
+
WEBSOCKET_ENDPOINTS = {websocket_endpoints!r}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def load_app():
|
|
472
|
+
module_name, _, attribute_path = APP_IMPORT_STRING.partition(":")
|
|
473
|
+
app = import_module(module_name)
|
|
474
|
+
for attribute in attribute_path.split(".") if attribute_path else []:
|
|
475
|
+
app = getattr(app, attribute)
|
|
476
|
+
return app
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
@pytest.mark.parametrize(("method", "path"), HTTP_ENDPOINTS)
|
|
480
|
+
def test_http_endpoint_is_registered_and_documented(method, path):
|
|
481
|
+
app = load_app()
|
|
482
|
+
assert any(route.path == path and method.upper() in route.methods for route in app.routes)
|
|
483
|
+
openapi = build_openapi(app, title=app.title, version=app.version)
|
|
484
|
+
assert method in openapi["paths"][path]
|
|
485
|
+
|
|
486
|
+
{websocket_test_source}
|
|
487
|
+
'''
|
|
488
|
+
destination.write_text(generated_source, encoding="utf-8")
|
|
489
|
+
print(f"Generated {destination}")
|
|
490
|
+
print(f"HTTP endpoints covered: {len(http_endpoints)}")
|
|
491
|
+
for method, path in http_endpoints:
|
|
492
|
+
print(f" {method.upper():7} {path}")
|
|
493
|
+
for path in websocket_endpoints:
|
|
494
|
+
print(f" WEBSOCKET {path}")
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
print("Endpoints covered by generated route tests:")
|
|
498
|
+
for method, path in http_endpoints:
|
|
499
|
+
print(f" {method.upper():7} {path}")
|
|
500
|
+
for path in websocket_endpoints:
|
|
501
|
+
print(f" WEBSOCKET {path}")
|
|
502
|
+
sys.stdout.flush()
|
|
503
|
+
|
|
504
|
+
if args.paths:
|
|
505
|
+
pytest_args = [*args.paths]
|
|
506
|
+
else:
|
|
507
|
+
pytest_args = []
|
|
508
|
+
if (Path.cwd() / "test.py").is_file():
|
|
509
|
+
pytest_args.append("test.py")
|
|
510
|
+
if (Path.cwd() / "tests").is_dir():
|
|
511
|
+
pytest_args.append("tests")
|
|
512
|
+
if not pytest_args:
|
|
513
|
+
pytest_args = ["tests"]
|
|
514
|
+
command = [sys.executable, "-m", "pytest", "-q"]
|
|
515
|
+
if args.command == "run_test_output":
|
|
516
|
+
with tempfile.TemporaryDirectory(prefix="lenapi-pytest-") as temp_dir:
|
|
517
|
+
junit_path = Path(temp_dir) / "results.xml"
|
|
518
|
+
result = subprocess.run(
|
|
519
|
+
[*command, "--junitxml", str(junit_path), *pytest_args], check=False
|
|
520
|
+
)
|
|
521
|
+
suites = ET.parse(junit_path).getroot()
|
|
522
|
+
cases = suites.findall(".//testcase")
|
|
523
|
+
case_results = []
|
|
524
|
+
for case in cases:
|
|
525
|
+
issue = next(
|
|
526
|
+
(case.find(tag) for tag in ("failure", "error", "skipped") if case.find(tag) is not None),
|
|
527
|
+
None,
|
|
528
|
+
)
|
|
529
|
+
outcome = "SKIP" if issue is not None and issue.tag == "skipped" else (
|
|
530
|
+
"FAIL" if issue is not None else "PASS"
|
|
531
|
+
)
|
|
532
|
+
details = "" if issue is None else (issue.get("message") or issue.text or "").strip()
|
|
533
|
+
case_results.append((case.get("classname", ""), case.get("name", ""), outcome, details))
|
|
534
|
+
passed = sum(outcome == "PASS" for _class_name, _name, outcome, _details in case_results)
|
|
535
|
+
failed = sum(outcome == "FAIL" for _class_name, _name, outcome, _details in case_results)
|
|
536
|
+
skipped = sum(outcome == "SKIP" for _class_name, _name, outcome, _details in case_results)
|
|
537
|
+
report_path = Path(args.output).expanduser()
|
|
538
|
+
if not report_path.is_absolute():
|
|
539
|
+
report_path = Path.cwd() / report_path
|
|
540
|
+
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
541
|
+
with report_path.open("w", newline="", encoding="utf-8") as report_file:
|
|
542
|
+
writer = csv.writer(report_file)
|
|
543
|
+
writer.writerow(["check_type", "method", "endpoint", "result", "details"])
|
|
544
|
+
writer.writerow(["pytest summary", "", "", "PASS" if result.returncode == 0 else "FAIL",
|
|
545
|
+
f"{passed} passed, {failed} failed, {skipped} skipped"])
|
|
546
|
+
endpoint_result = "PASS" if result.returncode == 0 else "REVIEW"
|
|
547
|
+
for method, path in http_endpoints:
|
|
548
|
+
writer.writerow([
|
|
549
|
+
"generated route/OpenAPI check", method.upper(), path, endpoint_result,
|
|
550
|
+
"See pytest case rows for the suite outcome",
|
|
551
|
+
])
|
|
552
|
+
for path in websocket_endpoints:
|
|
553
|
+
writer.writerow([
|
|
554
|
+
"generated WebSocket route check", "WEBSOCKET", path, endpoint_result,
|
|
555
|
+
"See pytest case rows for the suite outcome",
|
|
556
|
+
])
|
|
557
|
+
for class_name, name, outcome, details in case_results:
|
|
558
|
+
writer.writerow(["pytest case", "", f"{class_name}::{name}", outcome, details])
|
|
559
|
+
print(f"CSV report written to {report_path}")
|
|
560
|
+
else:
|
|
561
|
+
result = subprocess.run([*command, *pytest_args], check=False)
|
|
562
|
+
raise SystemExit(result.returncode)
|
|
563
|
+
|
|
564
|
+
if args.command == "runserver":
|
|
565
|
+
try:
|
|
566
|
+
import uvicorn
|
|
567
|
+
except ImportError:
|
|
568
|
+
parser.error("runserver requires Uvicorn; install lenApi with `pip install '.[server]'`")
|
|
569
|
+
print(f"lenApi server: http://{args.host}:{args.port}", flush=True)
|
|
570
|
+
uvicorn.run(args.app, host=args.host, port=args.port, reload=args.reload)
|
|
571
|
+
return
|
|
572
|
+
|
|
573
|
+
try:
|
|
574
|
+
from alembic import command
|
|
575
|
+
from alembic.config import Config
|
|
576
|
+
except ImportError:
|
|
577
|
+
parser.error("migrations require Alembic; install lenApi dependencies with `pip install -e .`")
|
|
578
|
+
|
|
579
|
+
ini_path = Path.cwd() / "alembic.ini"
|
|
580
|
+
if not ini_path.is_file():
|
|
581
|
+
parser.error(f"{ini_path} not found; run this command from your lenApi project directory")
|
|
582
|
+
config = Config(str(ini_path))
|
|
583
|
+
config.attributes["app_import_string"] = args.app
|
|
584
|
+
module_name, _, attribute_path = args.app.partition(":")
|
|
585
|
+
app = import_module(module_name)
|
|
586
|
+
for attribute in attribute_path.split(".") if attribute_path else []:
|
|
587
|
+
app = getattr(app, attribute)
|
|
588
|
+
from alembic.autogenerate import compare_metadata
|
|
589
|
+
from alembic.migration import MigrationContext
|
|
590
|
+
from alembic.script import ScriptDirectory
|
|
591
|
+
from sqlalchemy import MetaData, create_engine, inspect
|
|
592
|
+
from lenapi import Model
|
|
593
|
+
|
|
594
|
+
engine = app.database.engine
|
|
595
|
+
revisions = Path.cwd() / "migrations" / "versions"
|
|
596
|
+
if args.command == "migration":
|
|
597
|
+
existing_revisions = [
|
|
598
|
+
path for path in revisions.glob("*.py") if path.name != "__init__.py"
|
|
599
|
+
]
|
|
600
|
+
config.attributes["lenapi_initial_revision"] = not existing_revisions
|
|
601
|
+
|
|
602
|
+
# On a fresh non-SQLite database, apply the checked-in migration chain
|
|
603
|
+
# before asking Alembic to autogenerate the next revision. Otherwise
|
|
604
|
+
# Alembic rejects revision generation because the database is behind
|
|
605
|
+
# the current repository head.
|
|
606
|
+
if existing_revisions and engine.dialect.name != "sqlite":
|
|
607
|
+
with engine.connect() as connection:
|
|
608
|
+
table_names = set(inspect(connection).get_table_names())
|
|
609
|
+
if not table_names:
|
|
610
|
+
script = ScriptDirectory.from_config(config)
|
|
611
|
+
command.upgrade(config, script.get_current_head())
|
|
612
|
+
|
|
613
|
+
# Adopt databases created from this project's prior migration history.
|
|
614
|
+
# Verify against the already-recorded migration schema before stamping;
|
|
615
|
+
# never mark an unrelated or mismatched schema as migrated.
|
|
616
|
+
if existing_revisions and engine.dialect.name == "sqlite":
|
|
617
|
+
with engine.connect() as connection:
|
|
618
|
+
table_names = set(inspect(connection).get_table_names())
|
|
619
|
+
has_version = "alembic_version" in table_names
|
|
620
|
+
if has_version:
|
|
621
|
+
has_version = connection.exec_driver_sql(
|
|
622
|
+
"SELECT version_num FROM alembic_version LIMIT 1"
|
|
623
|
+
).first() is not None
|
|
624
|
+
user_tables = table_names - {"alembic_version"}
|
|
625
|
+
if not has_version:
|
|
626
|
+
script = ScriptDirectory.from_config(config)
|
|
627
|
+
existing_head = script.get_current_head()
|
|
628
|
+
if not user_tables:
|
|
629
|
+
command.upgrade(config, existing_head)
|
|
630
|
+
else:
|
|
631
|
+
temporary_engine = create_engine("sqlite://")
|
|
632
|
+
try:
|
|
633
|
+
with temporary_engine.connect() as temporary_connection:
|
|
634
|
+
config.attributes["connection"] = temporary_connection
|
|
635
|
+
command.upgrade(config, existing_head)
|
|
636
|
+
config.attributes.pop("connection", None)
|
|
637
|
+
expected_schema = MetaData()
|
|
638
|
+
expected_schema.reflect(bind=temporary_connection)
|
|
639
|
+
version_table = expected_schema.tables.get("alembic_version")
|
|
640
|
+
if version_table is not None:
|
|
641
|
+
expected_schema.remove(version_table)
|
|
642
|
+
with engine.connect() as connection:
|
|
643
|
+
differences = compare_metadata(
|
|
644
|
+
MigrationContext.configure(
|
|
645
|
+
connection, opts={"compare_type": True}
|
|
646
|
+
),
|
|
647
|
+
expected_schema,
|
|
648
|
+
)
|
|
649
|
+
if differences:
|
|
650
|
+
parser.error(
|
|
651
|
+
"existing database does not match its recorded migration history; "
|
|
652
|
+
"review the schema before migrating"
|
|
653
|
+
)
|
|
654
|
+
command.stamp(config, existing_head)
|
|
655
|
+
logger.info("Existing database matches its migration history; preserving its data.")
|
|
656
|
+
finally:
|
|
657
|
+
config.attributes.pop("connection", None)
|
|
658
|
+
temporary_engine.dispose()
|
|
659
|
+
|
|
660
|
+
def skip_empty_revision(migration_context, revision, directives):
|
|
661
|
+
if directives and directives[0].upgrade_ops.is_empty():
|
|
662
|
+
directives.clear()
|
|
663
|
+
|
|
664
|
+
command.revision(
|
|
665
|
+
config,
|
|
666
|
+
message=args.message,
|
|
667
|
+
autogenerate=True,
|
|
668
|
+
process_revision_directives=skip_empty_revision,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
# `migration` generates then applies; `migrate` only applies revisions already created.
|
|
672
|
+
from sqlalchemy import inspect, text
|
|
673
|
+
|
|
674
|
+
if not any(path.name != "__init__.py" for path in revisions.glob("*.py")):
|
|
675
|
+
if args.command == "migrate":
|
|
676
|
+
parser.error("no migration revisions found; run `python lenora.py migration` first")
|
|
677
|
+
logger.info("No schema changes detected.")
|
|
678
|
+
return
|
|
679
|
+
|
|
680
|
+
# When adopting a pre-migration SQLite file, recognize the earlier
|
|
681
|
+
# pluralized table name and rename it without dropping its existing rows.
|
|
682
|
+
if args.command == "migration":
|
|
683
|
+
with engine.begin() as connection:
|
|
684
|
+
inspector = inspect(connection)
|
|
685
|
+
existing = set(inspector.get_table_names())
|
|
686
|
+
if "alembic_version" not in existing and connection.dialect.name == "sqlite":
|
|
687
|
+
quote = connection.dialect.identifier_preparer.quote
|
|
688
|
+
for table_name, table in Model.metadata.tables.items():
|
|
689
|
+
old_name = f"{table_name}s"
|
|
690
|
+
if table_name not in existing and old_name in existing:
|
|
691
|
+
old_columns = {column["name"] for column in inspector.get_columns(old_name)}
|
|
692
|
+
if old_columns == set(table.columns.keys()):
|
|
693
|
+
connection.execute(text(f"ALTER TABLE {quote(old_name)} RENAME TO {quote(table_name)}"))
|
|
694
|
+
existing.remove(old_name)
|
|
695
|
+
existing.add(table_name)
|
|
696
|
+
|
|
697
|
+
mark_existing_schema = False
|
|
698
|
+
with engine.connect() as connection:
|
|
699
|
+
table_names = set(inspect(connection).get_table_names())
|
|
700
|
+
existing_tables = table_names - {"alembic_version"}
|
|
701
|
+
if existing_tables and "alembic_version" not in table_names:
|
|
702
|
+
differences = compare_metadata(MigrationContext.configure(connection, opts={"compare_type": True}), Model.metadata)
|
|
703
|
+
if differences:
|
|
704
|
+
parser.error("existing database schema differs from models; review it before applying the initial migration")
|
|
705
|
+
mark_existing_schema = True
|
|
706
|
+
if mark_existing_schema:
|
|
707
|
+
command.stamp(config, "head")
|
|
708
|
+
logger.info("Existing database matches the models; migration history initialized without changing its data.")
|
|
709
|
+
else:
|
|
710
|
+
command.upgrade(config, "head")
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def main() -> None:
|
|
714
|
+
"""Run the CLI and print an explicit result for migration commands."""
|
|
715
|
+
command_name = sys.argv[1] if len(sys.argv) > 1 else None
|
|
716
|
+
is_migration = command_name in {"migration", "migrate"}
|
|
717
|
+
if is_migration:
|
|
718
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
719
|
+
try:
|
|
720
|
+
_main()
|
|
721
|
+
except SystemExit as exc:
|
|
722
|
+
if is_migration and exc.code not in (None, 0):
|
|
723
|
+
logger.error("Migration failed: exit status %s", exc.code)
|
|
724
|
+
raise
|
|
725
|
+
except Exception as exc:
|
|
726
|
+
if is_migration:
|
|
727
|
+
logger.error("Migration failed: %s", str(exc) or "unknown error")
|
|
728
|
+
raise
|
|
729
|
+
else:
|
|
730
|
+
if is_migration:
|
|
731
|
+
logger.info("Migration successful.")
|