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/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.")