Source code for nlpmed_portal.cli

# SPDX-FileCopyrightText: Copyright (C) 2026 Omid Jafari <omidjafari.com>
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

"""Command-line interface for an installed NLPMed Portal Lite application."""

import argparse
import os
import sys
from importlib import resources
from pathlib import Path
from typing import Final

import environ

from nlpmed_portal.__about__ import __version__

DEFAULT_SETTINGS: Final = "config.settings.production"
ENV_FILENAME: Final = ".env"


def _runtime_directory(value: str | None) -> Path:
    """Return the directory that stores local configuration and runtime data."""
    configured_home = value or os.environ.get("NLPMED_PORTAL_HOME")
    return Path(configured_home or Path.cwd()).expanduser().resolve()


def _template_contents() -> str:
    """Load the environment template from a wheel or source checkout."""
    packaged_template = resources.files("nlpmed_portal").joinpath("env.example")
    if packaged_template.is_file():
        return packaged_template.read_text(encoding="utf-8")

    source_template = Path(__file__).resolve().parents[1] / ".env.example"
    return source_template.read_text(encoding="utf-8")


def _initialize(home: Path) -> int:
    """Create a new local environment file without overwriting an existing one."""
    home.mkdir(parents=True, exist_ok=True)
    env_file = home / ENV_FILENAME
    try:
        with env_file.open("x", encoding="utf-8", errors="strict") as stream:
            stream.write(_template_contents())
    except FileExistsError:
        sys.stdout.write(f"Configuration already exists: {env_file}\n")
        return 0

    sys.stdout.write(f"Created configuration: {env_file}\n")
    return 0


def _configure_django(home: Path, settings_module: str) -> None:
    """Load local configuration and prepare environment variables for Django."""
    env_file = home / ENV_FILENAME
    if not env_file.is_file():
        msg = f"Configuration file not found: {env_file}. Run 'nlpmed-portal-lite init' first."
        raise FileNotFoundError(msg)

    os.environ["DJANGO_SETTINGS_MODULE"] = settings_module
    os.environ["DJANGO_READ_DOT_ENV_FILE"] = "False"
    environ.Env.read_env(env_file)
    os.environ.setdefault("DJANGO_STATIC_ROOT", str(home / "staticfiles"))
    os.environ.setdefault("DJANGO_MEDIA_ROOT", str(home / "media"))


def _setup(home: Path, settings_module: str) -> int:
    """Apply migrations and prepare production static assets."""
    _configure_django(home, settings_module)

    import django  # ruff: ignore[import-outside-top-level]
    from django.core.management import call_command  # ruff: ignore[import-outside-top-level]

    django.setup()
    call_command("migrate", interactive=False)
    call_command("collectstatic", interactive=False, verbosity=0)
    call_command("compress", force=True)
    return 0


def _run(
    home: Path,
    settings_module: str,
    host: str,
    port: int,
    workers: int,
) -> int:
    """Run the production ASGI server."""
    _configure_django(home, settings_module)

    import uvicorn  # ruff: ignore[import-outside-top-level]

    uvicorn.run(
        "config.asgi:application",
        host=host,
        port=port,
        log_level="info",
        reload=False,
        workers=workers,
    )
    return 0


def _manage(
    home: Path,
    settings_module: str,
    django_arguments: list[str],
) -> int:
    """Run a Django management command."""
    _configure_django(home, settings_module)

    from django.core.management import (  # ruff: ignore[import-outside-top-level]
        execute_from_command_line,
    )

    execute_from_command_line(["nlpmed-portal-lite", *django_arguments])
    return 0


def _parser() -> argparse.ArgumentParser:
    """Create the command-line argument parser."""
    parser = argparse.ArgumentParser(
        prog="nlpmed-portal-lite",
        description="Configure and run NLPMed Portal Lite.",
    )
    parser.add_argument(
        "--version",
        action="version",
        version=f"%(prog)s {__version__}",
    )
    parser.add_argument(
        "--home",
        help="Directory containing .env and runtime data (default: current directory).",
    )
    parser.add_argument(
        "--settings",
        default=DEFAULT_SETTINGS,
        help=f"Django settings module (default: {DEFAULT_SETTINGS}).",
    )

    subparsers = parser.add_subparsers(dest="command", required=True)

    subparsers.add_parser(
        "init",
        help="Create .env in the application home directory.",
    )
    subparsers.add_parser(
        "setup",
        help="Apply migrations and prepare static assets.",
    )

    run_parser = subparsers.add_parser(
        "run",
        help="Start the application server.",
    )
    run_parser.add_argument("--host", default="127.0.0.1")
    run_parser.add_argument("--port", default=9090, type=int)
    run_parser.add_argument("--workers", default=2, type=int)

    manage_parser = subparsers.add_parser(
        "manage",
        help="Run a Django management command.",
    )
    manage_parser.add_argument("django_arguments", nargs=argparse.REMAINDER)
    return parser


[docs] def main(arguments: list[str] | None = None) -> int: """Run the NLPMed Portal Lite command-line interface.""" parser = _parser() parsed = parser.parse_args(arguments) home = _runtime_directory(parsed.home) if parsed.command == "init": return _initialize(home) if parsed.command == "setup": return _setup(home, parsed.settings) if parsed.command == "run": return _run( home, parsed.settings, parsed.host, parsed.port, parsed.workers, ) if parsed.command == "manage": if not parsed.django_arguments: parser.error("manage requires a Django command") return _manage(home, parsed.settings, parsed.django_arguments) parser.error(f"unknown command: {parsed.command}") return 2
if __name__ == "__main__": raise SystemExit(main())