Source code for nexusLIMS.cli.migrate

# ruff: noqa: PLC0415
"""CLI for NexusLIMS database migrations.

Provides simple commands for common database migration operations, while
still allowing advanced users to access the underlying Alembic functionality.

Usage
-----
.. code-block:: bash

    # Initialize a new database
    nexuslims db init

    # Upgrade to latest schema version
    nexuslims db upgrade

    # Show current database version
    nexuslims db current

    # Check for pending migrations
    nexuslims db check

    # Downgrade one migration
    nexuslims db downgrade

    # Browse the database interactively
    nexuslims db view

    # Create a demo database with sample instruments
    nexuslims db create-demo [PATH]

    # Advanced: Run any Alembic command
    nexuslims db alembic history --verbose

Examples
--------
Set up a new database:

.. code-block:: bash

    nexuslims db init

Check database status:

.. code-block:: bash

    nexuslims db current
    nexuslims db check

Apply pending migrations:

.. code-block:: bash

    nexuslims db upgrade

Notes
-----
This command automatically locates the migrations directory inside the
installed package, making it work correctly whether NexusLIMS is installed
via pip, uv, or run from source.
"""

from importlib.resources import files
from pathlib import Path


def _get_migrations_dir() -> Path:
    """Locate the migrations directory inside the installed package.

    Uses importlib.resources (Python 3.9+) to find nexusLIMS.db.migrations
    regardless of whether the package is installed normally, as an editable
    install, or run from source.

    Returns
    -------
    pathlib.Path
        Absolute path to the nexusLIMS/db/migrations/ directory.

    Raises
    ------
    ImportError
        If the nexusLIMS.db.migrations package cannot be found.
    """
    try:
        migrations_resource = files("nexusLIMS.db.migrations")
        return Path(str(migrations_resource))
    except (ImportError, TypeError) as e:
        msg = (
            "Could not locate nexusLIMS.db.migrations package. "
            "Ensure NexusLIMS is properly installed."
        )
        raise ImportError(msg) from e


def _get_alembic_config():
    """Create an Alembic Config object for the packaged migrations.

    Returns
    -------
    alembic.config.Config
        Configured Alembic Config object with script_location set.
    """
    from alembic.config import Config

    migrations_dir = _get_migrations_dir()
    cfg = Config()
    cfg.set_main_option("script_location", str(migrations_dir))
    return cfg


def _run_alembic_command(command_name: str, *args, **kwargs):
    """Run an Alembic command programmatically.

    Parameters
    ----------
    command_name : str
        Name of the Alembic command function (e.g., 'upgrade', 'downgrade')
    *args
        Positional arguments to pass to the Alembic command
    **kwargs
        Keyword arguments to pass to the Alembic command
    """
    import alembic.command

    cfg = _get_alembic_config()
    command_func = getattr(alembic.command, command_name)
    command_func(cfg, *args, **kwargs)


def _get_current_revision() -> str:
    """Get the current database revision.

    Returns
    -------
    str
        Current revision ID, "none" if not stamped, or "unknown" if database
        doesn't exist
    """
    import os

    from alembic.runtime.migration import MigrationContext
    from sqlalchemy import create_engine

    db_path = os.getenv("NX_DB_PATH")
    if not db_path:
        return "unknown"

    # Check if database file exists
    if not Path(db_path).exists():
        return "unknown"

    try:
        engine = create_engine(f"sqlite:///{db_path}")
        with engine.connect() as connection:
            context = MigrationContext.configure(connection)
            current_rev = context.get_current_revision()
            return current_rev or "none"
    except Exception:
        return "unknown"


