"""Project management commands."""
import os
import random
import shutil
import subprocess
import sys
from pathlib import Path
from typing import List, Optional
import typer
try:
import importlib.resources as resources
except ImportError:
import importlib_resources as resources
from dbx_python_cli.commands.install import (
install_as_sys_path,
install_frontend_if_exists,
install_package,
)
from dbx_python_cli.utils.project import (
_get_config_repo_names,
get_django_python_path,
resolve_project_path,
setup_django_command_env,
)
from dbx_python_cli.utils.repo import (
get_base_dir,
get_config,
get_projects_dir,
get_python_version,
is_flat_mode,
)
from dbx_python_cli.utils.venv import get_venv_info
app = typer.Typer(
help="๐ Project management commands",
context_settings={"help_option_names": ["-h", "--help"]},
no_args_is_help=True,
)
[docs]
@app.callback(invoke_without_command=True)
def project_callback(
ctx: typer.Context,
):
"""Project management commands."""
if not os.getenv("MONGODB_URI"):
typer.echo("โ ๏ธ Warning: MONGODB_URI is not set.", err=True)
[docs]
@app.command("list")
def list_projects():
"""List all projects in the projects directory."""
config = get_config()
base_dir = get_base_dir(config)
projects_dir = get_projects_dir(base_dir, is_flat_mode(config))
if not projects_dir.exists():
typer.echo(f"Projects directory: {projects_dir}\n")
typer.echo("No projects directory found.")
typer.echo("\nCreate a project using: dbx project add <name>")
raise typer.Exit(0)
# Find all projects (directories with pyproject.toml)
projects = []
for item in projects_dir.iterdir():
if item.is_dir() and (item / "manage.py").exists():
projects.append(item.name)
if not projects:
typer.echo(f"Projects directory: {projects_dir}\n")
typer.echo("No projects found.")
typer.echo("\nCreate a project using: dbx project add <name>")
raise typer.Exit(0)
typer.echo(f"Projects directory: {projects_dir}\n")
typer.echo(f"Found {len(projects)} project(s):\n")
for project in sorted(projects):
project_path = projects_dir / project
has_frontend = (project_path / "frontend").exists()
frontend_marker = " ๐จ" if has_frontend else ""
typer.echo(f" โข {project}{frontend_marker}")
if any((projects_dir / p / "frontend").exists() for p in projects):
typer.echo("\n๐จ = has frontend")
[docs]
@app.command("install")
def install_project(
name: str = typer.Argument(None, help="Project name (defaults to newest project)"),
directory: Path = typer.Option(
None,
"--directory",
"-d",
help="Custom directory where the project is located (defaults to base_dir/projects/)",
),
extras: str = typer.Option(
None,
"--extras",
"-e",
help="Comma-separated extras to install (e.g., 'test,dev')",
),
verbose: bool = typer.Option(
False,
"--verbose",
"-v",
help="Show verbose output",
),
):
"""
Install a Django project's dependencies.
If no project name is provided, uses the most recently created project.
Examples::
dbx project install # Install newest project
dbx project install myproject # Install specific project
dbx project install myproject -e test # Install with extras
"""
# Resolve project path
proj = resolve_project_path(name, directory)
python_path, venv_type = get_django_python_path(proj, directory)
typer.echo(f"๐ฆ Installing project '{proj.name}'...")
typer.echo(f" Project path: {proj.project_path}")
typer.echo(f" Using {venv_type} venv")
# Install the project
result = install_package(
proj.project_path,
python_path,
install_dir=None,
extras=extras,
groups=None,
verbose=verbose,
)
if result == "failed":
raise typer.Exit(1)
elif result == "skipped":
return
typer.echo(f"\nโ
Project '{proj.name}' installed successfully!")
# Check for frontend and install if present
install_frontend_if_exists(proj.project_path, verbose=verbose)
# Constants for random name generation
ADJECTIVES = [
"happy",
"sunny",
"clever",
"brave",
"calm",
"bright",
"swift",
"gentle",
"mighty",
"noble",
"quiet",
"wise",
"bold",
"keen",
"lively",
"merry",
"proud",
"quick",
"smart",
"strong",
]
NOUNS = [
"panda",
"eagle",
"tiger",
"dragon",
"phoenix",
"falcon",
"wolf",
"bear",
"lion",
"hawk",
"owl",
"fox",
"deer",
"otter",
"seal",
"whale",
"shark",
"raven",
"cobra",
"lynx",
]
[docs]
def generate_random_project_name():
"""Generate a random project name using adjectives and nouns."""
adjective = random.choice(ADJECTIVES)
noun = random.choice(NOUNS)
return f"{adjective}_{noun}"
[docs]
@app.command("add")
def add_project(
name: str = typer.Argument(
None, help="Project name (optional, generates random name if not provided)"
),
directory: Path = typer.Option(
None,
"--directory",
"-d",
help="Custom directory to create the project in (defaults to base_dir/projects/)",
),
base_dir: Path = typer.Option(
None,
"--base-dir",
help="Use this as the project root directory. Only used when --directory is not specified.",
),
add_frontend: bool = typer.Option(
True,
"--add-frontend/--no-frontend",
"-f/-F",
help="Add frontend (default: True)",
),
add_wagtail: bool = typer.Option(
False,
"--wagtail/--no-wagtail",
"-w/-W",
help="Enable Wagtail CMS (default: False)",
),
add_qe: bool = typer.Option(
False,
"--qe/--no-qe",
"-q/-Q",
help="Enable Queryable Encryption (default: False)",
),
add_bakerydemo: bool = typer.Option(
False,
"--bakerydemo/--no-bakerydemo",
"-b/-B",
help="Clone and install bakerydemo into the project venv (requires --wagtail).",
),
auto_install: bool = typer.Option(
True,
"--install/--no-install",
help="Automatically install the project after creation (default: True)",
),
python_path_override: Optional[str] = typer.Option(
None,
"--python-path",
hidden=True,
help="Override the Python executable used for django-admin (bypasses venv detection).",
),
with_repos: List[str] = typer.Option(
[],
"--with",
help="Install a local clone into the project venv after creation (repeatable).",
),
):
"""
Create a new Django project using bundled templates.
Frontend is added by default. Use --no-frontend to skip frontend creation.
Use --wagtail to create a Wagtail project from wagtail-mongodb-project.
Use --bakerydemo (with --wagtail) to clone and install bakerydemo into the venv.
Use --qe to enable Queryable Encryption.
Projects are created in base_dir/projects/ by default.
If no name is provided, a random name is generated.
Examples::
dbx project add # Create with random name (includes frontend)
dbx project add myproject # Create with explicit name (includes frontend)
dbx project add myproject --no-frontend # Create without frontend
dbx project add myproject --wagtail # Create Wagtail project from wagtail-mongodb-project
dbx project add myproject --qe # Create with Queryable Encryption enabled
dbx project add -d ~/custom/path # Create with random name in custom directory
dbx project add myproject -d ~/custom/path # Create in custom directory
dbx project add myproject --base-dir ~/path/to/myproject # Create directly at path
dbx project add myproject --with medical-records # Install a local clone
dbx project add myproject --with django --with django-mongodb-extensions
"""
# Normalize parameters when called programmatically (not via CLI).
# When called directly, typer.Option/Argument defaults are OptionInfo/ArgumentInfo objects.
if not isinstance(name, (str, type(None))):
name = None
if not isinstance(directory, (Path, type(None))):
directory = None
if not isinstance(base_dir, (Path, type(None))):
base_dir = None
if not isinstance(add_frontend, bool):
add_frontend = True
if not isinstance(add_wagtail, bool):
add_wagtail = False
if not isinstance(add_qe, bool):
add_qe = False
if not isinstance(add_bakerydemo, bool):
add_bakerydemo = False
if not isinstance(auto_install, bool):
auto_install = True
if not isinstance(python_path_override, (str, type(None))):
python_path_override = None
if not isinstance(with_repos, list):
with_repos = []
# Determine project directory and name
config = get_config()
use_base_dir_override = False
if directory is None:
if base_dir is None:
# Use base_dir/projects/name as default when using config
if name is None:
name = generate_random_project_name()
typer.echo(f"๐ฒ Generated random project name: {name}")
base_dir = get_base_dir(config)
projects_dir = get_projects_dir(base_dir, is_flat_mode(config))
projects_dir.mkdir(parents=True, exist_ok=True)
project_path = projects_dir / name
else:
# When --base-dir is specified, create project in that existing directory
use_base_dir_override = True
project_path = base_dir.expanduser()
# Ensure the directory exists
project_path.mkdir(parents=True, exist_ok=True)
# Use the provided name, or extract from path if not provided
if name is None:
name = project_path.name
else:
if name is None:
name = generate_random_project_name()
typer.echo(f"๐ฒ Generated random project name: {name}")
project_path = directory / name
# Use project name as default settings module
settings_path = f"settings.{name}"
# Only check if project exists when NOT using --base-dir override
if not use_base_dir_override and project_path.exists():
typer.echo(f"โ Project '{name}' already exists at {project_path}", err=True)
raise typer.Exit(code=1)
# When using --base-dir, check if manage.py already exists
if use_base_dir_override and (project_path / "manage.py").exists():
typer.echo(
f"โ Project already exists at {project_path} (manage.py found)", err=True
)
raise typer.Exit(code=1)
# Check for virtual environment before running django-admin
# For project creation, check parent directories only (project doesn't exist yet)
# Check in order: projects group-level โ base-level โ activated venv
if python_path_override is not None:
# Caller already determined the venv (e.g. from `dbx test django`); use it directly.
python_path = python_path_override
else:
config_python_version = get_python_version(config)
need_new_venv = False
try:
if directory is None and not use_base_dir_override:
# Using config-based base_dir/projects/name
# Check: projects_dir/.venv โ django group/.venv โ base_dir/.venv โ activated
django_group_path = base_dir / "django"
fallback_paths = (
[django_group_path] if django_group_path.exists() else None
)
python_path, venv_type = get_venv_info(
None,
projects_dir,
base_path=base_dir,
fallback_paths=fallback_paths,
)
else:
# Using custom --directory or --base-dir override
# Check: activated venv only
python_path, venv_type = get_venv_info(None, None, base_path=None)
# If config pins a Python version, reject found venvs that don't match
if config_python_version and auto_install:
found_ver = _venv_python_version(python_path)
if found_ver and found_ver != config_python_version:
typer.echo(
f"โ ๏ธ Found Python {found_ver} venv but config requires "
f"{config_python_version} โ creating project-specific venv.\n"
)
need_new_venv = True
if not need_new_venv:
if venv_type == "group":
typer.echo(
f"โ
Using group venv: {Path(python_path).parent.parent}\n"
)
elif venv_type == "base":
typer.echo(f"โ
Using base venv: {base_dir}/.venv\n")
elif venv_type == "venv":
typer.echo(f"โ
Using activated venv: {python_path}\n")
except typer.Exit:
need_new_venv = True
if need_new_venv:
if not auto_install:
# When --no-install is given we only need django-admin to scaffold
# the project โ no installation step runs, so a dedicated venv is
# not required. Fall back to the current interpreter; django-admin
# will be available as long as Django is installed in this env.
python_path = sys.executable
else:
# No suitable venv found โ create one with the configured Python version.
if directory is None and not use_base_dir_override:
venv_parent = projects_dir
elif use_base_dir_override:
venv_parent = project_path.parent
else:
venv_parent = directory
typer.echo(
"โ๏ธ Creating virtual environment"
+ (
f" (Python {config_python_version})"
if config_python_version
else ""
)
+ f" at {venv_parent / '.venv'}..."
)
python_path = _create_venv(
venv_parent / ".venv",
python_version=config_python_version,
)
venv_type = "group"
# Ensure Django is importable in the venv โ install it if not.
# This handles both freshly created venvs and existing venvs that predate
# any project setup (e.g. a venv created by a previous failed run).
if python_path_override is None and auto_install:
django_check = subprocess.run(
[python_path, "-c", "import django"],
capture_output=True,
check=False,
)
if django_check.returncode != 0:
_bootstrap_venv(python_path)
if add_wagtail:
from contextlib import nullcontext
from dbx_python_cli.utils.repo import (
find_repo_by_name,
is_flat_mode as _is_flat,
)
_wt_config = get_config()
_wt_base_dir = get_base_dir(_wt_config) if base_dir is None else base_dir
wt_repo = (
find_repo_by_name("wagtail-mongodb-project", _wt_base_dir, _wt_config)
if _wt_base_dir is not None
else None
)
if wt_repo is None and _wt_base_dir is not None:
clone_result = _clone_repo_from_config(
"wagtail-mongodb-project",
_wt_base_dir,
_wt_config,
_is_flat(_wt_config),
False,
)
if clone_result is not None:
wt_repo = find_repo_by_name(
"wagtail-mongodb-project", _wt_base_dir, _wt_config
)
if wt_repo is None:
typer.echo(
"โ wagtail-mongodb-project not found. Add it to your repo groups or clone it.",
err=True,
)
raise typer.Exit(1)
template_ctx = nullcontext(Path(wt_repo["path"]))
extra_name_args: list = []
else:
template_ctx = resources.path("dbx_python_cli.templates", "project_template")
extra_name_args = ["--name", "justfile"]
with template_ctx as template_path:
# Use python -m django to ensure we use the correct venv's Django
cmd = [
python_path,
"-m",
"django",
"startproject",
"--template",
str(template_path),
*extra_name_args,
name,
]
# When using --base-dir override, or when project_path.parent is base_dir
# (flat mode), create the project dir first and run django-admin in it with
# "." so that base_dir never ends up on sys.path (which would let the cloned
# django/ repo shadow the installed package).
_flat = is_flat_mode(get_config())
if use_base_dir_override or _flat:
project_path.mkdir(parents=True, exist_ok=True)
cmd.append(".")
cwd = str(project_path)
else:
cwd = str(project_path.parent)
typer.echo(f"๐ฆ Creating project: {name}")
# Run django in a way that surfaces a clean, user-friendly error
# instead of a full Python traceback when Django is missing or
# misconfigured in the current environment.
try:
result = subprocess.run(
cmd,
check=False,
capture_output=True,
text=True,
cwd=cwd,
)
except FileNotFoundError:
typer.echo(
f"โ Python not found at '{python_path}'. Make sure the venv exists "
"and Django is installed.",
err=True,
)
raise typer.Exit(code=1)
if result.returncode != 0:
# Try to show a concise reason (e.g. "ModuleNotFoundError: No module named 'django'")
reason = None
if result.stderr:
lines = [
line.strip() for line in result.stderr.splitlines() if line.strip()
]
if lines:
reason = lines[-1]
typer.echo(
"โ Failed to create project. "
"This usually means Django is not installed or is misconfigured "
"in the current Python environment.",
err=True,
)
if reason:
typer.echo(f" Reason: {reason}", err=True)
# Also show stdout if available for debugging
if result.stdout:
typer.echo(f" Output: {result.stdout.strip()}", err=True)
raise typer.Exit(code=result.returncode)
# Verify the project directory was created
if not project_path.exists():
typer.echo(
f"โ Project directory was not created at {project_path}. "
"The command may have failed silently.",
err=True,
)
if result.stdout:
typer.echo(f" stdout: {result.stdout.strip()}", err=True)
if result.stderr:
typer.echo(f" stderr: {result.stderr.strip()}", err=True)
raise typer.Exit(code=1)
# Add pyproject.toml after project creation
_create_pyproject_toml(
project_path, name, settings_path, wagtail=add_wagtail, qe=add_qe
)
# Enable Queryable Encryption if requested
if add_qe:
typer.echo(f"๐ Enabling Queryable Encryption for project '{name}'...")
try:
_enable_qe(project_path, name)
except Exception as e:
typer.echo(
f"โ ๏ธ Project created successfully, but QE setup failed: {e}",
err=True,
)
# Create frontend by default (unless --no-frontend is specified)
if add_frontend:
typer.echo(f"๐จ Adding frontend to project '{name}'...")
try:
# Call the internal frontend create helper
# Pass the parent directory of project_path and the venv python so
# the helper uses the correct Django.
_add_frontend(name, project_path.parent, python_path=python_path)
except Exception as e:
typer.echo(
f"โ ๏ธ Project created successfully, but frontend creation failed: {e}",
err=True,
)
# Automatically install the project if requested
if auto_install:
typer.echo(f"\n๐ฆ Installing project '{name}'...")
try:
# Get the repos base directory for venv detection
repos_config = get_config()
repos_base_dir = get_base_dir(repos_config)
# Get the virtual environment info, checking most specific to least specific:
# project โ projects group โ django group โ base
projects_dir = get_projects_dir(repos_base_dir, is_flat_mode(repos_config))
django_group_path = repos_base_dir / "django"
fallback_paths = [django_group_path] if django_group_path.exists() else None
python_path, venv_type = get_venv_info(
project_path,
projects_dir if projects_dir.exists() else None,
base_path=repos_base_dir,
fallback_paths=fallback_paths,
)
# Install the Python package
result = install_package(
project_path,
python_path,
install_dir=None,
extras=None,
groups=None,
verbose=False,
)
if result == "success":
typer.echo("โ
Python package installed successfully")
elif result == "skipped":
typer.echo(
"โ ๏ธ Installation skipped (no pyproject.toml found)", err=True
)
else:
typer.echo("โ ๏ธ Python package installation failed", err=True)
# Install frontend dependencies if frontend exists
if add_frontend:
frontend_installed = install_frontend_if_exists(
project_path, verbose=False
)
if not frontend_installed and (project_path / "frontend").exists():
typer.echo(
"โ ๏ธ Frontend installation failed or npm not found",
err=True,
)
except Exception as e:
typer.echo(
f"โ ๏ธ Project created successfully, but installation failed: {e}",
err=True,
)
if with_repos:
try:
_install_with_repos(with_repos, python_path)
except Exception as e:
typer.echo(f"โ ๏ธ --with install failed: {e}", err=True)
if add_qe:
from dbx_python_cli.utils.project import validate_qe_env
config = get_config()
base_dir = get_base_dir(config)
# 1. Ensure libmongocrypt is cloned + built (provides PYMONGOCRYPT_LIB target)
typer.echo("๐ Setting up libmongocrypt for Queryable Encryption...")
lib_path = _ensure_libmongocrypt_built(base_dir, config)
if lib_path is None:
typer.echo(
"โ ๏ธ libmongocrypt not available. QE will not work at runtime "
"until you build it manually (see `dbx install libmongocrypt`).",
err=True,
)
# 2. Install pymongocrypt (prefers clone at libmongocrypt/bindings/python)
pmc_result = _ensure_package_installed(
"pymongocrypt", python_path, base_dir, required=False
)
if pmc_result == "failed":
typer.echo("โ pymongocrypt install failed (required for --qe)", err=True)
raise typer.Exit(1)
# 3. Install medical-records (REQUIRED โ listed in QE_INSTALLED_APPS)
_ensure_package_installed(
"medical_records", python_path, base_dir, required=True
)
# 4. Validate env vars at the end so the user sees what's missing
validate_qe_env(config, base_dir=base_dir, fatal=False)
if add_wagtail:
typer.echo("๐ฟ Installing wagtail...")
try:
_install_with_repos(["wagtail"], python_path)
except Exception as e:
typer.echo(f"โ ๏ธ wagtail install failed: {e}", err=True)
if add_bakerydemo:
if not add_wagtail:
typer.echo("โ ๏ธ --bakerydemo requires --wagtail", err=True)
else:
typer.echo("๐ง Installing bakerydemo...")
try:
_install_with_repos(["bakerydemo"], python_path)
subprocess.run(
[
"uv",
"pip",
"install",
"--python",
python_path,
"wagtail-font-awesome-svg",
],
check=True,
capture_output=True,
)
except Exception as e:
typer.echo(f"โ ๏ธ bakerydemo install failed: {e}", err=True)
else:
typer.echo("๐ง Configuring bakerydemo in project settings...")
try:
_configure_bakerydemo(project_path, name)
except Exception as e:
typer.echo(f"โ ๏ธ bakerydemo configuration failed: {e}", err=True)
def _configure_bakerydemo(project_path: Path, name: str) -> None:
"""Add bakerydemo apps to INSTALLED_APPS and create migration stubs."""
settings_file = project_path / name / "settings" / "base.py"
if not settings_file.exists():
settings_file = project_path / name / "settings" / f"{name}.py"
if not settings_file.exists():
typer.echo(
"โ ๏ธ Could not find settings file for bakerydemo configuration", err=True
)
return
content = settings_file.read_text()
bakerydemo_apps = (
' "bakerydemo",\n'
' "wagtailfontawesomesvg",\n'
' "bakerydemo.base",\n'
' "bakerydemo.blog",\n'
' "bakerydemo.breads",\n'
' "bakerydemo.locations",\n'
' "bakerydemo.people",\n'
' "bakerydemo.recipes",\n'
' "bakerydemo.search",\n'
)
# Replace "home" with bakerydemo apps; home.HomePage conflicts with
# bakerydemo.base.HomePage (same page_ptr reverse name via Wagtail).
# The init management command now lives in the project package, not home.
if ' "home",\n' in content:
content = content.replace(' "home",\n', bakerydemo_apps)
# Remove template "search" app (bakerydemo.search has the same label)
content = content.replace(' "search",\n', "")
bakerydemo_migration_entries = (
' "base": "mongo_migrations.bakerydemo_base",\n'
' "blog": "mongo_migrations.bakerydemo_blog",\n'
' "breads": "mongo_migrations.bakerydemo_breads",\n'
' "locations": "mongo_migrations.bakerydemo_locations",\n'
' "people": "mongo_migrations.bakerydemo_people",\n'
' "recipes": "mongo_migrations.bakerydemo_recipes",\n'
' "search": "mongo_migrations.bakerydemo_search",\n'
)
if "MIGRATION_MODULES = {" in content:
content = content.replace(
"MIGRATION_MODULES = {\n",
"MIGRATION_MODULES = {\n" + bakerydemo_migration_entries,
)
settings_file.write_text(content)
mongo_migrations_dir = project_path / "mongo_migrations"
if mongo_migrations_dir.exists():
for stub_name in (
"bakerydemo_base",
"bakerydemo_blog",
"bakerydemo_breads",
"bakerydemo_locations",
"bakerydemo_people",
"bakerydemo_recipes",
"bakerydemo_search",
):
stub_dir = mongo_migrations_dir / stub_name
stub_dir.mkdir(exist_ok=True)
(stub_dir / "__init__.py").touch()
def _venv_python_version(python_path: str) -> Optional[str]:
"""Return the 'major.minor' Python version for a given executable, or None on error."""
result = subprocess.run(
[
python_path,
"-c",
"import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')",
],
capture_output=True,
text=True,
check=False,
)
return result.stdout.strip() if result.returncode == 0 else None
def _install_with_repos(
repo_names: List[str], python_path: str, verbose: bool = False
) -> None:
"""Install local clones into the project venv by name."""
from dbx_python_cli.utils.repo import find_repo_by_name, is_flat_mode
config = get_config()
base_dir = get_base_dir(config)
flat = is_flat_mode(config)
for repo_name in repo_names:
repo_info = find_repo_by_name(repo_name, base_dir, config)
if not repo_info:
clone_path = _clone_repo_from_config(
repo_name, base_dir, config, flat, verbose
)
if clone_path is None:
typer.echo(
f"โ ๏ธ Clone '{repo_name}' not found locally and not in repos config โ skipping.",
err=True,
)
continue
else:
clone_path = repo_info["path"]
# Check if this repo should be added to sys.path rather than pip-installed.
group_name = (repo_info or {}).get("group")
if group_name is None:
# repo was freshly cloned โ look up its group from config
for gname, gcfg in config.get("repo", {}).get("groups", {}).items():
if any(
url.split("/")[-1].replace(".git", "") == repo_name
for url in gcfg.get("repos", [])
):
group_name = gname
break
use_sys_path = group_name is not None and config.get("repo", {}).get(
"groups", {}
).get(group_name, {}).get("sys_path", {}).get(repo_name, False)
if use_sys_path:
typer.echo(f"๐ Adding '{repo_name}' to sys.path at {clone_path}...")
result = install_as_sys_path(clone_path, python_path, verbose=verbose)
else:
typer.echo(
f"๐ฆ Installing '{repo_name}' from local clone at {clone_path}..."
)
result = install_package(
clone_path,
python_path,
install_dir=None,
extras=None,
groups=None,
verbose=verbose,
)
if result == "success":
typer.echo(f"โ
'{repo_name}' installed")
elif result == "failed":
typer.echo(f"โ ๏ธ Failed to install '{repo_name}'", err=True)
def _clone_repo_from_config(
repo_name: str,
base_dir: Path,
config: dict,
flat: bool,
verbose: bool = False,
) -> Optional[Path]:
"""Clone a repo by name if it appears in a config group. Returns the clone path, or None."""
from dbx_python_cli.utils.repo import get_group_dir, get_repo_groups
groups = get_repo_groups(config)
found_url = None
found_group = None
for group_name, group_config in groups.items():
for repo_url in group_config.get("repos", []):
if repo_url.split("/")[-1].replace(".git", "") == repo_name:
found_url = repo_url
found_group = group_name
break
if found_url:
break
if not found_url:
return None
group_dir = get_group_dir(base_dir, found_group, flat)
repo_path = group_dir / repo_name
group_dir.mkdir(parents=True, exist_ok=True)
typer.echo(f" ๐ฅ Cloning '{repo_name}' from config (group: {found_group})...")
try:
subprocess.run(
["git", "clone", found_url, str(repo_path)],
check=True,
capture_output=not verbose,
text=True,
)
except subprocess.CalledProcessError as e:
typer.echo(f"โ Failed to clone '{repo_name}': {e}", err=True)
return None
from dbx_python_cli.utils.repo import get_preferred_branch, switch_to_branch
preferred_branch = get_preferred_branch(config, found_group, repo_name)
if preferred_branch:
switch_to_branch(repo_path, preferred_branch, verbose)
return repo_path
def _create_venv(venv_path: Path, python_version: Optional[str] = None) -> str:
"""Create a venv with uv and return the python executable path."""
import platform
cmd = ["uv", "venv", str(venv_path), "--no-python-downloads"]
if python_version:
cmd += ["--python", python_version]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
typer.echo("โ Failed to create virtual environment", err=True)
if result.stderr:
typer.echo(result.stderr.strip(), err=True)
raise typer.Exit(1)
typer.echo(f"โ
Created virtual environment at {venv_path}")
python_subpath = (
"Scripts/python.exe" if platform.system() == "Windows" else "bin/python"
)
return str(venv_path / python_subpath)
def _bootstrap_venv(python_path: str) -> None:
"""Install Django into a freshly created venv so django-admin startproject can run."""
typer.echo("๐ฆ Installing Django into new virtual environment...")
result = subprocess.run(
["uv", "pip", "install", "django", "--python", python_path],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
typer.echo("โ Failed to install Django", err=True)
if result.stderr:
typer.echo(result.stderr.strip(), err=True)
raise typer.Exit(1)
typer.echo("โ
Django installed")
def _fix_broken_editable_installs(
python_path: str, project_path: Path, verbose: bool = False
) -> None:
"""Reinstall any declared dependency that has a stale editable-install dist-info.
Scoped to the project's declared dependencies so that old removed projects
(which also leave behind dist-info entries) are never touched.
uv considers a package satisfied if its dist-info exists, even when the source
directory the editable install pointed to has been deleted. This leaves the package
unimportable while uv's resolver thinks it's fine.
"""
import json
import re
import tomllib
pyproject = project_path / "pyproject.toml"
if not pyproject.exists():
return
try:
with open(pyproject, "rb") as f:
toml_data = tomllib.load(f)
except Exception:
return
raw_deps = toml_data.get("project", {}).get("dependencies", [])
if not raw_deps:
return
# Build a set of normalised names (both hyphen and underscore forms) so we
# can match dist-info directory names regardless of normalisation.
declared: set[str] = set()
for dep in raw_deps:
name = re.split(r"[>=<!;\[\s]", dep, maxsplit=1)[0].strip()
declared.add(name.lower().replace("-", "_"))
declared.add(name.lower().replace("_", "-"))
site_result = subprocess.run(
[python_path, "-c", "import site; print(site.getsitepackages()[0])"],
capture_output=True,
text=True,
check=False,
)
if site_result.returncode != 0:
return
site_packages = Path(site_result.stdout.strip())
if not site_packages.exists():
return
for dist_info in site_packages.glob("*.dist-info"):
pkg_raw = dist_info.stem.rsplit("-", 1)[0]
if (
pkg_raw.lower() not in declared
and pkg_raw.lower().replace("_", "-") not in declared
and pkg_raw.lower().replace("-", "_") not in declared
):
continue # not a declared dependency of this project
direct_url = dist_info / "direct_url.json"
if not direct_url.exists():
continue
try:
data = json.loads(direct_url.read_text())
except Exception:
continue
if not data.get("dir_info", {}).get("editable"):
continue
url = data.get("url", "")
if not url.startswith("file://"):
continue
source_path = Path(url[len("file://") :])
if source_path.exists() and (
(source_path / "pyproject.toml").exists()
or (source_path / "setup.py").exists()
):
continue # source is still a valid installable package
top_level = dist_info / "top_level.txt"
if not top_level.exists():
continue
modules = [m.strip() for m in top_level.read_text().splitlines() if m.strip()]
if not modules:
continue
check = subprocess.run(
[python_path, "-c", f"import {modules[0]}"],
capture_output=True,
cwd="/tmp",
check=False,
)
if check.returncode == 0:
continue
pkg_name = pkg_raw.replace("_", "-")
typer.echo(f"๐ง Reinstalling broken editable install: {pkg_name}")
subprocess.run(
["uv", "pip", "install", "--reinstall", "--python", python_path, pkg_name],
capture_output=not verbose,
text=True,
check=False,
)
def _ensure_package_installed(
import_name: str,
python_path: str,
base_dir,
verbose: bool = False,
*,
required: bool = False,
) -> str:
"""Ensure a package is installed, preferring a local clone over PyPI.
Uses find_repo_by_name to locate a clone in any configured group,
respecting group priority and install_dirs (e.g. pymongocrypt lives
at libmongocrypt/bindings/python). If no clone is found but the repo
appears in config, auto-clones it (same pattern as _ensure_libmongocrypt_built).
Falls back to PyPI.
Returns "skipped" | "clone" | "pypi" | "failed". When required=True
and the result is "failed", raises typer.Exit(1).
"""
from dbx_python_cli.utils.repo import (
find_repo_by_name,
get_install_dirs,
is_flat_mode,
)
check = subprocess.run(
[python_path, "-c", f"import {import_name}"],
capture_output=True,
cwd="/tmp",
)
if check.returncode == 0:
return "skipped"
repo_name = import_name.replace("_", "-")
config = get_config()
repo_info = (
find_repo_by_name(repo_name, base_dir, config) if base_dir is not None else None
)
# If not found locally, try auto-cloning from config before falling back to PyPI.
if repo_info is None and base_dir is not None:
clone_path = _clone_repo_from_config(
repo_name, base_dir, config, is_flat_mode(config), verbose
)
if clone_path is not None:
repo_info = find_repo_by_name(repo_name, base_dir, config)
if repo_info:
clone_path = Path(repo_info["path"])
install_dirs = get_install_dirs(config, repo_info["group"], repo_name) or [""]
first = install_dirs[0]
target = clone_path / first if first else clone_path
if (target / "pyproject.toml").exists() or (target / "setup.py").exists():
typer.echo(f"๐ฆ Installing {repo_name} from local clone at {target}...")
result = subprocess.run(
[
"uv",
"pip",
"install",
"--reinstall",
"--python",
python_path,
"-e",
str(target),
],
capture_output=not verbose,
check=False,
)
if result.returncode == 0:
return "clone"
# Use --reinstall to override any stale editable installs pointing to a
# deleted source directory (uv skips reinstall otherwise, leaving a broken .pth).
typer.echo(f"๐ฆ Installing {repo_name} from PyPI...")
result = subprocess.run(
["uv", "pip", "install", "--reinstall", "--python", python_path, repo_name],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
return "pypi"
typer.echo(f"โ ๏ธ Failed to install {repo_name} from PyPI:", err=True)
if result.stderr:
typer.echo(result.stderr.strip(), err=True)
if required:
raise typer.Exit(1)
return "failed"
def _ensure_libmongocrypt_built(
base_dir: Path, config: dict, verbose: bool = False
) -> Optional[Path]:
"""Ensure libmongocrypt is cloned AND built (cmake-build artifact exists).
Returns the path to the built shared library, or None on failure.
"""
from dbx_python_cli.commands.install import run_build_commands
from dbx_python_cli.utils.repo import (
find_repo_by_name,
get_build_commands,
is_flat_mode,
)
repo_info = find_repo_by_name("libmongocrypt", base_dir, config)
if repo_info:
clone_path = Path(repo_info["path"])
else:
clone_path = _clone_repo_from_config(
"libmongocrypt", base_dir, config, is_flat_mode(config), verbose
)
if clone_path is None:
typer.echo("โ ๏ธ libmongocrypt not in config; cannot auto-build.", err=True)
return None
for suffix in ("libmongocrypt.dylib", "libmongocrypt.so"):
artifact = clone_path / "cmake-build" / suffix
if artifact.exists():
return artifact
group = repo_info["group"] if repo_info else "pymongocrypt"
build_cmds = get_build_commands(config, group, "libmongocrypt")
if not build_cmds:
typer.echo("โ ๏ธ No build_commands configured for libmongocrypt.", err=True)
return None
typer.echo(
f"๐จ Building libmongocrypt at {clone_path} (this can take a few minutes)..."
)
if not run_build_commands(clone_path, build_cmds, verbose=verbose):
typer.echo("โ libmongocrypt build failed", err=True)
return None
for suffix in ("libmongocrypt.dylib", "libmongocrypt.so"):
artifact = clone_path / "cmake-build" / suffix
if artifact.exists():
return artifact
return None
def _create_pyproject_toml(
project_path: Path,
project_name: str,
settings_path: str = "settings.base",
wagtail: bool = False,
qe: bool = False,
):
"""Create a pyproject.toml file for the Django project."""
base_deps = [
'"django-debug-toolbar"',
'"django-mongodb-backend"',
'"django-mongodb-extensions"',
'"python-webpack-boilerplate"',
]
if wagtail:
base_deps.append('"wagtail"')
if qe:
base_deps.append('"pymongocrypt"')
deps_str = ",\n ".join(base_deps)
pyproject_content = f"""[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "{project_name}"
version = "0.1.0"
description = "A Django project built with DBX Python CLI"
authors = [
{{name = "Your Name", email = "your.email@example.com"}},
]
dependencies = [
{deps_str},
]
[project.optional-dependencies]
dev = [
"django-debug-toolbar",
]
test = [
"pytest",
"pytest-django",
"ruff",
]
encryption = [
"pymongocrypt",
]
postgres = [
"dj-database-url",
"psycopg[binary]",
]
wagtail = [
"wagtail",
]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "{project_name}.{settings_path}"
python_files = ["tests.py", "test_*.py", "*_tests.py"]
[tool.setuptools]
packages = ["{project_name}"]
"""
pyproject_path = project_path / "pyproject.toml"
try:
pyproject_path.write_text(pyproject_content)
typer.echo(
f"โ
Created pyproject.toml for '{project_name}' with settings: {settings_path}"
)
except Exception as e:
typer.echo(f"โ ๏ธ Failed to create pyproject.toml: {e}", err=True)
def _enable_qe(project_path: Path, project_name: str) -> None:
"""Uncomment the QE block in settings."""
settings_file = project_path / project_name / "settings" / f"{project_name}.py"
if settings_file.exists():
content = settings_file.read_text()
content = content.replace(
"# from .qe import * # noqa\n"
"# INSTALLED_APPS += QE_INSTALLED_APPS # noqa: F405",
"from .qe import * # noqa\n"
"INSTALLED_APPS += QE_INSTALLED_APPS # noqa: F405",
)
settings_file.write_text(content)
def _add_frontend(
project_name: str,
directory: Path = Path("."),
python_path: str = None,
):
"""
Internal helper to create a frontend app inside an existing project.
Copies the frontend_template/app_name/ directory directly to
project_path/frontend/ โ no Django startapp needed since the template
contains no Django template variables.
"""
import shutil
project_path = directory / project_name
name = "frontend"
if not project_path.exists() or not project_path.is_dir():
typer.echo(f"โ Project '{project_name}' not found at {project_path}", err=True)
raise typer.Exit(code=1)
# Destination for new app
app_path = project_path / name
if app_path.exists():
typer.echo(
f"โ App '{name}' already exists in project '{project_name}'", err=True
)
raise typer.Exit(code=1)
typer.echo(f"๐ฆ Creating app '{name}' in project '{project_name}'")
with resources.path(
"dbx_python_cli.templates", "frontend_template"
) as template_path:
src = Path(template_path) / "app_name"
shutil.copytree(src, app_path)
def _drop_project_databases(proj, python_path: str) -> None:
"""Drop all MongoDB databases declared in the project's Django settings."""
import json as _json
from dbx_python_cli.commands.mongodb import ensure_mongodb
env = os.environ.copy()
env["DJANGO_SETTINGS_MODULE"] = f"{proj.name}.settings.{proj.name}"
env["PYTHONPATH"] = str(proj.project_path)
try:
env = ensure_mongodb(env)
except Exception:
pass
script = "\n".join(
[
"import json, django, pymongo",
"django.setup()",
"from django.conf import settings",
"dropped = []",
"for cfg in settings.DATABASES.values():",
" if 'django_mongodb_backend' in cfg.get('ENGINE', '') and cfg.get('NAME'):",
" pymongo.MongoClient(cfg['HOST']).drop_database(cfg['NAME'])",
" dropped.append(cfg['NAME'])",
"print(json.dumps(dropped))",
]
)
result = subprocess.run(
[python_path, "-c", script],
cwd=proj.project_path,
env=env,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
stderr = result.stderr.strip().splitlines()
msg = stderr[-1] if stderr else "unknown error"
typer.echo(f"โ ๏ธ Database drop script failed: {msg}", err=True)
return
try:
dropped = _json.loads(result.stdout)
except ValueError:
return
for name in dropped:
typer.echo(f"๐๏ธ Dropped database '{name}'")
[docs]
@app.command("remove")
def remove_project(
name: str = typer.Argument(None, help="Project name (defaults to newest project)"),
directory: Path = typer.Option(
None,
"--directory",
"-d",
help="Custom directory where the project is located (defaults to base_dir/projects/)",
),
):
"""Delete a Django project by name.
If no project name is provided, removes the most recently created project.
This will first attempt to uninstall the project package using pip in the
current Python environment, then remove the project directory.
Examples::
dbx project remove # Remove newest project
dbx project remove myproject # Remove specific project
"""
# Resolve project path (don't require exists - we check manually for better error message)
proj = resolve_project_path(name, directory, require_exists=False)
if not proj.project_path.exists() or not proj.project_path.is_dir():
typer.echo(
f"โ Project {proj.name} does not exist at {proj.project_path}.", err=True
)
return
# Refuse to remove directories that are tracked repos in config
_config = get_config()
if proj.name in _get_config_repo_names(_config):
typer.echo(
f"โ '{proj.name}' is a tracked repo in config โ use 'dbx repo' commands instead.",
err=True,
)
return
# Drop MongoDB databases associated with the project (non-fatal)
try:
python_path, _ = get_django_python_path(proj, directory)
_drop_project_databases(proj, python_path)
except Exception as e:
typer.echo(f"โ ๏ธ Could not drop databases: {e}", err=True)
# Try to uninstall the package from the current environment before
# removing the project directory. Failures here are non-fatal so that
# filesystem cleanup still proceeds.
uninstall_cmd = [sys.executable, "-m", "pip", "uninstall", "-y", proj.name]
typer.echo(f"๐ฆ Uninstalling project package '{proj.name}' with pip")
try:
result = subprocess.run(uninstall_cmd, check=False)
if result.returncode != 0:
typer.echo(
f"โ ๏ธ pip uninstall exited with code {result.returncode}. "
"Proceeding to remove project files.",
err=True,
)
except FileNotFoundError:
typer.echo(
"โ ๏ธ Could not run pip to uninstall the project package. "
"Proceeding to remove project files.",
err=True,
)
shutil.rmtree(proj.project_path)
typer.echo(f"๐๏ธ Removed project {proj.name}")
# If using default projects directory, check if it's now empty and remove it
# Skip in flat mode โ projects_dir IS base_dir and must never be deleted
_config = get_config()
if (
directory is None
and proj.projects_dir is not None
and not is_flat_mode(_config)
):
# Check if projects_dir is empty (no directories with pyproject.toml)
remaining_projects = []
if proj.projects_dir.exists():
for item in proj.projects_dir.iterdir():
if item.is_dir() and (item / "manage.py").exists():
remaining_projects.append(item)
# If no projects remain, remove the projects directory
if not remaining_projects and proj.projects_dir.exists():
# Check if directory is completely empty or only has hidden files
all_items = list(proj.projects_dir.iterdir())
if not all_items:
shutil.rmtree(proj.projects_dir)
typer.echo(f"๐๏ธ Removed empty projects directory: {proj.projects_dir}")
[docs]
@app.command("run")
def run_project(
ctx: typer.Context,
name: str = typer.Argument(None, help="Project name (defaults to newest project)"),
directory: Path = typer.Option(
None,
"--directory",
"-d",
help="Custom directory where the project is located (defaults to base_dir/projects/)",
),
host: str = typer.Option(
"127.0.0.1",
"--host",
help="Host to bind the Django server to",
),
port: int = typer.Option(
8000,
"--port",
"-p",
help="Port to bind the Django server to",
),
settings: str = typer.Option(
None,
"--settings",
"-s",
help="Settings configuration name to use (defaults to project name, e.g., 'base', 'qe')",
),
):
"""
Run a Django project with manage.py runserver.
If no project name is provided, runs the most recently created project.
If a frontend directory exists, it will be run automatically alongside the Django server.
Examples::
dbx project run # Run newest project
dbx project run myproject
dbx project run myproject --settings base
dbx project run myproject -s qe --port 8080
"""
import signal
verbose = ctx.obj.get("verbose", False) if ctx.obj else False
# Resolve project path and get venv
proj = resolve_project_path(name, directory)
# Refuse to run directories that are tracked repos in config
_config = get_config()
if proj.name in _get_config_repo_names(_config):
typer.echo(
f"โ '{proj.name}' is a tracked repo in config โ use 'dbx repo' commands instead.",
err=True,
)
raise typer.Exit(1)
python_path, venv_type = get_django_python_path(proj, directory)
# Always sync project dependencies before starting to ensure all declared
# dependencies (including ones added after the project was first installed)
# are present in the venv. uv pip install is fast and idempotent.
pyproject_path = proj.project_path / "pyproject.toml"
if pyproject_path.exists():
typer.echo(f"๐ฆ Syncing project dependencies for '{proj.name}'...")
install_result = install_package(
proj.project_path,
python_path,
install_dir=None,
extras=None,
groups=None,
verbose=verbose,
)
if install_result == "failed":
typer.echo(
f"โ ๏ธ Warning: Failed to install project '{proj.name}'. Some dependencies may be missing.",
err=True,
)
# Fix any declared dependency that has a stale editable-install dist-info.
# Scoped to this project's pyproject.toml so removed projects in the same
# venv are never accidentally reinstalled.
_fix_broken_editable_installs(python_path, proj.project_path, verbose)
# Ensure django_mongodb_extensions is available: prefer a local clone over PyPI.
_ensure_package_installed(
"django_mongodb_extensions", python_path, proj.base_dir, verbose
)
# Check if frontend exists
frontend_path = proj.project_path / "frontend"
has_frontend = frontend_path.exists() and (frontend_path / "package.json").exists()
# Ensure the frontend build directory exists so Django's staticfiles system
# check (W004) doesn't fire before webpack has had a chance to create it.
if has_frontend:
(frontend_path / "build").mkdir(exist_ok=True)
typer.echo(f"๐ Running project '{proj.name}' on http://{host}:{port}")
# Set up environment
env = setup_django_command_env(proj, ctx, settings=settings)
# Prepend venv bin dir to PATH so the correct manage.py / Django runtime is used
venv_bin = str(Path(python_path).parent)
env["PATH"] = f"{venv_bin}{os.pathsep}{env.get('PATH', '')}"
migrate_env = setup_django_command_env(
proj, ctx, settings=settings, include_dyld_fallback=False
)
# Generate migrations for Wagtail apps only when this project declares
# wagtail as a dependency (requirements.txt or wagtail_urls/ presence),
# not just because wagtail happens to be installed in the shared venv.
_req_txt = proj.project_path / "requirements.txt"
_project_uses_wagtail = (
_req_txt.exists() and "wagtail" in _req_txt.read_text()
) or any(True for _ in proj.project_path.rglob("wagtail_urls") if _.is_dir())
if _project_uses_wagtail:
typer.echo("โ๏ธ Generating Wagtail migrations...")
subprocess.run(
[python_path, "-m", "django", "makemigrations"],
cwd=proj.project_path,
env=migrate_env,
check=False,
capture_output=not verbose,
text=True,
)
# Run migrations before starting server
typer.echo(f"๐๏ธ Running migrations for project '{proj.name}'")
result = subprocess.run(
[python_path, "-m", "django", "migrate"],
cwd=proj.project_path,
env=migrate_env,
check=False,
capture_output=not verbose,
text=True,
)
if result.returncode != 0:
typer.echo(f"โ Migrations failed with exit code {result.returncode}", err=True)
if verbose and result.stderr:
typer.echo(result.stderr, err=True)
elif not verbose and result.stderr:
# Show the last few non-blank lines so the real error isn't hidden
# behind a warning printed earlier in the same stderr stream.
lines = [ln for ln in result.stderr.strip().splitlines() if ln.strip()]
for line in lines[-3:]:
typer.echo(f" {line}", err=True)
raise typer.Exit(code=result.returncode)
typer.echo("โ
Migrations completed successfully")
# Migrate the encrypted database if the project has one configured (e.g. QE projects)
has_encrypted_db = subprocess.run(
[
python_path,
"-c",
"import django; django.setup(); from django.conf import settings; "
"exit(0 if 'encrypted' in settings.DATABASES else 1)",
],
cwd=proj.project_path,
env=migrate_env,
check=False,
capture_output=True,
)
if has_encrypted_db.returncode == 0:
from dbx_python_cli.utils.project import validate_qe_env
validate_qe_env(get_config(), base_dir=proj.base_dir, fatal=True)
typer.echo("๐ Running migrations for encrypted database")
enc_result = subprocess.run(
[python_path, "-m", "django", "migrate", "--database", "encrypted"],
cwd=proj.project_path,
env=migrate_env,
check=False,
capture_output=not verbose,
text=True,
)
if enc_result.returncode != 0:
typer.echo("โ ๏ธ Encrypted database migration failed", err=True)
if enc_result.stderr:
lines = [
ln for ln in enc_result.stderr.strip().splitlines() if ln.strip()
]
for line in lines[-3:]:
typer.echo(f" {line}", err=True)
else:
typer.echo("โ
Encrypted database migrations completed successfully")
# Create initial Wagtail data (root page + default site) if the project
# ships an `init` management command (wagtail-mongodb-project convention).
init_cmd = next(
(
p
for p in proj.project_path.rglob("management/commands/init.py")
if "__pycache__" not in str(p)
),
None,
)
if init_cmd:
typer.echo("โ๏ธ Running manage.py init...")
subprocess.run(
[python_path, "-m", "django", "init"],
cwd=proj.project_path,
env=migrate_env,
check=False,
capture_output=not verbose,
text=True,
)
# Create superuser (non-fatal if already exists)
su_email = os.getenv("PROJECT_EMAIL", "admin@example.com")
typer.echo("๐ Creating Django superuser 'admin'")
su_env = setup_django_command_env(
proj, ctx, settings=settings, include_dyld_fallback=False
)
su_env["DJANGO_SUPERUSER_PASSWORD"] = "admin"
su_result = subprocess.run(
[
python_path,
"-m",
"django",
"createsuperuser",
"--noinput",
"--username=admin",
f"--email={su_email}",
],
cwd=proj.project_path,
env=su_env,
capture_output=not verbose,
)
if su_result.returncode == 0:
typer.echo("โ
Superuser 'admin' created successfully")
else:
typer.echo("โน๏ธ Superuser 'admin' already exists, skipping")
if has_frontend:
# Ensure frontend is installed
typer.echo("๐ฆ Checking frontend dependencies...")
try:
_install_npm(proj.name, directory=proj.project_path.parent)
except Exception as e:
typer.echo(f"โ ๏ธ Frontend installation check failed: {e}", err=True)
# Continue anyway - frontend might already be installed
# Start frontend process in background
typer.echo("๐จ Starting frontend development server...")
frontend_proc = subprocess.Popen(
["npm", "run", "watch"],
cwd=frontend_path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# Handle CTRL-C to kill both processes
def signal_handler(signum, frame):
typer.echo("\n๐ Stopping servers...")
frontend_proc.terminate()
raise KeyboardInterrupt
signal.signal(signal.SIGINT, signal_handler)
try:
typer.echo("๐ Starting Django development server...")
subprocess.run(
[python_path, "manage.py", "runserver", f"{host}:{port}"],
cwd=proj.project_path,
env=env,
check=True,
)
except KeyboardInterrupt:
typer.echo("\nโ
Servers stopped")
finally:
if frontend_proc.poll() is None:
frontend_proc.terminate()
frontend_proc.wait()
else:
# No frontend - just run Django
try:
typer.echo("๐ Starting Django development server...")
subprocess.run(
[python_path, "manage.py", "runserver", f"{host}:{port}"],
cwd=proj.project_path,
env=env,
check=True,
)
except KeyboardInterrupt:
typer.echo("\nโ
Server stopped")
[docs]
@app.command("open")
def open_browser(
host: str = typer.Option("localhost", "--host", "-h", help="Host to open"),
port: int = typer.Option(8000, "--port", "-p", help="Port to open"),
):
"""
Open localhost in the browser.
Examples::
dbx project open # Opens http://localhost:8000
dbx project open --port 3000 # Opens http://localhost:3000
dbx project open --host 127.0.0.1 # Opens http://127.0.0.1:8000
"""
import webbrowser
url = f"http://{host}:{port}"
typer.echo(f"๐ Opening {url} in browser...")
try:
webbrowser.open(url)
typer.echo(f"โ
Opened {url}")
except Exception as e:
typer.echo(f"โ Failed to open browser: {e}", err=True)
raise typer.Exit(code=1)
[docs]
@app.command("manage")
def manage(
ctx: typer.Context,
name: str = typer.Argument(None, help="Project name (defaults to newest project)"),
command: str = typer.Argument(None, help="Django management command to run"),
args: list[str] = typer.Argument(None, help="Additional arguments for the command"),
directory: Path = typer.Option(
None,
"--directory",
"-d",
help="Custom directory where the project is located (defaults to base_dir/projects/)",
),
mongodb_uri: str = typer.Option(
None, "--mongodb-uri", help="MongoDB connection URI"
),
database: str = typer.Option(
None,
"--database",
help="Specify the database to use",
),
settings: str = typer.Option(
None,
"--settings",
"-s",
help="Settings configuration name to use (defaults to project name)",
),
):
"""
Run any Django management command for a project.
If no project name is provided, uses the most recently created project.
Examples::
dbx project manage shell # Run shell on newest project
dbx project manage myproject shell
dbx project manage myproject createsuperuser
dbx project manage myproject --mongodb-uri mongodb+srv://user:pwd@cluster
dbx project manage myproject --settings base shell
dbx project manage myproject migrate --database default
"""
if args is None:
args = []
# Resolve project path and get venv
proj = resolve_project_path(name, directory)
python_path, venv_type = get_django_python_path(proj, directory)
# Set up environment
env = setup_django_command_env(
proj, ctx, mongodb_uri=mongodb_uri, settings=settings
)
# Build command - use manage.py so project-specific management commands are discovered
manage_py = proj.project_path / "manage.py"
cmd_args = []
if command:
cmd_args.append(command)
if database:
cmd_args.append(f"--database={database}")
cmd_args.extend(args)
typer.echo(f"โ๏ธ Running: {python_path} manage.py {' '.join(cmd_args)}")
else:
typer.echo(f"โน๏ธ Running: {python_path} manage.py")
try:
subprocess.run(
[python_path, str(manage_py), *cmd_args],
cwd=proj.project_path,
env=env,
check=True,
)
except subprocess.CalledProcessError as e:
typer.echo(f"โ Command failed with exit code {e.returncode}", err=True)
raise typer.Exit(code=e.returncode)
except FileNotFoundError:
typer.echo(
f"โ Python not found at '{python_path}'. Make sure the venv exists.",
err=True,
)
raise typer.Exit(code=1)
[docs]
@app.command("su")
def create_superuser(
ctx: typer.Context,
name: str = typer.Argument(None, help="Project name (defaults to newest project)"),
directory: Path = typer.Option(
None,
"--directory",
"-d",
help="Custom directory where the project is located (defaults to base_dir/projects/)",
),
username: str = typer.Option(
"admin", "--username", "-u", help="Superuser username"
),
password: str = typer.Option(
"admin", "--password", "-p", help="Superuser password"
),
email: str = typer.Option(
None,
"--email",
"-e",
help="Superuser email (defaults to $PROJECT_EMAIL if set)",
),
mongodb_uri: str = typer.Option(
None,
"--mongodb-uri",
help="Optional MongoDB connection URI. Falls back to $MONGODB_URI if not provided.",
),
settings: str = typer.Option(
None,
"--settings",
"-s",
help="Settings configuration name to use (defaults to project name)",
),
):
"""
Create a Django superuser with no interaction required.
If no project name is provided, uses the most recently created project.
Examples::
dbx project su # Create superuser on newest project
dbx project su myproject
dbx project su myproject --settings base
dbx project su myproject -u myuser -p mypass
dbx project su myproject -e admin@example.com
"""
if not email:
email = os.getenv("PROJECT_EMAIL", "admin@example.com")
# Resolve project path and get venv
proj = resolve_project_path(name, directory)
python_path, venv_type = get_django_python_path(proj, directory)
typer.echo(f"๐ Creating Django superuser '{username}' for project '{proj.name}'")
# Set up environment (without DYLD_FALLBACK_LIBRARY_PATH for su command)
env = setup_django_command_env(
proj,
ctx,
mongodb_uri=mongodb_uri,
settings=settings,
include_dyld_fallback=False,
)
env["DJANGO_SUPERUSER_PASSWORD"] = password
# Use python -m django to ensure we use the correct venv's Django
try:
subprocess.run(
[
python_path,
"-m",
"django",
"createsuperuser",
"--noinput",
f"--username={username}",
f"--email={email}",
],
cwd=proj.project_path,
env=env,
check=True,
)
typer.echo(f"โ
Superuser '{username}' created successfully")
except subprocess.CalledProcessError as e:
typer.echo(f"โ Command failed with exit code {e.returncode}", err=True)
raise typer.Exit(code=e.returncode)
except FileNotFoundError:
typer.echo(
f"โ Python not found at '{python_path}'. Make sure the venv exists.",
err=True,
)
raise typer.Exit(code=1)
[docs]
@app.command("migrate")
def migrate_project(
ctx: typer.Context,
name: str = typer.Argument(None, help="Project name (defaults to newest project)"),
directory: Path = typer.Option(
None,
"--directory",
"-d",
help="Custom directory where the project is located (defaults to base_dir/projects/)",
),
database: str = typer.Option(
None,
"--database",
help="Specify the database to migrate (e.g., 'default', 'encrypted')",
),
mongodb_uri: str = typer.Option(
None,
"--mongodb-uri",
help="Optional MongoDB connection URI. Falls back to $MONGODB_URI if not provided.",
),
settings: str = typer.Option(
None,
"--settings",
"-s",
help="Settings configuration name to use (defaults to project name)",
),
):
"""
Run Django migrations on a project.
If no project name is provided, uses the most recently created project.
Examples::
dbx project migrate # Migrate newest project
dbx project migrate myproject
dbx project migrate myproject --settings base
dbx project migrate myproject --database encrypted
"""
# Resolve project path and get venv
proj = resolve_project_path(name, directory)
python_path, venv_type = get_django_python_path(proj, directory)
# If migrating the encrypted database, validate QE env vars up front
if database == "encrypted":
from dbx_python_cli.utils.project import validate_qe_env
validate_qe_env(get_config(), base_dir=proj.base_dir, fatal=True)
# Set up environment (without DYLD_FALLBACK_LIBRARY_PATH for migrate command)
env = setup_django_command_env(
proj,
ctx,
mongodb_uri=mongodb_uri,
settings=settings,
include_dyld_fallback=False,
)
# Build migrate command - use python -m django to ensure we use the correct venv's Django
cmd = [python_path, "-m", "django", "migrate"]
if database:
cmd.append(f"--database={database}")
typer.echo(f"๐๏ธ Running migrations for database: {database}")
else:
typer.echo(f"๐๏ธ Running migrations for project '{proj.name}'")
try:
subprocess.run(
cmd,
cwd=proj.project_path,
env=env,
check=True,
)
typer.echo("โ
Migrations completed successfully")
except subprocess.CalledProcessError as e:
typer.echo(f"โ Command failed with exit code {e.returncode}", err=True)
raise typer.Exit(code=e.returncode)
except FileNotFoundError:
typer.echo(
f"โ Python not found at '{python_path}'. Make sure the venv exists.",
err=True,
)
raise typer.Exit(code=1)
[docs]
@app.command("edit")
def edit_project(
name: str = typer.Argument(None, help="Project name (defaults to newest project)"),
directory: Path = typer.Option(
None,
"--directory",
"-d",
help="Custom directory where the project is located (defaults to base_dir/projects/)",
),
settings: str = typer.Option(
None,
"--settings",
"-s",
help="Settings configuration name to edit (e.g., 'base', 'qe', or project name). Defaults to project name.",
),
):
"""
Edit project settings files with your default editor.
Opens all files in the project's settings/ directory by default. Pass
--settings to open a single specific file instead.
Uses the editor from the EDITOR environment variable; falls back to vim,
nano, vi, or 'open' on macOS.
If no project name is provided, uses the most recently created project.
Examples::
dbx project edit # Edit all settings files
dbx project edit myproject # Edit myproject's settings files
dbx project edit myproject --settings base # Edit only base settings
dbx project edit myproject -s qe # Edit only qe settings
EDITOR=code dbx project edit # Open with VS Code
"""
# Resolve project path
proj = resolve_project_path(name, directory)
settings_dir = proj.project_path / proj.name / "settings"
if settings:
# Single file requested via --settings
settings_file = settings_dir / f"{settings}.py"
if not settings_file.exists():
typer.echo(f"โ Settings file not found: {settings_file}", err=True)
typer.echo(f"\nAvailable settings files in {settings_dir}:")
if settings_dir.exists():
for f in sorted(settings_dir.glob("*.py")):
if f.name != "__init__.py":
typer.echo(f" โข {f.stem}")
raise typer.Exit(code=1)
files_to_edit = [settings_file]
else:
# Open all settings files (excluding __init__.py)
if not settings_dir.exists():
typer.echo(f"โ Settings directory not found: {settings_dir}", err=True)
raise typer.Exit(code=1)
files_to_edit = sorted(
f for f in settings_dir.glob("*.py") if f.name != "__init__.py"
)
if not files_to_edit:
typer.echo(f"โ No settings files found in {settings_dir}", err=True)
raise typer.Exit(code=1)
# Get editor from environment variable
editor = os.environ.get("EDITOR")
if not editor:
# Try common editors in order of preference
common_editors = ["vim", "nano", "vi"]
for candidate in common_editors:
try:
subprocess.run(["which", candidate], check=True, capture_output=True)
editor = candidate
break
except subprocess.CalledProcessError:
continue
# If no common editor found, try 'open' on macOS
if not editor:
import platform
if platform.system() == "Darwin":
editor = "open"
else:
typer.echo(
"โ No editor found. Please set the EDITOR environment variable.",
err=True,
)
typer.echo("\nExample: export EDITOR=nano")
raise typer.Exit(1)
label = (
files_to_edit[0].name
if len(files_to_edit) == 1
else f"{len(files_to_edit)} settings files"
)
typer.echo(f"๐ Opening {label} with {editor}...")
try:
result = subprocess.run([editor] + [str(f) for f in files_to_edit])
if result.returncode == 0:
typer.echo("โ
Settings files saved")
else:
typer.echo(
f"โ ๏ธ Editor exited with code {result.returncode}",
err=True,
)
raise typer.Exit(result.returncode)
except FileNotFoundError:
typer.echo(
f"โ Editor '{editor}' not found. Please check your EDITOR environment variable.",
err=True,
)
raise typer.Exit(1)
except KeyboardInterrupt:
typer.echo("\nโ ๏ธ Editing cancelled")
raise typer.Exit(130)
def _install_npm(
project_name: str,
frontend_dir: str = "frontend",
directory: Path = Path("."),
clean: bool = False,
):
"""
Internal helper to install npm dependencies in the frontend directory.
"""
project_path = directory / project_name
if not project_path.exists():
typer.echo(
f"โ Project '{project_name}' does not exist at {project_path}", err=True
)
raise typer.Exit(code=1)
frontend_path = project_path / frontend_dir
if not frontend_path.exists():
typer.echo(
f"โ Frontend directory '{frontend_dir}' not found at {frontend_path}",
err=True,
)
raise typer.Exit(code=1)
package_json = frontend_path / "package.json"
if not package_json.exists():
typer.echo(f"โ package.json not found in {frontend_path}", err=True)
raise typer.Exit(code=1)
if clean:
typer.echo(f"๐งน Cleaning node_modules and package-lock.json in {frontend_path}")
node_modules = frontend_path / "node_modules"
package_lock = frontend_path / "package-lock.json"
if node_modules.exists():
shutil.rmtree(node_modules)
typer.echo(" โ Removed node_modules")
if package_lock.exists():
package_lock.unlink()
typer.echo(" โ Removed package-lock.json")
typer.echo(f"๐ฆ Installing npm dependencies in {frontend_path}")
try:
subprocess.run(["npm", "install"], cwd=frontend_path, check=True)
typer.echo("โ
Dependencies installed successfully")
except subprocess.CalledProcessError as e:
typer.echo(f"โ npm install failed with exit code {e.returncode}", err=True)
raise typer.Exit(code=e.returncode)
except FileNotFoundError:
typer.echo(
"โ npm not found. Please ensure Node.js and npm are installed.", err=True
)
raise typer.Exit(code=1)