def _cli():  # noqa: PLR0915
    """Create the Click CLI application.

    Lazy import of click to speed up --help for other entry points.
    """
    import click

    @click.group(invoke_without_command=True)
    @click.option("--version", is_flag=True, help="Show version and exit")
    @click.pass_context
    def cli(ctx, version):
        """Manage NexusLIMS database.

        This tool provides simple commands for common database operations.
        For advanced usage, use 'nexuslims db alembic [COMMAND]' to
        access the full Alembic CLI.
        """
        # Load .env file if it exists (before any commands run)
        from dotenv import find_dotenv, load_dotenv

        load_dotenv(find_dotenv(usecwd=True))

        if version:
            from nexusLIMS import __version__

            click.echo(f"nexuslims db (NexusLIMS {__version__})")
            ctx.exit()

        if ctx.invoked_subcommand is None:
            click.echo(ctx.get_help())

    @cli.command()
    @click.option(
        "--force",
        is_flag=True,
        help="Overwrite existing database file if it exists",
    )
    def init(force):
        """Initialize a new NexusLIMS database.

        Creates the database file at NX_DB_PATH, applies the schema,
        and marks it as migrated to the latest version.

        This creates the database file and applies all migrations
        (equivalent to 'nexuslims db upgrade head' on a new DB).
        """
        import os
        import sys

        # Get DB path from environment variable directly (not from settings)
        # because settings validation requires the file to exist
        db_path_str = os.getenv("NX_DB_PATH")
        if not db_path_str:
            click.secho(
                "Error: NX_DB_PATH environment variable is not set.", fg="red", err=True
            )
            click.echo("\nSet it via the interactive configurator:\n", err=True)
            click.echo("    nexuslims config edit\n", err=True)
            click.echo("Or set it directly, e.g.:\n", err=True)
            click.echo("    export NX_DB_PATH=/path/to/database.db", err=True)
            sys.exit(1)

        db_path = Path(db_path_str)

        if db_path.exists() and not force:
            click.secho(
                f"Error: Database already exists at {db_path}", fg="red", err=True
            )
            click.echo(
                "Use --force to overwrite, or remove the file manually.", err=True
            )
            sys.exit(1)

        # Create parent directory if it doesn't exist
        db_path.parent.mkdir(parents=True, exist_ok=True)

        # Create empty database file
        db_path.touch()

        click.echo(f"Initializing database at {db_path}...")

        # Run all migrations to create the schema
        try:
            _run_alembic_command("upgrade", "head")
            click.secho("✓ Database initialized successfully", fg="green")
            click.echo(f"  Current version: {_get_current_revision()}")
        except Exception as e:
            click.secho(f"Error initializing database: {e}", fg="red", err=True)
            # Clean up the empty database file on failure
            if db_path.exists():
                db_path.unlink()
            sys.exit(1)

    @cli.command()
    @click.argument("revision", default="head")
    @click.option(
        "--sql", is_flag=True, help="Generate SQL script instead of applying changes"
    )
    def upgrade(revision, sql):
        r"""Upgrade database to a later version.

        REVISION is the target migration version (default: 'head' for latest).

        \b
        Examples:
          nexuslims db upgrade          # Upgrade to latest
          nexuslims db upgrade +1       # Upgrade one version
          nexuslims db upgrade abc123   # Upgrade to specific revision
        """
        import os
        import sys

        # Check database exists (unless in SQL mode where we just generate SQL)
        if not sql:
            db_path = os.getenv("NX_DB_PATH")
            if not db_path or not Path(db_path).exists():
                click.secho(
                    "Error: Database does not exist. Run 'nexuslims db init' first.",
                    fg="red",
                    err=True,
                )
                sys.exit(1)

            # Get current revision before upgrade
            before_revision = _get_current_revision()

        try:
            _run_alembic_command("upgrade", revision, sql=sql)
            if not sql:
                # Get new revision after upgrade
                after_revision = _get_current_revision()
                click.secho("✓ Database upgraded successfully", fg="green")
                click.echo(f"  {before_revision} -> {after_revision}")
        except Exception as e:
            click.secho(f"Error upgrading database: {e}", fg="red", err=True)
            sys.exit(1)

    @cli.command()
    @click.argument("revision", default="-1")
    @click.option(
        "--sql", is_flag=True, help="Generate SQL script instead of applying changes"
    )
    def downgrade(revision, sql):
        r"""Downgrade database to an earlier version.

        REVISION is the target migration version (default: '-1' for one step back).

        \b
        Examples:
          nexuslims db downgrade        # Downgrade one version
          nexuslims db downgrade -2     # Downgrade two versions
          nexuslims db downgrade abc123 # Downgrade to specific revision
        """
        import os
        import sys

        # Check database exists (unless in SQL mode where we just generate SQL)
        if not sql:
            db_path = os.getenv("NX_DB_PATH")
            if not db_path or not Path(db_path).exists():
                click.secho(
                    "Error: Database does not exist.",
                    fg="red",
                    err=True,
                )
                sys.exit(1)

            # Get current revision before downgrade
            before_revision = _get_current_revision()

        try:
            _run_alembic_command("downgrade", revision, sql=sql)
            if not sql:
                # Get new revision after downgrade
                after_revision = _get_current_revision()
                click.secho("✓ Database downgraded successfully", fg="green")
                click.echo(f"  {before_revision} -> {after_revision}")
        except Exception as e:
            click.secho(f"Error downgrading database: {e}", fg="red", err=True)
            sys.exit(1)

    @cli.command()
    @click.option("--verbose", "-v", is_flag=True, help="Show detailed information")
    def current(verbose):
        """Show the current database migration version.

        Displays the revision ID that the database is currently at.
        """
        import os
        import sys

        # Check database exists
        db_path = os.getenv("NX_DB_PATH")
        if not db_path or not Path(db_path).exists():
            click.secho(
                "Error: Database does not exist. Run 'nexuslims db init' first.",
                fg="red",
                err=True,
            )
            sys.exit(1)

        try:
            _run_alembic_command("current", verbose=verbose)
        except Exception as e:
            click.secho(f"Error checking database version: {e}", fg="red", err=True)
            sys.exit(1)

    @cli.command()
    def check():
        """Check if the database has pending migrations.

        Exits with code 0 if database is up-to-date, code 1 if migrations
        are pending, or code 2 on error.
        """
        import os
        import sys

        from alembic.script import ScriptDirectory

        # Check database exists
        db_path = os.getenv("NX_DB_PATH")
        if not db_path or not Path(db_path).exists():
            click.secho(
                "Error: Database does not exist. Run 'nexuslims db init' first.",
                fg="red",
                err=True,
            )
            sys.exit(2)

        try:
            cfg = _get_alembic_config()
            script = ScriptDirectory.from_config(cfg)

            # Get current database revision
            from alembic.runtime.migration import MigrationContext
            from sqlalchemy import create_engine

            engine = create_engine(f"sqlite:///{db_path}")
            with engine.connect() as connection:
                context = MigrationContext.configure(connection)
                current_rev = context.get_current_revision()

            head_rev = script.get_current_head()

            if current_rev == head_rev:
                click.secho(
                    f"✓ Database is up-to-date (revision: {current_rev or 'none'})",
                    fg="green",
                )
                sys.exit(0)
            else:
                click.secho("⚠ Database has pending migrations", fg="yellow", err=True)
                click.echo(f"  Current revision: {current_rev or 'none'}", err=True)
                click.echo(f"  Latest revision:  {head_rev}", err=True)
                click.echo(
                    "\nRun 'nexuslims db upgrade' to apply pending migrations.",
                    err=True,
                )
                sys.exit(1)
        except Exception as e:
            click.secho(f"Error checking migrations: {e}", fg="red", err=True)
            sys.exit(2)

    @cli.command()
    @click.option("--verbose", "-v", is_flag=True, help="Show detailed information")
    @click.option(
        "--indicate-current",
        "-i",
        is_flag=True,
        help="Indicate current revision (Alembic default)",
    )
    def history(verbose, indicate_current):
        """Show migration history.

        Displays the revision history for the database migrations.
        """
        try:
            _run_alembic_command(
                "history", verbose=verbose, indicate_current=indicate_current
            )
        except Exception as e:
            click.secho(f"Error showing history: {e}", fg="red", err=True)
            import sys

            sys.exit(1)

    @cli.command(
        context_settings={"ignore_unknown_options": True, "allow_extra_args": True}
    )
    @click.pass_context
    def alembic(ctx):
        r"""Run Alembic commands directly (advanced usage).

        This passes all arguments directly to Alembic's CLI, allowing
        access to the full range of Alembic commands and options.

        \b
        Examples:
          nexuslims db alembic history --verbose
          nexuslims db alembic revision --autogenerate -m "Add column"
          nexuslims db alembic show head
        """
        import contextlib
        import sys
        import tempfile

        from alembic.config import CommandLine

        migrations_dir = _get_migrations_dir()

        # Create a temporary config file for Alembic
        with tempfile.NamedTemporaryFile(
            mode="w", suffix=".ini", delete=False
        ) as tmp_config:
            tmp_config.write(f"[alembic]\nscript_location = {migrations_dir}\n")
            tmp_config_path = Path(tmp_config.name)

        try:
            # Inject config file and pass through remaining arguments
            original_argv = sys.argv.copy()
            sys.argv = [
                "nexuslims db alembic",
                "-c",
                str(tmp_config_path),
                *ctx.args,
            ]

            # Run Alembic's CLI
            CommandLine(prog="nexuslims db alembic").main()
        finally:
            sys.argv = original_argv
            # Clean up the temporary config file
            with contextlib.suppress(OSError):
                tmp_config_path.unlink()

    @cli.command("create-demo")
    @click.argument(
        "path",
        type=click.Path(dir_okay=False, path_type=Path),
        default=None,
        required=False,
    )
    @click.option(
        "--force",
        is_flag=True,
        help="Overwrite the file if it already exists.",
    )
    def create_demo(path, force):
        """Create a demo database pre-populated with sample instruments.

        Creates a SQLite database containing 10 sample instruments with
        diverse configurations, useful for testing the TUI, generating
        documentation screenshots, or exploring NexusLIMS without a live
        NEMO instance.

        PATH defaults to a file named ``nexuslims_demo.db`` in the current
        working directory when not supplied.

        \b
        Examples:
          nexuslims db create-demo
          nexuslims db create-demo /tmp/demo.db
          nexuslims db create-demo ./my_demo.db --force
        """  # noqa: D301
        import sys

        from nexusLIMS.tui.demo_helpers import create_demo_database

        if path is None:
            path = Path.cwd() / "nexuslims_demo.db"

        if path.exists() and not force:
            click.secho(f"Error: file already exists at {path}", fg="red", err=True)
            click.echo("Use --force to overwrite it.", err=True)
            sys.exit(1)

        if force and path.exists():
            path.unlink()
        click.echo(f"Creating demo database at {path}\u2026")
        try:
            create_demo_database(path)
            click.secho(f"\u2713 Demo database created at {path}", fg="green")
        except Exception as e:
            click.secho(f"Error: {e}", fg="red", err=True)
            sys.exit(1)

    @cli.command()
    def view():
        """Open a read-only TUI browser for the NexusLIMS database.

        Launches a Textual-based SQLite browser pointed at the database
        configured via NX_DB_PATH.  You can browse tables and inspect rows
        across all NexusLIMS tables (instruments, session_log, upload_log,
        external_user_identifiers).

        \b
        Keybindings (inside the browser):
        ----------------------------------
          - Tab / Shift+Tab   Navigate between panels
          - Enter             Select / confirm
          - q / Ctrl+Q        Quit
        """  # noqa: D301
        import os
        import sys
        from argparse import Namespace

        db_path_str = os.getenv("NX_DB_PATH")
        if not db_path_str:
            click.secho(
                "Error: NX_DB_PATH environment variable is not set.", fg="red", err=True
            )
            click.echo(
                "Run 'nexuslims config edit' or set NX_DB_PATH manually.", err=True
            )
            sys.exit(1)

        if not Path(db_path_str).exists():
            click.secho(
                f"Error: Database does not exist at {db_path_str}",
                fg="red",
                err=True,
            )
            click.echo("Run 'nexuslims db init' to create the database.", err=True)
            sys.exit(1)

        from nexusLIMS.tui.apps.db_browser import NexusLIMSDBApp

        app = NexusLIMSDBApp(Namespace(filepath=db_path_str))
        app.run()

    return cli


[docs] def main() -> None: """Entry point for nexuslims db CLI.""" cli = _cli() cli()
if __name__ == "__main__": main()