diff --git a/README.md b/README.md index dcb783b..37a2b9e 100644 --- a/README.md +++ b/README.md @@ -18,23 +18,3 @@ python -m venv .venv source .venv/bin/activate just install ``` - -## Usage - -``` -Usage: dm [OPTIONS] COMMAND [ARGS]... - - Django MongoDB CLI - - System executable: - - /Users/alexclark/Developer/django-mongodb-cli/.venv/bin/python - -Options: - --help Show this message and exit. - -Commands: - app Create Django apps configured to test django-mongodb-backend. - proj Create Django projects configured to test django-mongodb-backend. - repo Run tests configured to test django-mongodb-backend. -``` diff --git a/django_mongodb_cli/__init__.py b/django_mongodb_cli/__init__.py index 2b764c1..c60b68d 100644 --- a/django_mongodb_cli/__init__.py +++ b/django_mongodb_cli/__init__.py @@ -1,23 +1,21 @@ -import click -import sys +import os +import typer -from .app import app -from .proj import proj from .repo import repo - -def get_help_text(): - help_text = """ - Django MongoDB CLI +help_text = ( """ - return f"\n\n{help_text.strip()}\n\nSystem executable:\n\n{sys.executable}\n" - +Django MongoDB CLI -@click.group(help=get_help_text()) -def cli(): - """Django MongoDB CLI""" +System executable: +""" + + os.sys.executable +) +dm = typer.Typer( + help=help_text, + add_completion=False, + context_settings={"help_option_names": ["-h", "--help"]}, +) -cli.add_command(app) -cli.add_command(proj) -cli.add_command(repo) +dm.add_typer(repo, name="repo") diff --git a/django_mongodb_cli/app.py b/django_mongodb_cli/app.py deleted file mode 100644 index b32e64f..0000000 --- a/django_mongodb_cli/app.py +++ /dev/null @@ -1,72 +0,0 @@ -import click -import os -import shutil -import subprocess - - -from .utils import get_management_command, random_app_name - - -class App: - def __init__(self): - self.config = {} - - def set_config(self, key, value): - self.config[key] = value - - def __repr__(self): - return f"" - - -pass_app = click.make_pass_decorator(App) - - -@click.group(invoke_without_command=True) -@click.pass_context -def app(context): - """ - Create Django apps configured to test django-mongodb-backend. - """ - context.obj = App() - - # Show help only if no subcommand is invoked - if context.invoked_subcommand is None: - click.echo(context.get_help()) - context.exit() - - -@app.command() -@click.argument("app_name", required=False) -def start(app_name): - """Run startapp with a custom template and move the app into ./apps/.""" - - if not app_name: - app_name = random_app_name() - - if not app_name.isidentifier(): - raise click.UsageError( - f"App name '{app_name}' is not a valid Python identifier." - ) - - temp_path = app_name # Django will create the app here temporarily - target_path = os.path.join("apps", app_name) - - click.echo(f"Creating app '{app_name}' in ./apps") - - # Make sure ./apps exists - os.makedirs("apps", exist_ok=True) - - # Run the Django startapp command - command = get_management_command("startapp") - subprocess.run( - command - + [ - temp_path, - "--template", - os.path.join("templates", "app_template"), - ], - check=True, - ) - - # Move the generated app into ./apps/ - shutil.move(temp_path, target_path) diff --git a/django_mongodb_cli/proj.py b/django_mongodb_cli/proj.py deleted file mode 100644 index acbf09a..0000000 --- a/django_mongodb_cli/proj.py +++ /dev/null @@ -1,173 +0,0 @@ -import click -import os -import shutil -import subprocess - - -from .utils import DELETE_DIRS_AND_FILES, get_management_command - - -class Proj: - def __init__(self): - self.config = {} - - def set_config(self, key, value): - self.config[key] = value - - def __repr__(self): - return f"" - - -pass_proj = click.make_pass_decorator(Proj) - - -@click.group(invoke_without_command=True) -@click.option("-d", "--delete", is_flag=True, help="Delete existing project files") -@click.pass_context -def proj(context, delete): - """ - Create Django projects configured to test django-mongodb-backend. - """ - context.obj = Proj() - - if delete: - for item, check_function in DELETE_DIRS_AND_FILES.items(): - if check_function(item): - if os.path.isdir(item): - shutil.rmtree(item) - click.echo(f"Removed directory: {item}") - elif os.path.isfile(item): - os.remove(item) - click.echo(f"Removed file: {item}") - else: - click.echo(f"Skipping: {item} does not exist") - return - - # Show help only if no subcommand is invoked - if context.invoked_subcommand is None: - click.echo(context.get_help()) - context.exit() - - -@proj.command(context_settings={"ignore_unknown_options": True}) -@click.argument("args", nargs=-1) -def manage(args): - """Run management commands.""" - - command = get_management_command() - - subprocess.run(command + [*args]) - - -@proj.command() -def run(): - """Start the Django development server.""" - - if os.environ.get("MONGODB_URI"): - click.echo(os.environ["MONGODB_URI"]) - - command = get_management_command() - - # Start npm install - subprocess.run(["npm", "install"], cwd="frontend") - - # Start npm run watch - npm_process = subprocess.Popen(["npm", "run", "watch"], cwd="frontend") - - # Start django-admin runserver - django_process = subprocess.Popen(command + ["runserver"]) - - # Wait for both processes to complete - npm_process.wait() - django_process.wait() - - -@proj.command() -@click.option("-dj", "--django", is_flag=True, help="Use django mongodb template") -@click.option("-w", "--wagtail", is_flag=True, help="Use wagtail mongodb template") -@click.argument("project_name", required=False, default="backend") -def start( - django, - wagtail, - project_name, -): - """Run Django's `startproject` with custom templates.""" - if os.path.exists("manage.py"): - click.echo("manage.py already exists") - return - template = None - django_admin = "django-admin" - startproject = "startproject" - startapp = "startapp" - if wagtail: - template = os.path.join(os.path.join("src", "wagtail-mongodb-project")) - django_admin = "wagtail" - startproject = "start" - elif django: - template = os.path.join(os.path.join("src", "django-mongodb-project")) - if not template: - template = os.path.join(os.path.join("templates", "project_template")) - click.echo(f"Using template: {template}") - subprocess.run( - [ - django_admin, - startproject, - project_name, - ".", - "--template", - template, - ] - ) - frontend_template = os.path.join("templates", "frontend_template") - click.echo(f"Using template: {frontend_template}") - subprocess.run( - [ - django_admin, - startproject, - "frontend", - ".", - "--template", - frontend_template, - ] - ) - if not wagtail: - home_template = os.path.join("templates", "home_template") - click.echo(f"Using template: {home_template}") - subprocess.run( - [ - django_admin, - startapp, - "home", - "--template", - home_template, - ] - ) - - -@proj.command() -def su(): - """Create a superuser with the username 'admin' and the email from git config.""" - try: - user_email = subprocess.check_output( - ["git", "config", "user.email"], text=True - ).strip() - except subprocess.CalledProcessError: - click.echo("Error: Unable to retrieve the user email from git config.") - return - - os.environ["DJANGO_SUPERUSER_PASSWORD"] = "admin" - - if os.environ.get("MONGODB_URI"): - click.echo(os.environ["MONGODB_URI"]) - click.echo(f"User email: {user_email}") - - command = get_management_command("createsuperuser") - - subprocess.run( - command - + [ - "--noinput", - "--username=admin", - f"--email={user_email}", - ] - ) diff --git a/django_mongodb_cli/repo.py b/django_mongodb_cli/repo.py index 838d7a0..66830b8 100644 --- a/django_mongodb_cli/repo.py +++ b/django_mongodb_cli/repo.py @@ -1,452 +1,153 @@ -import os -import subprocess +import typer -import click -from rich import print as rprint -from black import format_str, Mode +from .utils import Repo, Test -from .settings import test_settings_map -from .utils import ( - clone_repo, - copy_mongo_apps, - copy_mongo_migrations, - copy_mongo_settings, - get_management_command, - get_repos, - get_status, - get_repo_name_map, - install_package, -) - - -class Repo: - def __init__(self): - self.home = "src" - self.config = {} - - def set_config(self, key, value): - self.config[key] = value - - def __repr__(self): - return f"" - - -pass_repo = click.make_pass_decorator(Repo) - - -@click.group(invoke_without_command=True) -@click.option( - "-l", - "--list-repos", - is_flag=True, - help="List all repositories in `pyproject.toml`.", -) -@click.pass_context -def repo(context, list_repos): - """ - Run tests configured to test django-mongodb-backend. - """ - context.obj = Repo() - repos, url_pattern, branch_pattern = get_repos("pyproject.toml") - if list_repos: - for repo_entry in repos: - click.echo(repo_entry) - return - if context.invoked_subcommand is None: - click.echo(context.get_help()) +repo = typer.Typer() @repo.command() -@click.argument("repo_names", nargs=-1, required=False) -@click.option( - "-a", - "--all-repos", - is_flag=True, - help="Clone all repositories listed in pyproject.toml.", -) -@click.option( - "-i", - "--install", - is_flag=True, - help="Install repository after cloning.", -) -@click.pass_context -@pass_repo -def clone(repo, context, repo_names, all_repos, install): - """Clone and optionally install repositories from pyproject.toml.""" - repos, url_pattern, branch_pattern = get_repos("pyproject.toml") - repo_name_map = get_repo_name_map(repos, url_pattern) - - # If specific repo names are given - if repo_names: - not_found = [] - for name in repo_names: - repo_url = repo_name_map.get(name) - if repo_url: - clone_repo(repo_url, url_pattern, branch_pattern, repo) - if install: - clone_path = os.path.join(context.obj.home, name) - if os.path.exists(clone_path): - install_package(clone_path) - else: - not_found.append(name) - if not_found: - for name in not_found: - click.echo(f"Repository '{name}' not found.") - return - - # If -a/--all-repos is given +def status( + repo_name: str = typer.Argument(None), + all_repos: bool = typer.Option( + False, "--all-repos", "-a", help="Show status of all repos" + ), +): + """Show the status of the specified Git repository.""" if all_repos: - click.echo(f"Cloning {len(repos)} repositories...") - for name, repo_url in repo_name_map.items(): - clone_repo(repo_url, url_pattern, branch_pattern, repo) - if install: - clone_path = os.path.join(context.obj.home, name) - if os.path.exists(clone_path): - install_package(clone_path) - return - - # If no args/options, show help - click.echo(context.get_help()) + typer.echo( + typer.style("Showing status for all repositories...", fg=typer.colors.CYAN) + ) + for repo_name in Repo().map: + Repo().get_repo_status(repo_name) + elif repo_name: + Repo().get_repo_status(repo_name) + else: + typer.echo( + typer.style( + "Please specify a repository name or use --all-repos to show all repositories.", + fg=typer.colors.YELLOW, + ) + ) @repo.command() -@click.argument("repo_names", nargs=-1) -@click.option("-a", "--all-repos", is_flag=True, help="Install all repositories") -@click.pass_context -@pass_repo -def install(repo, context, repo_names, all_repos): - """Install repositories (like 'clone -i').""" - repos, url_pattern, _ = get_repos("pyproject.toml") - repo_name_map = get_repo_name_map(repos, url_pattern) - - if all_repos and repo_names: - click.echo("Cannot specify both repo names and --all-repos") - return - - # If -a/--all-repos is given +def clone( + repo_name: str = typer.Argument(None), + all_repos: bool = typer.Option( + False, "--all-repos", "-a", help="Clone all repositories" + ), + install: bool = typer.Option( + False, "--install", "-i", help="Install after cloning" + ), +): + """Clone the specified Git repository.""" if all_repos: - click.echo(f"Updating {len(repo_name_map)} repositories...") - for repo_name, repo_url in repo_name_map.items(): - clone_path = os.path.join(context.obj.home, repo_name) - if os.path.exists(clone_path): - install_package(clone_path) - return - - # If specific repo names are given - if repo_names: - not_found = [] - for repo_name in repo_names: - clone_path = os.path.join(context.obj.home, repo_name) - if os.path.exists(clone_path): - install_package(clone_path) - else: - not_found.append(repo_name) - for name in not_found: - click.echo(f"Repository '{name}' not found.") - return - - click.echo(context.get_help()) - - -@repo.command(context_settings={"ignore_unknown_options": True}) -@click.argument("repo_name", required=False) -@click.argument("args", nargs=-1) -@click.pass_context -def makemigrations(context, repo_name, args): - """Run `makemigrations` for a cloned repository.""" - repos, url_pattern, _ = get_repos("pyproject.toml") - - if repo_name: - repo_name_map = get_repo_name_map(repos, url_pattern) - # Check if repo exists - if repo_name not in repo_name_map or repo_name not in test_settings_map: - click.echo(click.style(f"Repository '{repo_name}' not found.", fg="red")) - return - - # Try settings copy - try: - copy_mongo_apps(repo_name) - copy_mongo_settings( - test_settings_map[repo_name]["settings"]["migrations"]["source"], - test_settings_map[repo_name]["settings"]["migrations"]["target"], + typer.echo(typer.style("Cloning all repositories...", fg=typer.colors.CYAN)) + for repo_name in Repo().map: + Repo().clone_repo(repo_name) + if install: + Repo().install_package(repo_name) + elif repo_name: + Repo().clone_repo(repo_name) + if install: + Repo().install_package(repo_name) + else: + typer.echo( + typer.style( + "Please specify a repository name or use --all-repos to clone all repositories.", + fg=typer.colors.YELLOW, ) - except FileNotFoundError: - click.echo(click.style(f"Settings for '{repo_name}' not found.", fg="red")) - return - - command = get_management_command("makemigrations") - command.extend( - [ - "--settings", - test_settings_map[repo_name]["settings"]["module"]["migrations"], - ] ) - if repo_name != "django-filter": - command.extend( - [ - "--pythonpath", - os.path.join(os.getcwd(), test_settings_map[repo_name]["test_dir"]), - ] - ) - if args: - command.extend(args) - click.echo(f"Running command: {' '.join(command)}") - subprocess.run(command) - return - - # No repo_name provided, show help - click.echo(context.get_help()) @repo.command() -@click.argument("repo_names", nargs=-1) -@click.option("-a", "--all-repos", is_flag=True, help="Status for all repositories") -@click.option("-r", "--reset", is_flag=True, help="Reset") -@click.option("-d", "--diff", is_flag=True, help="Show diff") -@click.option("-b", "--branch", is_flag=True, help="Show branch") -@click.option("-u", "--update", is_flag=True, help="Update repos") -@click.option("-l", "--log", is_flag=True, help="Show log") -@click.pass_context -@pass_repo -def status(repo, context, repo_names, all_repos, reset, diff, branch, update, log): - """Repository status.""" - repos, url_pattern, _ = get_repos("pyproject.toml") - repo_name_map = get_repo_name_map(repos, url_pattern) - - # Status for specified repo names - if repo_names: - not_found = [] - for repo_name in repo_names: - repo_url = repo_name_map.get(repo_name) - if repo_url: - get_status( - repo_url, - url_pattern, - repo, - reset=reset, - diff=diff, - branch=branch, - update=update, - log=log, - ) - else: - not_found.append(repo_name) - for name in not_found: - click.echo(f"Repository '{name}' not found.") - return - - # Status for all repos +def install( + repo_name: str = typer.Argument(None), + all_repos: bool = typer.Option( + False, "--all-repos", "-a", help="Install all repositories" + ), +): + """Install the specified Git repository.""" if all_repos: - click.echo(f"Status of {len(repos)} repositories...") - for repo_name, repo_url in repo_name_map.items(): - get_status( - repo_url, - url_pattern, - repo, - reset=reset, - diff=diff, - branch=branch, - update=update, - log=log, + typer.echo(typer.style("Installing all repositories...", fg=typer.colors.CYAN)) + for repo_name in Repo().map: + Repo().install_package(repo_name) + elif repo_name: + Repo().install_package(repo_name) + else: + typer.echo( + typer.style( + "Please specify a repository name or use --all-repos to install all repositories.", + fg=typer.colors.YELLOW, ) - return - - # Show help if nothing selected - click.echo(context.get_help()) + ) @repo.command() -@click.argument("repo_name", required=False) -@click.argument("modules", nargs=-1) -@click.option("-k", "--keyword", help="Filter tests by keyword") -@click.option("-l", "--list-tests", is_flag=True, help="List tests") -@click.option("-s", "--show-settings", is_flag=True, help="Show settings") -@click.option("-a", "--all-repos", is_flag=True, help="All repos") -@click.option("--keepdb", is_flag=True, help="Keep db") -@click.pass_context -def test( - context, repo_name, modules, keyword, list_tests, show_settings, keepdb, all_repos +def sync( + repo_name: str = typer.Argument(None), + all_repos: bool = typer.Option( + False, "--all-repos", "-a", help="Sync all repositories" + ), ): - """Run tests for Django fork and third-party libraries.""" - repos, url_pattern, _ = get_repos("pyproject.toml") - repo_name_map = get_repo_name_map(repos, url_pattern) - - if repo_name: - if repo_name not in repo_name_map or repo_name not in test_settings_map: - click.echo( - click.style( - f"Repository/settings for '{repo_name}' not found.", fg="red" - ) - ) - return - - if show_settings: - click.echo(f"āš™ļø Test settings for šŸ“¦ {repo_name}:") - settings_dict = dict(sorted(test_settings_map[repo_name].items())) - formatted = format_str(str(settings_dict), mode=Mode()) - rprint(formatted) - return - - settings = test_settings_map[repo_name] - test_dirs = settings.get("test_dirs", []) - - if list_tests: - for test_dir in test_dirs: - click.echo(f"šŸ“‚ {test_dir}") - try: - test_modules = sorted(os.listdir(test_dir)) - for module in test_modules: - if module not in ("__pycache__", "__init__.py"): - click.echo(click.style(f" └── {module}", fg="green")) - click.echo() - except FileNotFoundError: - click.echo( - click.style(f"Directory '{test_dir}' not found.", fg="red") - ) - return - - # Prepare and copy settings, etc. - if "settings" in settings: - repo_dir = os.path.join(context.obj.home, repo_name) - if not os.path.exists(repo_dir): - click.echo( - click.style( - f"Repository '{repo_name}' not found on disk.", fg="red" - ) - ) - return - copy_mongo_settings( - settings["settings"]["test"]["source"], - settings["settings"]["test"]["target"], - ) - else: - click.echo(click.style(f"Settings for '{repo_name}' not found.", fg="red")) - return - - command = [settings["test_command"]] - copy_mongo_migrations(repo_name) - copy_mongo_apps(repo_name) - - if ( - settings["test_command"] == "./runtests.py" - and repo_name != "django-rest-framework" - ): - command.extend( - [ - "--settings", - settings["settings"]["module"]["test"], - "--parallel", - "1", - "--verbosity", - "3", - "--debug-sql", - "--noinput", - ] - ) - if keyword: - command.extend(["-k", keyword]) - if keepdb: - command.append("--keepdb") - - if repo_name in { - "django-debug-toolbar", - "django-allauth", - "django-mongodb-extensions", - }: - os.environ["DJANGO_SETTINGS_MODULE"] = settings["settings"]["module"][ - "test" - ] - command.extend( - [ - "--continue-on-collection-errors", - "--html=report.html", - "--self-contained-html", - ] - ) - elif repo_name == "mongo-python-driver": - command.extend(["test", "-s"]) - - command.extend(modules) - if os.environ.get("DJANGO_SETTINGS_MODULE"): - click.echo( - click.style( - f"DJANGO_SETTINGS_MODULE={os.environ['DJANGO_SETTINGS_MODULE']}", - fg="blue", - ) - ) - click.echo(click.style(f"Running {' '.join(command)}", fg="blue")) - subprocess.run(command, cwd=settings["test_dir"]) - return - - if all_repos and show_settings: - repos, url_pattern, _ = get_repos("pyproject.toml") - repo_name_map = get_repo_name_map(repos, url_pattern) - for repo_name in repo_name_map: - if repo_name in test_settings_map: - click.echo(f"āš™ļø Test settings for šŸ“¦ {repo_name}:") - settings_dict = dict(sorted(test_settings_map[repo_name].items())) - formatted = format_str(str(settings_dict), mode=Mode()) - rprint(formatted) - else: - click.echo(f"Settings for '{repo_name}' not found.") - return + """Sync the specified Git repository.""" + if all_repos: + typer.echo(typer.style("Syncing all repositories...", fg=typer.colors.CYAN)) + for repo_name in Repo().map: + Repo().sync_repo(repo_name) + elif repo_name: + Repo().sync_repo(repo_name) else: - click.echo("Can only use --all-repos with --show-settings") - return - - # No repo_name, show help - click.echo(context.get_help()) + typer.echo( + typer.style( + "Please specify a repository name or use --all-repos to sync all repositories.", + fg=typer.colors.YELLOW, + ) + ) @repo.command() -@click.argument("repo_names", nargs=-1) -@click.option("-a", "--all-repos", is_flag=True, help="Update all repositories") -@click.pass_context -@pass_repo -def update(repo, context, repo_names, all_repos): - """Update repositories (like 'status -u').""" - repos, url_pattern, _ = get_repos("pyproject.toml") - repo_name_map = get_repo_name_map(repos, url_pattern) - - if all_repos and repo_names: - click.echo("Cannot specify both repo names and --all-repos") - return - +def test( + repo_name: str = typer.Argument(None), + modules: list[str] = typer.Argument(None), + all_repos: bool = typer.Option( + False, "--all-repos", "-a", help="Sync all repositories" + ), + keep_db: bool = typer.Option( + False, "--keepdb", help="Keep the database after tests" + ), + keyword: str = typer.Option( + None, "--keyword", "-k", help="Run tests with the specified keyword" + ), + setenv: bool = typer.Option( + False, + "--setenv", + "-s", + help="Set DJANGO_SETTINGS_MODULE" " environment variable", + ), +): + """Run tests for the specified Git repository.""" + repo = Test() + if modules: + repo.set_modules(modules) + if keep_db: + repo.set_keep_db(keep_db) + if keyword: + repo.set_keyword(keyword) + if setenv: + repo.set_env(setenv) if all_repos: - click.echo(f"Updating {len(repo_name_map)} repositories...") - for repo_name, repo_url in repo_name_map.items(): - get_status( - repo_url, - url_pattern, - repo, - update=True, # Just like '-u' in status - reset=False, - diff=False, - branch=False, - log=False, + typer.echo( + typer.style("Running tests for all repositories...", fg=typer.colors.CYAN) + ) + for repo_name in repo.map: + repo.run_tests(repo_name) + elif repo_name: + repo.run_tests(repo_name) + else: + typer.echo( + typer.style( + "Please specify a repository name or use --all-repos to run tests for all repositories.", + fg=typer.colors.YELLOW, ) - return - - if repo_names: - not_found = [] - for repo_name in repo_names: - repo_url = repo_name_map.get(repo_name) - if repo_url: - get_status( - repo_url, - url_pattern, - repo, - update=True, - reset=False, - diff=False, - branch=False, - log=False, - ) - else: - not_found.append(repo_name) - for name in not_found: - click.echo(f"Repository '{name}' not found.") - return - - click.echo(context.get_help()) + ) diff --git a/django_mongodb_cli/settings.py b/django_mongodb_cli/settings.py deleted file mode 100644 index 24aae0b..0000000 --- a/django_mongodb_cli/settings.py +++ /dev/null @@ -1,289 +0,0 @@ -from os.path import join - -test_settings_map = { - "mongo-python-driver": { - "test_command": "just", - "test_dir": join("src", "mongo-python-driver", "test"), - "clone_dir": join("src", "mongo-python-driver"), - "test_dirs": [ - join("src", "mongo-python-driver", "test"), - ], - }, - "django": { - "apps_file": {}, - "test_command": "./runtests.py", - "test_dir": join("src", "django", "tests"), - "clone_dir": join("src", "django"), - "migrations_dir": { - "source": "mongo_migrations", - "target": join("src", "django", "tests", "mongo_migrations"), - }, - "settings": { - "test": { - "source": join("test", "settings", "django.py"), - "target": join("src", "django", "tests", "mongo_settings.py"), - }, - "migrations": { - "source": join("test", "settings", "django.py"), - "target": join("src", "django", "tests", "mongo_settings.py"), - }, - "module": { - "test": "mongo_settings", - "migrations": "mongo_settings", - }, - }, - "test_dirs": [ - join("src", "django", "tests"), - join("src", "django-mongodb-backend", "tests"), - ], - }, - "django-filter": { - "apps_file": { - "source": join("test", "apps", "django_filter.py"), - "target": join("src", "django-filter", "tests", "mongo_apps.py"), - }, - "test_command": "./runtests.py", - "test_dir": join("src", "django-filter"), - "migrations_dir": { - "source": "mongo_migrations", - "target": join("src", "django-filter", "tests", "mongo_migrations"), - }, - "clone_dir": join("src", "django-filter"), - "settings": { - "test": { - "source": join("test", "settings", "django_filter.py"), - "target": join("src", "django-filter", "tests", "settings.py"), - }, - "migrations": { - "source": join("test", "settings", "django_filter.py"), - "target": join("src", "django-filter", "tests", "settings.py"), - }, - "module": { - "test": "tests.settings", - "migrations": "tests.settings", - }, - }, - "test_dirs": [join("src", "django-filter", "tests")], - }, - "django-rest-framework": { - "apps_file": { - "source": join("test", "apps", "rest_framework.py"), - "target": join("src", "django-rest-framework", "tests", "mongo_apps.py"), - }, - "migrations_dir": { - "source": "mongo_migrations", - "target": join("src", "django-rest-framework", "tests", "mongo_migrations"), - }, - "test_command": "./runtests.py", - "test_dir": join("src", "django-rest-framework"), - "clone_dir": join("src", "django-rest-framework"), - "settings": { - "test": { - "source": join("test", "settings", "rest_framework.py"), - "target": join("src", "django-rest-framework", "tests", "conftest.py"), - }, - "migrations": { - "source": join("test", "settings", "rest_framework_migrations.py"), - "target": join("src", "django-rest-framework", "tests", "conftest.py"), - }, - "module": { - "test": "tests.conftest", - "migrations": "tests.conftest", - }, - }, - "test_dirs": [join("src", "django-rest-framework", "tests")], - }, - "wagtail": { - "apps_file": { - "source": join("test", "apps", "wagtail.py"), - "target": join("src", "wagtail", "wagtail", "test", "mongo_apps.py"), - }, - "migrations_dir": { - "source": "mongo_migrations", - "target": join("src", "wagtail", "wagtail", "test", "mongo_migrations"), - }, - "test_command": "./runtests.py", - "test_dir": join("src", "wagtail"), - "clone_dir": join("src", "wagtail"), - "settings": { - "test": { - "source": join("test", "settings", "wagtail.py"), - "target": join( - "src", "wagtail", "wagtail", "test", "mongo_settings.py" - ), - }, - "migrations": { - "source": join("test", "settings", "wagtail.py"), - "target": join( - "src", "wagtail", "wagtail", "test", "mongo_settings.py" - ), - }, - "module": { - "test": "wagtail.test.mongo_settings", - "migrations": "wagtail.test.mongo_settings", - }, - }, - "test_dirs": [ - join("src", "wagtail", "wagtail", "tests"), - join("src", "wagtail", "wagtail", "test"), - ], - }, - "django-debug-toolbar": { - "apps_file": { - "source": join("test", "apps", "debug_toolbar.py"), - "target": join( - "src", "django-debug-toolbar", "debug_toolbar", "mongo_apps.py" - ), - }, - "test_command": "pytest", - "test_dir": join("src", "django-debug-toolbar"), - "clone_dir": join("src", "django-debug-toolbar"), - "settings": { - "test": { - "source": join("test", "settings", "debug_toolbar.py"), - "target": join( - "src", "django-debug-toolbar", "debug_toolbar", "mongo_settings.py" - ), - }, - "migrations": { - "source": join("test", "settings", "debug_toolbar.py"), - "target": join( - "src", "django-debug-toolbar", "debug_toolbar", "mongo_settings.py" - ), - }, - "module": { - "test": "debug_toolbar.mongo_settings", - "migrations": "debug_toolbar.mongo_settings", - }, - }, - "test_dirs": [ - join("src", "django-debug-toolbar", "tests"), - ], - }, - "django-mongodb-extensions": { - "apps_file": { - "source": join("test", "apps", "django_mongodb_extensions.py"), - "target": join( - "src", - "django-mongodb-extensions", - "django_mongodb_extensions", - "mongo_apps.py", - ), - }, - "test_command": "pytest", - "test_dir": join("src", "django-mongodb-extensions"), - "clone_dir": join("src", "django-mongodb-extensions"), - "settings": { - "test": { - "source": join("test", "settings", "django_mongodb_extensions.py"), - "target": join( - "src", - "django-mongodb-extensions", - "django_mongodb_extensions", - "mongo_settings.py", - ), - }, - "migrations": { - "source": join("test", "extensions", "debug_toolbar_settings.py"), - "target": join( - "src", - "django-mongodb-extensions", - "django_mongodb_extensions", - "mongo_settings.py", - ), - }, - "module": { - "test": "django_mongodb_extensions.mongo_settings", - "migrations": "django_mongodb_extensions.mongo_settings", - }, - }, - "test_dirs": [ - join( - "src", "django-mongodb-extensions", "django_mongodb_extensions", "tests" - ), - ], - }, - "django-allauth": { - "test_command": "pytest", - "test_dir": join("src", "django-allauth"), - "clone_dir": join("src", "django-allauth"), - "apps_file": { - "source": join("test", "apps", "allauth.py"), - "target": join("src", "django-allauth", "allauth", "mongo_apps.py"), - }, - "settings": { - "test": { - "source": join("test", "settings", "allauth.py"), - "target": join("src", "django-allauth", "allauth", "mongo_settings.py"), - }, - "migrations": { - "source": join("test", "settings", "allauth.py"), - "target": join("src", "django-allauth", "allauth", "mongo_settings.py"), - }, - "module": { - "test": "allauth.mongo_settings", - "migrations": "allauth.mongo_settings", - }, - }, - "migrations_dir": { - "source": "mongo_migrations", - "target": join("src", "django-allauth", "allauth", "mongo_migrations"), - }, - "test_dirs": [ - join("src", "django-allauth", "allauth", "usersessions", "tests"), - join("src", "django-allauth", "allauth", "core", "tests"), - join("src", "django-allauth", "allauth", "core", "internal", "tests"), - join("src", "django-allauth", "allauth", "tests"), - join("src", "django-allauth", "allauth", "mfa", "recovery_codes", "tests"), - join("src", "django-allauth", "allauth", "mfa", "webauthn", "tests"), - join("src", "django-allauth", "allauth", "mfa", "totp", "tests"), - join("src", "django-allauth", "allauth", "mfa", "base", "tests"), - join( - "src", - "django-allauth", - "allauth", - "socialaccount", - "providers", - "oauth2", - "tests", - ), - join("src", "django-allauth", "allauth", "socialaccount", "tests"), - join( - "src", "django-allauth", "allauth", "socialaccount", "internal", "tests" - ), - join("src", "django-allauth", "allauth", "templates", "tests"), - join( - "src", "django-allauth", "allauth", "headless", "usersessions", "tests" - ), - join("src", "django-allauth", "allauth", "headless", "tests"), - join("src", "django-allauth", "allauth", "headless", "spec", "tests"), - join("src", "django-allauth", "allauth", "headless", "internal", "tests"), - join("src", "django-allauth", "allauth", "headless", "mfa", "tests"), - join( - "src", "django-allauth", "allauth", "headless", "socialaccount", "tests" - ), - join( - "src", - "django-allauth", - "allauth", - "headless", - "contrib", - "ninja", - "tests", - ), - join( - "src", - "django-allauth", - "allauth", - "headless", - "contrib", - "rest_framework", - "tests", - ), - join("src", "django-allauth", "allauth", "headless", "account", "tests"), - join("src", "django-allauth", "allauth", "headless", "base", "tests"), - join("src", "django-allauth", "allauth", "account", "tests"), - join("src", "django-allauth", "tests"), - ], - }, -} diff --git a/django_mongodb_cli/utils.py b/django_mongodb_cli/utils.py index ce915dc..57f7d86 100644 --- a/django_mongodb_cli/utils.py +++ b/django_mongodb_cli/utils.py @@ -1,512 +1,394 @@ -import click -import git -import os -import shutil -import string -import sys import toml -import random +import typer +import os import re +import shutil import subprocess +from git import Repo as GitRepo +from pathlib import Path -from .settings import test_settings_map - - -DELETE_DIRS_AND_FILES = { - ".babelrc": os.path.isfile, - ".dockerignore": os.path.isfile, - ".browserslistrc": os.path.isfile, - ".eslintrc": os.path.isfile, - ".nvmrc": os.path.isfile, - ".stylelintrc.json": os.path.isfile, - "Dockerfile": os.path.isfile, - "apps": os.path.isdir, - "home": os.path.isdir, - "backend": os.path.isdir, - "db.sqlite3": os.path.isfile, - "frontend": os.path.isdir, - "manage.py": os.path.isfile, - "package-lock.json": os.path.isfile, - "package.json": os.path.isfile, - "postcss.config.js": os.path.isfile, - "requirements.txt": os.path.isfile, - "search": os.path.isdir, -} - - -def random_app_name(prefix="app_", length=6): - suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=length)) - return prefix + suffix +URL_PATTERN = re.compile(r"git\+ssh://(?:[^@]+@)?([^/]+)/([^@]+)") +BRANCH_PATTERN = re.compile( + r"git\+ssh://git@github\.com/[^/]+/[^@]+@([a-zA-Z0-9_\-\.]+)\b" +) -def copy_mongo_apps(repo_name): +class Repo: """ - Copy the appropriate mongo_apps file based on the repo name. - - Requires test_settings_map[repo_name]['apps_file']['source'] and - test_settings_map[repo_name]['apps_file']['target'] to be defined. + Repo is a class that manages repository operations such as cloning, updating, + and checking the status of repositories defined in a configuration file. + It provides methods to handle various repository-related tasks. """ - settings = test_settings_map.get(repo_name) - if not settings or "apps_file" not in settings or repo_name == "django": - return # Nothing to copy for this repo - - apps_file = settings["apps_file"] - source = apps_file.get("source") - target = apps_file.get("target") - - if not source or not target: - click.echo( - click.style( - f"[copy_mongo_apps] Source or target path missing for '{repo_name}'.", - fg="yellow", - ) - ) - return - - try: - click.echo(click.style(f"Copying {source} → {target}", fg="blue")) - shutil.copyfile(source, target) - click.echo( - click.style(f"Copied {source} to {target} successfully.", fg="green") - ) - except FileNotFoundError as e: - click.echo(click.style(f"File not found: {e.filename}", fg="red")) - except Exception as e: - click.echo(click.style(f"Failed to copy: {e}", fg="red")) - -def copy_mongo_migrations(repo_name): - """ - Copy mongo_migrations to the specified test directory for this repo. - - test_settings_map[repo_name]['migrations_dir']['source'] and - test_settings_map[repo_name]['migrations_dir']['target'] must be defined. - Does nothing if 'migrations_dir' not present. - """ - settings = test_settings_map.get(repo_name) - if not settings or "migrations_dir" not in settings: - click.echo(click.style("No migrations to copy.", fg="yellow")) - return - - migrations = settings["migrations_dir"] - source = migrations.get("source") - target = migrations.get("target") - - if not source or not target: - click.echo( - click.style( - f"[copy_mongo_migrations] Source/target path missing for '{repo_name}'.", - fg="yellow", + def __init__(self, pyproject_file: Path = Path("pyproject.toml")): + self.pyproject_file = pyproject_file + self.config = self._load_config() + self.path = Path(self.config["tool"]["django_mongodb_cli"]["path"]) + self.map = self.get_map() + + def _load_config(self) -> dict: + return toml.load(self.pyproject_file) + + def get_map(self) -> dict: + """ + Return a dict mapping repo_name to repo_url from repos in + [tool.django_mongodb_cli.repos]. + """ + return { + repo.split("@", 1)[0].strip(): repo.split("@", 1)[1].strip() + for repo in self.config["tool"]["django_mongodb_cli"].get("repos", []) + if "@" in repo + } + + def get_repo_path(self, name: str) -> Path: + return (self.path / name).resolve() + + def get_repo(self, path: str) -> GitRepo: + return GitRepo(path) + + def get_repo_status(self, repo_name: str) -> str: + """ + Get the status of a repository. + """ + typer.echo( + typer.style( + f"Getting status for repository: {repo_name}", fg=typer.colors.CYAN ) ) - return - if not os.path.exists(source): - click.echo( - click.style( - f"Source migrations directory does not exist: {source}", fg="red" + path = self.get_repo_path(repo_name) + if not os.path.exists(path): + typer.echo( + typer.style( + f"Repository '{repo_name}' not found at path: {path}", + fg=typer.colors.RED, + ) ) - ) - return - - if os.path.exists(target): - click.echo( - click.style( - f"Target migrations already exist: {target} (skipping copy)", fg="cyan" + return + typer.echo( + typer.style( + f"Repository '{repo_name}' found at path: {path}", fg=typer.colors.GREEN ) ) - return - - try: - click.echo( - click.style(f"Copying migrations from {source} to {target}", fg="blue") + repo = self.get_repo(path) + typer.echo( + typer.style(f"On branch: {repo.active_branch}", fg=typer.colors.CYAN) ) - shutil.copytree(source, target) - click.echo( - click.style(f"Copied migrations to {target} successfully.", fg="green") - ) - except Exception as e: - click.echo(click.style(f"Failed to copy migrations: {e}", fg="red")) - - -def copy_mongo_settings(source, target): - """ - Copy mongo_settings to the specified test directory. - - Args: - source (str): Path to the source settings file. - target (str): Path to the target location. - - Shows a message and handles errors gracefully. - """ - if not source or not target: - click.echo(click.style("Source or target not specified.", fg="yellow")) - return - if not os.path.exists(source): - click.echo(click.style(f"Source file does not exist: {source}", fg="red")) - exit(1) - - try: - click.echo(click.style(f"Copying {source} → {target}", fg="blue")) - shutil.copyfile(source, target) - click.echo(click.style(f"Copied settings to {target}.", fg="green")) - except Exception as e: - click.echo(click.style(f"Failed to copy settings: {e}", fg="red")) - - -def get_management_command(command=None, *args): - """ - Construct a Django management command suitable for subprocess execution. - - If 'manage.py' exists in the current directory, use it; otherwise, fall back to 'django-admin'. - Commands in REQUIRES_MANAGE_PY require 'manage.py' to be present. - Pass additional arguments via *args for command options. - """ - REQUIRES_MANAGE_PY = { - "createsuperuser", - "migrate", - "runserver", - "shell", - "startapp", - } - manage_py = "manage.py" - manage_py_exists = os.path.isfile(manage_py) - - if not manage_py_exists and (command is None or command in REQUIRES_MANAGE_PY): - raise click.ClickException( - "manage.py is required to run this command. " - "Please run this command in the project directory." - ) - - base_command = [sys.executable, manage_py] if manage_py_exists else ["django-admin"] - - if command: - full_command = base_command + [command] - else: - full_command = base_command - - if args: - # *args allows for further options/args (e.g. get_management_command("makemigrations", "--verbosity", "2")) - full_command.extend(args) - - return full_command - - -URL_PATTERN = re.compile(r"git\+ssh://(?:[^@]+@)?([^/]+)/([^@]+)") -BRANCH_PATTERN = re.compile( - r"git\+ssh://git@github\.com/[^/]+/[^@]+@([a-zA-Z0-9_\-\.]+)\b" -) - - -def get_repos(pyproject_path, section="dev"): - """ - Load repository info from a pyproject.toml file. - - Args: - pyproject_path (str): Path to the pyproject.toml file. - section (str): Which section ('dev', etc.) of [tool.django_mongodb_cli] to use. + unstaged = repo.index.diff(None) + if unstaged: + typer.echo( + typer.style("\nChanges not staged for commit:", fg=typer.colors.YELLOW) + ) + for diff in unstaged: + typer.echo( + typer.style(f" modified: {diff.a_path}", fg=typer.colors.YELLOW) + ) + staged = repo.index.diff("HEAD") + if staged: + typer.echo(typer.style("\nChanges to be committed:", fg=typer.colors.GREEN)) + for diff in staged: + typer.echo( + typer.style(f" staged: {diff.a_path}", fg=typer.colors.GREEN) + ) + if repo.untracked_files: + typer.echo(typer.style("\nUntracked files:", fg=typer.colors.MAGENTA)) + for f in repo.untracked_files: + typer.echo(typer.style(f" {f}", fg=typer.colors.MAGENTA)) + if not unstaged and not staged and not repo.untracked_files: + typer.echo( + typer.style( + "\nNothing to commit, working tree clean.", fg=typer.colors.GREEN + ) + ) - Returns: - repos (list): List of repository spec strings. - url_pattern (Pattern): Compiled regex to extract repo basename. - branch_pattern (Pattern): Compiled regex to extract branch from url. - """ - try: - with open(pyproject_path, "r") as f: - pyproject_data = toml.load(f) - except FileNotFoundError: - raise click.ClickException(f"Could not find {pyproject_path}") - except toml.TomlDecodeError as e: - raise click.ClickException(f"Failed to parse TOML: {e}") - - tool_section = pyproject_data.get("tool", {}).get("django_mongodb_cli", {}) - repos = tool_section.get(section, []) - if not isinstance(repos, list): - raise click.ClickException( - f"Expected a list of repos in [tool.django_mongodb_cli.{section}]" + def clone_repo(self, repo_name: str) -> None: + """ + Clone a repository into the specified path. + If the repository already exists, it will skip cloning. + """ + typer.echo( + typer.style(f"Cloning repository: {repo_name}", fg=typer.colors.CYAN) ) - return repos, URL_PATTERN, BRANCH_PATTERN - - -def clone_repo(repo_entry, url_pattern, branch_pattern, repo): - """ - Clone a single repository into repo.home given a repo spec entry. - If the repo already exists at the destination, skips. - Tries to check out a branch if one is found in the entry, defaults to 'main'. - """ - url_match = url_pattern.search(repo_entry) - if not url_match: - click.echo(click.style(f"Invalid repository entry: {repo_entry}", fg="red")) - return - - repo_url = url_match.group(0) - repo_name = os.path.basename(repo_url) - branch = ( - branch_pattern.search(repo_entry).group(1) - if branch_pattern.search(repo_entry) - else "main" - ) - clone_path = os.path.join(repo.home, repo_name) - - if os.path.exists(clone_path): - click.echo( - click.style( - f"Skipping {repo_name}: already exists at {clone_path}", fg="yellow" + if repo_name not in self.map: + typer.echo( + typer.style( + f"Repository '{repo_name}' not found in configuration.", + fg=typer.colors.RED, + ) ) - ) - return + return - click.echo( - click.style( - f"Cloning {repo_name} from {repo_url} into {clone_path} (branch: {branch})", - fg="blue", + url = self.map[repo_name] + path = self.get_repo_path(repo_name) + branch = ( + BRANCH_PATTERN.search(url).group(1) + if BRANCH_PATTERN.search(url) + else "main" ) - ) - try: - git.Repo.clone_from(repo_url, clone_path, branch=branch) - except git.exc.GitCommandError as e: - click.echo( - click.style( - f"Warning: Failed to clone branch '{branch}'. Trying default branch instead... ({e})", - fg="yellow", + url = URL_PATTERN.search(url).group(0) + if os.path.exists(path): + typer.echo( + typer.style( + f"Repository '{repo_name}' already exists at path: {path}", + fg=typer.colors.YELLOW, + ) ) - ) - try: - git.Repo.clone_from(repo_url, clone_path) - except git.exc.GitCommandError as e2: - click.echo(click.style(f"Failed to clone repository: {e2}", fg="red")) return - # Optionally install pre-commit hooks if available - pre_commit_cfg = os.path.join(clone_path, ".pre-commit-config.yaml") - if os.path.exists(pre_commit_cfg): - click.echo( - click.style(f"Installing pre-commit hooks for {repo_name}...", fg="green") - ) - result = subprocess.run(["pre-commit", "install"], cwd=clone_path) - if result.returncode == 0: - click.echo(click.style("pre-commit installed successfully.", fg="green")) - else: - click.echo(click.style("pre-commit installation failed.", fg="red")) - else: - click.echo( - click.style(f"No pre-commit config found in {repo_name}.", fg="yellow") + typer.echo( + typer.style( + f"Cloning {url} into {path} (branch: {branch})", fg=typer.colors.CYAN + ) ) + GitRepo.clone_from(url, path, branch=branch) + # Install pre-commit hooks if config exists + pre_commit_config = os.path.join(path, ".pre-commit-config.yaml") + if os.path.exists(pre_commit_config): + typer.echo( + typer.style("Installing pre-commit hooks...", fg=typer.colors.CYAN) + ) + try: + subprocess.run(["pre-commit", "install"], cwd=path, check=True) + typer.echo( + typer.style("Pre-commit hooks installed!", fg=typer.colors.GREEN) + ) + except subprocess.CalledProcessError as e: + typer.echo( + typer.style( + f"Failed to install pre-commit hooks for {repo_name}: {e}", + fg=typer.colors.RED, + ) + ) + else: + typer.echo( + typer.style( + "No .pre-commit-config.yaml found. Skipping pre-commit hook installation.", + fg=typer.colors.YELLOW, + ) + ) -def get_repo_name_map(repos, url_pattern): - """Return a dict mapping repo_name to repo_url from a list of repo URLs.""" - return { - os.path.basename(url_pattern.search(url).group(0)): url - for url in repos - if url_pattern.search(url) - } + def install_package(self, repo_name: str) -> None: + """ + Install a package from the cloned repository. + """ + typer.echo( + typer.style( + f"Installing package from repository: {repo_name}", fg=typer.colors.CYAN + ) + ) + path = self.get_repo_path(repo_name) + if not os.path.exists(path): + typer.echo( + typer.style( + f"Repository '{repo_name}' not found at path: {path}", + fg=typer.colors.RED, + ) + ) + return -def install_package(clone_path): - """ - Install a package from the given clone path. + try: + subprocess.run( + [os.sys.executable, "-m", "pip", "install", "-e", path], + check=True, + ) + typer.echo( + typer.style( + f"āœ… Successfully installed package from {repo_name}.", + fg=typer.colors.GREEN, + ) + ) + except subprocess.CalledProcessError as e: + typer.echo( + typer.style( + f"āŒ Failed to install package from {repo_name}: {e}", + fg=typer.colors.RED, + ) + ) - - If clone_path ends with 'mongo-arrow' or 'libmongocrypt', the actual install path is 'bindings/python' inside it. - - Tries editable install via pip if pyproject.toml exists. - - Tries setup.py develop if setup.py exists. - - Installs from requirements.txt if available. - - Warns the user if nothing can be installed. - """ - # Special case for known subdir installs - if clone_path.endswith(("mongo-arrow", "libmongocrypt")): - install_path = os.path.join(clone_path, "bindings", "python") - else: - install_path = clone_path - - if os.path.exists(os.path.join(install_path, "pyproject.toml")): - click.echo( - click.style( - f"Installing (editable) with pyproject.toml: {install_path}", fg="blue" + def sync_repo(self, repo_name: str) -> None: + """ + Synchronize the repository by pulling the latest changes. + """ + typer.echo(typer.style("Synchronizing repository...", fg=typer.colors.CYAN)) + path = self.get_repo_path(repo_name) + if not os.path.exists(path): + typer.echo( + typer.style( + f"Repository '{repo_name}' not found at path: {path}", + fg=typer.colors.RED, + ) ) - ) - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "-e", install_path] - ) - elif os.path.exists(os.path.join(install_path, "setup.py")): - click.echo( - click.style( - f"Installing (develop) with setup.py in {install_path}", fg="blue" + return + try: + repo = self.get_repo(path) + typer.echo( + typer.style( + f"Pulling latest changes for {repo_name}...", fg=typer.colors.CYAN + ) ) - ) - result = subprocess.run( - [sys.executable, "setup.py", "develop"], cwd=install_path - ) - elif os.path.exists(os.path.join(install_path, "requirements.txt")): - click.echo( - click.style(f"Installing requirements.txt in {install_path}", fg="blue") - ) - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "-r", "requirements.txt"], - cwd=install_path, - ) - else: - click.echo( - click.style( - f"No valid installation method found for {install_path}", fg="red" + repo.remotes.origin.pull() + typer.echo( + typer.style( + f"āœ… Successfully synchronized {repo_name}.", fg=typer.colors.GREEN + ) + ) + except Exception as e: + typer.echo( + typer.style( + f"āŒ Failed to synchronize {repo_name}: {e}", fg=typer.colors.RED + ) ) - ) - return - if "result" in locals(): - if result.returncode == 0: - click.echo( - click.style(f"Install successful in {install_path}.", fg="green") + def run_tests(self, repo_name: str) -> None: + """ + Run tests for the specified repository. + """ + typer.echo( + typer.style( + f"Running tests for repository: {repo_name}", fg=typer.colors.CYAN ) - else: - click.echo(click.style(f"Install failed in {install_path}.", fg="red")) + ) + path = self.get_repo_path(repo_name) + if not os.path.exists(path): + typer.echo( + typer.style( + f"Repository '{repo_name}' not found at path: {path}", + fg=typer.colors.RED, + ) + ) + return -def repo_update(repo_entry, url_pattern, clone_path): - """ - Update a single git repository at clone_path. + Test().run_tests(repo_name) + typer.echo( + typer.style( + f"āœ… Tests completed successfully for {repo_name}.", + fg=typer.colors.GREEN, + ) + ) - Args: - repo_entry (str): The repository entry string (e.g. repo URL). - url_pattern (Pattern): Compiled regex to extract repo name from URL. - clone_path (str): Path where the repository is cloned. - If the repo doesn't exist, skips it. Handles and reports errors. +class Test(Repo): + """ + Test is a subclass of Repo that provides additional functionality + for running tests on repositories. + It inherits methods from the Repo class and can be extended with + more test-specific methods if needed. """ - url_match = url_pattern.search(repo_entry) - if not url_match: - click.echo(click.style(f"Invalid repository entry: {repo_entry}", fg="red")) - return - - repo_url = url_match.group(0) - repo_name = os.path.basename(repo_url) - - if not os.path.exists(clone_path): - click.echo( - click.style( - f"Skipping {repo_name}: {clone_path} does not exist.", fg="yellow" + + def __init__(self, pyproject_file: Path = Path("pyproject.toml")): + super().__init__(pyproject_file) + self.modules = [] + self.keep_db = False + self.keyword = None + self.setenv = False + + def set_modules(self, modules: list) -> None: + self.modules = modules + + def set_keep_db(self, keep_db: bool) -> None: + """Set whether to keep the database after tests.""" + self.keep_db = keep_db + + def set_keyword(self, keyword: str) -> None: + """Set a keyword to filter tests.""" + self.keyword = keyword + + def set_env(self, setenv: bool) -> None: + """Set whether to set DJANGO_SETTINGS_MODULE environment variable.""" + self.setenv = setenv + + def copy_settings(self, repo_name: str) -> None: + """ + Copy test settings from this repository to the repository + specified by repo_name. + """ + source = self.test_settings["settings"]["test"]["source"] + target = self.test_settings["settings"]["test"]["target"] + shutil.copyfile(source, target) + typer.echo( + typer.style( + f"Copied test settings from {source} to {target} for {repo_name}.", + fg=typer.colors.CYAN, ) ) - return - - try: - # Check if clone_path is a git repo - repo = git.Repo(clone_path) - click.echo(click.style(f"Updating šŸ“¦ {repo_name}...", fg="blue")) - pull_output = repo.git.pull() - click.echo( - click.style(f"Pull result for {repo_name}:\n{pull_output}", fg="green") - ) - except git.exc.NoSuchPathError: - click.echo( - click.style(f"Not a valid Git repository at {clone_path}.", fg="red") + + def copy_apps(self, repo_name: str) -> None: + """ + Copy test settings from this repository to the repository + specified by repo_name. + """ + source = self.test_settings["apps_file"]["source"] + target = self.test_settings["apps_file"]["target"] + shutil.copyfile(source, target) + typer.echo( + typer.style( + f"Copied apps from {source} to {target} for {repo_name}.", + fg=typer.colors.CYAN, + ) ) - except git.exc.GitCommandError as e: - click.echo(click.style(f"Failed to update {repo_name}: {e}", fg="red")) - except Exception as e: - click.echo(click.style(f"Unexpected error updating {repo_name}: {e}", fg="red")) - - -def get_status( - repo_entry, - url_pattern, - repo, - reset=False, - diff=False, - branch=False, - update=False, - log=False, -): - """ - Show status (and optionally reset/update/log/diff/branch) for a single repo. - - Args: - repo_entry (str): The repository entry spec (from pyproject.toml). - url_pattern (Pattern): Regex to extract basename from URL. - repo (object): Repo context with .home attribute. - reset (bool): If True, hard-reset the repo. - diff (bool): If True, show git diff. - branch (bool): If True, show all branches. - update (bool): If True, run repo_update(). - log (bool): If True, show formatted log. - - Outputs status/log info with color and error reporting. - """ - url_match = url_pattern.search(repo_entry) - if not url_match: - click.echo(click.style(f"Invalid repository entry: {repo_entry}", fg="red")) - return - - repo_url = url_match.group(0) - repo_name = os.path.basename(repo_url) - clone_path = os.path.join(repo.home, repo_name) - - if not os.path.exists(clone_path): - click.echo(click.style(f"Skipping šŸ“¦ {repo_name} (not cloned)", fg="yellow")) - return - - try: - repo_obj = git.Repo(clone_path) - click.echo(click.style(f"\nšŸ“¦ {repo_name}", fg="blue", bold=True)) - - if reset: - click.echo( - click.style(f"Resetting {repo_name} to HEAD (hard)...", fg="red") + + def copy_migrations(self, repo_name: str) -> None: + """ + Copy migrations from this repository to the repository + specified by repo_name. + """ + source = self.test_settings["migrations_dir"]["source"] + target = self.test_settings["migrations_dir"]["target"] + shutil.copytree(source, target) + typer.echo( + typer.style( + f"Copied apps from {source} to {target} for {repo_name}.", + fg=typer.colors.CYAN, ) - out = repo_obj.git.reset("--hard") - click.echo(out) - click.echo() # Space + ) - else: - # Print remote info - for remote in repo_obj.remotes: - click.echo( - click.style(f"Remote: {remote.name} @ {remote.url}", fg="blue") + def run_tests(self, repo_name: str) -> None: + self.test_settings = ( + self.config.get("tool", {}) + .get("django_mongodb_cli", {}) + .get("test", {}) + .get(repo_name, {}) + ) + if not self.test_settings: + typer.echo( + typer.style( + f"No test settings found for {repo_name}.", fg=typer.colors.YELLOW ) - - # Print branch info - current_branch = ( - repo_obj.active_branch.name - if repo_obj.head.is_valid() - else "" ) - click.echo(click.style(f"On branch: {current_branch}", fg="magenta")) - - status = repo_obj.git.status() - click.echo(status) - - # Show diff if requested - if diff: - diff_output = repo_obj.git.diff() - if diff_output.strip(): - click.echo(click.style("Diff:", fg="yellow")) - click.echo(click.style(diff_output, fg="red")) - else: - click.echo(click.style("No diff output", fg="green")) - - # Show branches if requested - if branch: - click.echo(click.style("Branches:", fg="blue")) - click.echo(repo_obj.git.branch("--all")) - - # Show log if requested - if log: - click.echo(click.style("Git log:", fg="blue")) - click.echo( - repo_obj.git.log("--pretty=format:%h - %an, %ar : %s", "--graph") + return + test_dir = self.test_settings.get("test_dir") + if not os.path.exists(test_dir): + typer.echo( + typer.style( + f"Test directory '{test_dir}' does not exist for {repo_name}.", + fg=typer.colors.RED, ) - - # Run update if requested (AFTER showing status etc) - if update: - repo_update(repo_entry, url_pattern, clone_path) - - click.echo() # Add extra space after each repo status for legibility - - except git.exc.InvalidGitRepositoryError: - click.echo( - click.style(f"{clone_path} is not a valid Git repository.", fg="red") + ) + return + self.copy_apps(repo_name) + self.copy_migrations(repo_name) + self.copy_settings(repo_name) + test_options = self.test_settings.get("test_options") + test_command = [self.test_settings.get("test_command")] + settings_module = ( + self.test_settings.get("settings", {}).get("module", {}).get("test") + ) + if test_options: + test_command.extend(test_options) + if settings_module and test_command == "./runtests.py": + test_command.extend(["--settings", settings_module]) + if self.keep_db: + test_command.extend("--keepdb") + if self.keyword: + test_command.extend(["-k", self.keyword]) + if self.modules: + test_command.extend(self.modules) + subprocess.run( + test_command, + cwd=test_dir, ) - except Exception as e: - click.echo(click.style(f"An error occurred for {repo_name}: {e}", fg="red")) diff --git a/docs/source/index.rst b/docs/source/index.rst index d060742..b0b5fd1 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -19,8 +19,6 @@ This library provides the ``dm`` command which can be used for: - :ref:`Running third party library test suites ` with :ref:`Django MongoDB Backend ` and :ref:`MongoDB's Django fork `. -- Creating `Django projects`_ with :ref:`Django MongoDB Backend ` - and :ref:`third party libraries `. Table of Contents ----------------- diff --git a/docs/source/supported-libraries/index.rst b/docs/source/supported-libraries/index.rst index ce34d25..33d2e9b 100644 --- a/docs/source/supported-libraries/index.rst +++ b/docs/source/supported-libraries/index.rst @@ -16,22 +16,9 @@ Supported Libraries +------------------------------------+-------------------------------------------------------------+-------------------------------+----------------------------------+-------------------------+ | **django-rest-framework** | :ref:`81% passing ` | | | | +------------------------------------+-------------------------------------------------------------+-------------------------------+----------------------------------+-------------------------+ -| | | | In addition to custom apps and | Test results prior to | -| | | | migrations, requires custom | merge of `256`_. | -| | | | URL patterns. | | -| **wagtail** | :ref:`43% passing ` | | | | -| | | | | | -| | | | | | -| | | | | | -| | | | | | -| | | | | | -+------------------------------------+-------------------------------------------------------------+-------------------------------+----------------------------------+-------------------------+ .. toctree:: django-filter django-rest-framework django-debug-toolbar django-allauth - wagtail - -.. _256: https://github.com/mongodb/django-mongodb-backend/pull/256 diff --git a/docs/source/supported-libraries/wagtail.rst b/docs/source/supported-libraries/wagtail.rst deleted file mode 100644 index c84a78b..0000000 --- a/docs/source/supported-libraries/wagtail.rst +++ /dev/null @@ -1,28 +0,0 @@ -Wagtail -======= - -.. _wagtail-results: - -Test suite ----------- - -Via ``dm repo test wagtail`` - -.. note:: - - Test results prior to merging https://github.com/mongodb/django-mongodb-backend/pull/256 - -+---------------------------+------------+-----------+-----------+----------------+--------------+----------------------------+------------------+ -| **PERCENTAGE PASSED** | **TOTAL** | **PASS** | **FAIL** | **SKIPPED** | **ERROR** | **EXPECTED FAILURES** | **WARNING** | -+---------------------------+------------+-----------+-----------+----------------+--------------+----------------------------+------------------+ -| 43% | 4897 | 2124 | 52 | 468 | 2252 | 1 | 0 | -+---------------------------+------------+-----------+-----------+----------------+--------------+----------------------------+------------------+ - -- `wagtail.txt <../_static/logs/wagtail.txt>`_ -- `wagtail2.txt <../_static/logs/wagtail2.txt>`_ - -Project examples ----------------- - -Known issues ------------- diff --git a/docs/source/usage/third-party.rst b/docs/source/usage/third-party.rst index 7eca797..b1ebd80 100644 --- a/docs/source/usage/third-party.rst +++ b/docs/source/usage/third-party.rst @@ -51,14 +51,3 @@ django-allauth When completed successfully the output will look like this: .. image:: ../_static/images/django-allauth.png - -wagtail -~~~~~~~ - -:: - - dm repo test wagtail - -When completed successfully the output will look like this: - -.. image:: ../_static/images/wagtail.png diff --git a/justfile b/justfile index 7d98fba..2551f41 100644 --- a/justfile +++ b/justfile @@ -89,6 +89,11 @@ sphinx-clean: rm -rvf docs/_build alias sc := sphinx-clean +[group('sphinx')] +sphinx-open: + open docs/_build/index.html +alias so := sphinx-open + # ---------------------------------------- qe ---------------------------------------- qe: diff --git a/pyproject.toml b/pyproject.toml index 1e1f511..51034d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,6 @@ dependencies = [ "GitPython", # For git integration "Sphinx", # For django-mongodb-backend documentation "black", - "click", "django-extensions", # <3 django-extensions "django-debug-toolbar", "django-ninja", # For django-allauth @@ -17,6 +16,7 @@ dependencies = [ "python3-saml", # For django-allauth "pyjwt[crypto]", # For django-allauth "pymongocrypt", # For django-mongodb-backend QE + "pymongo-auth-aws", # For django-mongodb-backend QE "pytest", "pytest-html", "pytest-django", # For django-rest-framework and django-debug-toolbar @@ -28,14 +28,18 @@ dependencies = [ "sphinx-autobuild", # For django-mongodb-backend documentation "sphinx-copybutton", # For django-mongodb-backend documentation "toml", + "typer", "wagtail", # For django-mongodb-templates ] +[project.scripts] +dm = "django_mongodb_cli:dm" + [tool.setuptools] packages = ["django_mongodb_cli"] [tool.django_mongodb_cli] -dev = [ +repos = [ "django @ git+ssh://git@github.com/mongodb-forks/django@mongodb-5.2.x", "django-allauth @ git+ssh://git@github.com/pennersr/django-allauth@main", "django-debug-toolbar @ git+ssh://git@github.com/django-commons/django-debug-toolbar@main", @@ -44,22 +48,233 @@ dev = [ "django-mongodb-backend @ git+ssh://git@github.com/mongodb/django-mongodb-backend@main", "django-mongodb-extensions @ git+ssh://git@github.com/mongodb-labs/django-mongodb-extensions@main", "django-mongodb-project @ git+ssh://git@github.com/mongodb-labs/django-mongodb-project@5.2.x", - "django-mongodb-templates @ git+ssh://git@github.com/aclark4life/django-mongodb-templates@main", + "django-mongodb-project-benchmark @ git+ssh://git@github.com/NoahStapp/django-mongodb-backend-benchmark.git@main", "django-rest-framework @ git+ssh://git@github.com/encode/django-rest-framework@main", - "docs @ git+ssh://git@github.com/mongodb/docs@master", + "drivers-evergreen-tools @ git+ssh://git@github.com/mongodb-labs/drivers-evergreen-tools@master", + "docs @ git+ssh://git@github.com/mongodb/docs@main", "flask-pymongo @ git+ssh://git@github.com/mongodb-labs/flask-pymongo", "langchain-mongodb @ git+ssh://git@github.com/langchain-ai/langchain-mongodb@main", - "libmongocrypt @ git+ssh://git@github.com/mongodb-labs/libmongocrypt@main", + "libmongocrypt @ git+ssh://git@github.com/mongodb-labs/libmongocrypt@master", "mongo-arrow @ git+ssh://git@github.com/mongodb-labs/mongo-arrow@main", "mongo-orchestration @ git+ssh://git@github.com/mongodb-labs/mongo-orchestration@master", "mongo-python-driver @ git+ssh://git@github.com/mongodb/mongo-python-driver@master", - "pymongo-auth-aws @ git+ssh://git@github.com/mongodb/pymongo-auth-aws@main", + "pymongo-auth-aws @ git+ssh://git@github.com/mongodb/pymongo-auth-aws@master", "specifications @ git+ssh://git@github.com/mongodb/specifications@master", "wagtail @ git+ssh://git@github.com/mongodb-forks/wagtail@main", "wagtail-mongodb-project @ git+ssh://git@github.com/mongodb-labs/wagtail-mongodb-project@main", "winkerberos @ git+ssh://git@github.com/mongodb-labs/winkerberos@main", "xmlsec @ git+ssh://git@github.com/xmlsec/python-xmlsec@main", ] +path = "src" -[project.scripts] -dm = "django_mongodb_cli:cli" +[tool.django_mongodb_cli.test.mongo-python-driver] +test_command = "just" +test_dir = "src/mongo-python-driver/test" +clone_dir = "src/mongo-python-driver" +test_dirs = ["src/mongo-python-driver/test"] + +[tool.django_mongodb_cli.test.django] +test_command = "./runtests.py" +test_options = [ + "--parallel", + "1", + "--verbosity", + "3", + "--debug-sql", + "--noinput", +] +test_dir = "src/django/tests" +clone_dir = "src/django" +test_dirs = [ "src/django/tests", "src/django-mongodb-backend/tests" ] + +[tool.django_mongodb_cli.test.django.migrations_dir] +source = "mongo_migrations" +target = "src/django/tests/mongo_migrations" + +[tool.django_mongodb_cli.test.django.settings.test] +source = "test/settings/django.py" +target = "src/django/tests/mongo_settings.py" + +[tool.django_mongodb_cli.test.django.settings.migrations] +source = "test/settings/django_migrations.py" +target = "src/django/tests/mongo_settings.py" + +[tool.django_mongodb_cli.test.django.settings.module] +test = "mongo_settings" +migrations = "mongo_settings" + +[tool.django_mongodb_cli.test.django-filter] +test_command = "./runtests.py" +test_dir = "src/django-filter" +clone_dir = "src/django-filter" +test_dirs = ["src/django-filter/tests"] + +[tool.django_mongodb_cli.test.django-filter.apps_file] +source = "test/apps/django_filter.py" +target = "src/django-filter/tests/mongo_apps.py" + +[tool.django_mongodb_cli.test.django-filter.migrations_dir] +source = "mongo_migrations" +target = "src/django-filter/tests/mongo_migrations" + +[tool.django_mongodb_cli.test.django-filter.settings.test] +source = "test/settings/django_filter.py" +target = "src/django-filter/tests/settings.py" + +[tool.django_mongodb_cli.test.django-filter.settings.migrations] +source = "test/settings/django_filter.py" +target = "src/django-filter/tests/settings.py" + +[tool.django_mongodb_cli.test.django-filter.settings.module] +test = "tests.settings" +migrations = "tests.settings" + +[tool.django_mongodb_cli.test.django-rest-framework] +test_command = "./runtests.py" +test_dir = "src/django-rest-framework" +clone_dir = "src/django-rest-framework" +test_dirs = ["src/django-rest-framework/tests"] + +[tool.django_mongodb_cli.test.django-rest-framework.apps_file] +source = "test/apps/rest_framework.py" +target = "src/django-rest-framework/tests/mongo_apps.py" + +[tool.django_mongodb_cli.test.django-rest-framework.migrations_dir] +source = "mongo_migrations" +target = "src/django-rest-framework/tests/mongo_migrations" + +[tool.django_mongodb_cli.test.django-rest-framework.settings.test] +source = "test/settings/rest_framework.py" +target = "src/django-rest-framework/tests/conftest.py" + +[tool.django_mongodb_cli.test.django-rest-framework.settings.migrations] +source = "test/settings/rest_framework_migrations.py" +target = "src/django-rest-framework/tests/conftest.py" + +[tool.django_mongodb_cli.test.django-rest-framework.settings.module] +test = "tests.conftest" +migrations = "tests.conftest" + +[tool.django_mongodb_cli.test.wagtail] +test_command = "./runtests.py" +test_dir = "src/wagtail" +clone_dir = "src/wagtail" +test_dirs = [ + "src/wagtail/wagtail/tests", + "src/wagtail/wagtail/test" +] + +[tool.django_mongodb_cli.test.wagtail.apps_file] +source = "test/apps/wagtail.py" +target = "src/wagtail/wagtail/test/mongo_apps.py" + +[tool.django_mongodb_cli.test.wagtail.migrations_dir] +source = "mongo_migrations" +target = "src/wagtail/wagtail/test/mongo_migrations" + +[tool.django_mongodb_cli.test.wagtail.settings.test] +source = "test/settings/wagtail.py" +target = "src/wagtail/wagtail/test/mongo_settings.py" + +[tool.django_mongodb_cli.test.wagtail.settings.migrations] +source = "test/settings/wagtail.py" +target = "src/wagtail/wagtail/test/mongo_settings.py" + +[tool.django_mongodb_cli.test.wagtail.settings.module] +test = "wagtail.test.mongo_settings" +migrations = "wagtail.test.mongo_settings" + +[tool.django_mongodb_cli.test.django-debug-toolbar] +test_command = "pytest" +test_dir = "src/django-debug-toolbar" +clone_dir = "src/django-debug-toolbar" +test_dirs = ["src/django-debug-toolbar/tests"] + +[tool.django_mongodb_cli.test.django-debug-toolbar.apps_file] +source = "test/apps/debug_toolbar.py" +target = "src/django-debug-toolbar/debug_toolbar/mongo_apps.py" + +[tool.django_mongodb_cli.test.django-debug-toolbar.settings.test] +source = "test/settings/debug_toolbar.py" +target = "src/django-debug-toolbar/debug_toolbar/mongo_settings.py" + +[tool.django_mongodb_cli.test.django-debug-toolbar.settings.migrations] +source = "test/settings/debug_toolbar.py" +target = "src/django-debug-toolbar/debug_toolbar/mongo_settings.py" + +[tool.django_mongodb_cli.test.django-debug-toolbar.settings.module] +test = "debug_toolbar.mongo_settings" +migrations = "debug_toolbar.mongo_settings" + +[tool.django_mongodb_cli.test.django-mongodb-extensions] +test_command = "pytest" +test_dir = "src/django-mongodb-extensions" +clone_dir = "src/django-mongodb-extensions" +test_dirs = ["src/django-mongodb-extensions/django_mongodb_extensions/tests"] + +[tool.django_mongodb_cli.test.django-mongodb-extensions.apps_file] +source = "test/apps/django_mongodb_extensions.py" +target = "src/django-mongodb-extensions/django_mongodb_extensions/mongo_apps.py" + +[tool.django_mongodb_cli.test.django-mongodb-extensions.settings.test] +source = "test/settings/django_mongodb_extensions.py" +target = "src/django-mongodb-extensions/django_mongodb_extensions/mongo_settings.py" + +[tool.django_mongodb_cli.test.django-mongodb-extensions.settings.migrations] +source = "test/extensions/debug_toolbar_settings.py" +target = "src/django-mongodb-extensions/django_mongodb_extensions/mongo_settings.py" + +[tool.django_mongodb_cli.test.django-mongodb-extensions.settings.module] +test = "django_mongodb_extensions.mongo_settings" +migrations = "django_mongodb_extensions.mongo_settings" + +[tool.django_mongodb_cli.test.django-allauth] +test_command = "pytest" +test_dir = "src/django-allauth" +clone_dir = "src/django-allauth" +test_dirs = [ + "src/django-allauth/allauth/usersessions/tests", + "src/django-allauth/allauth/core/tests", + "src/django-allauth/allauth/core/internal/tests", + "src/django-allauth/allauth/tests", + "src/django-allauth/allauth/mfa/recovery_codes/tests", + "src/django-allauth/allauth/mfa/webauthn/tests", + "src/django-allauth/allauth/mfa/totp/tests", + "src/django-allauth/allauth/mfa/base/tests", + "src/django-allauth/allauth/socialaccount/providers/oauth2/tests", + "src/django-allauth/allauth/socialaccount/tests", + "src/django-allauth/allauth/socialaccount/internal/tests", + "src/django-allauth/allauth/templates/tests", + "src/django-allauth/allauth/headless/usersessions/tests", + "src/django-allauth/allauth/headless/tests", + "src/django-allauth/allauth/headless/spec/tests", + "src/django-allauth/allauth/headless/internal/tests", + "src/django-allauth/allauth/headless/mfa/tests", + "src/django-allauth/allauth/headless/socialaccount/tests", + "src/django-allauth/allauth/headless/contrib/ninja/tests", + "src/django-allauth/allauth/headless/contrib/rest_framework/tests", + "src/django-allauth/allauth/headless/account/tests", + "src/django-allauth/allauth/headless/base/tests", + "src/django-allauth/allauth/account/tests", + "src/django-allauth/tests" +] + +[tool.django_mongodb_cli.test.django-allauth.apps_file] +source = "test/apps/allauth.py" +target = "src/django-allauth/allauth/mongo_apps.py" + +[tool.django_mongodb_cli.test.django-allauth.settings.test] +source = "test/settings/allauth.py" +target = "src/django-allauth/allauth/mongo_settings.py" + +[tool.django_mongodb_cli.test.django-allauth.settings.migrations] +source = "test/settings/allauth.py" +target = "src/django-allauth/allauth/mongo_settings.py" + +[tool.django_mongodb_cli.test.django-allauth.settings.module] +test = "allauth.mongo_settings" +migrations = "allauth.mongo_settings" + +[tool.django_mongodb_cli.test.django-allauth.migrations_dir] +source = "mongo_migrations" +target = "src/django-allauth/allauth/mongo_migrations" diff --git a/qe.py b/qe.py index b972224..d55e19f 100644 --- a/qe.py +++ b/qe.py @@ -1,32 +1,28 @@ -from bson.binary import STANDARD from bson.codec_options import CodecOptions from pymongo import MongoClient -from pymongo.encryption import ClientEncryption +from pymongo.encryption import ClientEncryption, AutoEncryptionOpts from pymongo.errors import EncryptedCollectionError -from django_mongodb_backend.encryption import ( - get_auto_encryption_opts, - get_kms_providers, - get_key_vault_namespace, -) -kms_providers = get_kms_providers() -key_vault_namespace = get_key_vault_namespace() +from django_mongodb_backend.encryption import KMS_PROVIDERS + +KEY_VAULT_NAMESPACE = "encryption.__keyVault" client = MongoClient( - auto_encryption_opts=get_auto_encryption_opts( - key_vault_namespace=key_vault_namespace, - kms_providers=kms_providers, + auto_encryption_opts=AutoEncryptionOpts( + key_vault_namespace=KEY_VAULT_NAMESPACE, + kms_providers=KMS_PROVIDERS, ) ) -codec_options = CodecOptions(uuid_representation=STANDARD) +codec_options = CodecOptions() client_encryption = ClientEncryption( - kms_providers, key_vault_namespace, client, codec_options + KMS_PROVIDERS, KEY_VAULT_NAMESPACE, client, codec_options ) -client.drop_database("test") - -database = client["test"] +COLLECTION_NAME = "patient" +DB_NAME = "qe" +client.drop_database(DB_NAME) +database = client[DB_NAME] encrypted_fields = { "fields": [ @@ -42,10 +38,10 @@ ] } try: - encrypted_collection = client_encryption.create_encrypted_collection( - database, "encrypted_collection", encrypted_fields, "local" + collection = client_encryption.create_encrypted_collection( + database, "patient", encrypted_fields, "local" ) - patient_document = { + patient = { "patientName": "Jon Doe", "patientId": 12345678, "patientRecord": { @@ -57,9 +53,9 @@ "billAmount": 1500, }, } - encrypted_collection = client["test"]["encrypted_collection"] - encrypted_collection.insert_one(patient_document) - print(encrypted_collection.find_one({"patientRecord.ssn": "987-65-4320"})) + collection = client[DB_NAME][COLLECTION_NAME] + collection.insert_one(patient) + print(collection.find_one({"patientRecord.ssn": "987-65-4320"})) except EncryptedCollectionError as e: print(f"Encrypted collection error: {e}") diff --git a/test/settings/django.py b/test/settings/django.py index edfe5d6..6a2d870 100644 --- a/test/settings/django.py +++ b/test/settings/django.py @@ -1,35 +1,131 @@ import os +from bson.binary import Binary from django_mongodb_backend import encryption, parse_uri +from pymongo.encryption import AutoEncryptionOpts -# Queryable Encryption settings -KEY_VAULT_NAMESPACE = encryption.get_key_vault_namespace() -KMS_PROVIDERS = encryption.get_kms_providers() -KMS_PROVIDER = encryption.KMS_PROVIDER -AUTO_ENCRYPTION_OPTS = encryption.get_auto_encryption_opts( - key_vault_namespace=KEY_VAULT_NAMESPACE, - kms_providers=KMS_PROVIDERS, -) - -ENCRYPTED_DB_ALIAS = encryption.ENCRYPTED_DB_ALIAS -ENCRYPTED_APPS = encryption.ENCRYPTED_APPS - +EXPECTED_ENCRYPTED_FIELDS_MAP = { + "billing": { + "fields": [ + { + "bsonType": "string", + "path": "cc_type", + "queries": {"queryType": "equality"}, + "keyId": Binary(b" \x901\x89\x1f\xafAX\x9b*\xb1\xc7\xc5\xfdl\xa4", 4), + }, + { + "bsonType": "long", + "path": "cc_number", + "queries": {"queryType": "equality"}, + "keyId": Binary( + b"\x97\xb4\x9d\xb8\xd5\xa6Ay\x85\xfe\x00\xc0\xd4{\xa2\xff", 4 + ), + }, + { + "bsonType": "decimal", + "path": "account_balance", + "queries": {"queryType": "range"}, + "keyId": Binary(b"\xcc\x01-s\xea\xd9B\x8d\x80\xd7\xf8!n\xc6\xf5U", 4), + }, + ] + }, + "patientrecord": { + "fields": [ + { + "bsonType": "string", + "path": "ssn", + "queries": {"queryType": "equality"}, + "keyId": Binary( + b"\x14F\x89\xde\x8d\x04K7\xa9\x9a\xaf_\xca\x8a\xfb&", 4 + ), + }, + { + "bsonType": "date", + "path": "birth_date", + "queries": {"queryType": "range"}, + "keyId": Binary(b"@\xdd\xb4\xd2%\xc2B\x94\xb5\x07\xbc(ER[s", 4), + }, + { + "bsonType": "binData", + "path": "profile_picture", + "queries": {"queryType": "equality"}, + "keyId": Binary(b"Q\xa2\xebc!\xecD,\x8b\xe4$\xb6ul9\x9a", 4), + }, + { + "bsonType": "int", + "path": "patient_age", + "queries": {"queryType": "range"}, + "keyId": Binary(b"\ro\x80\x1e\x8e1K\xde\xbc_\xc3bi\x95\xa6j", 4), + }, + { + "bsonType": "double", + "path": "weight", + "queries": {"queryType": "range"}, + "keyId": Binary( + b"\x9b\xfd:n\xe1\xd0N\xdd\xb3\xe7e)\x06\xea\x8a\x1d", 4 + ), + }, + ] + }, + "patient": { + "fields": [ + { + "bsonType": "int", + "path": "patient_id", + "queries": {"queryType": "equality"}, + "keyId": Binary(b"\x8ft\x16:\x8a\x91D\xc7\x8a\xdf\xe5O\n[\xfd\\", 4), + }, + { + "bsonType": "string", + "path": "patient_name", + "keyId": Binary( + b"<\x9b\xba\xeb:\xa4@m\x93\x0e\x0c\xcaN\x03\xfb\x05", 4 + ), + }, + { + "bsonType": "string", + "path": "patient_notes", + "queries": {"queryType": "equality"}, + "keyId": Binary(b"\x01\xe7\xd1isnB$\xa9(gwO\xca\x10\xbd", 4), + }, + { + "bsonType": "date", + "path": "registration_date", + "queries": {"queryType": "equality"}, + "keyId": Binary(b"F\xfb\xae\x82\xd5\x9a@\xee\xbfJ\xaf#\x9c:-I", 4), + }, + { + "bsonType": "bool", + "path": "is_active", + "queries": {"queryType": "equality"}, + "keyId": Binary(b"\xb2\xb5\xc4K53A\xda\xb9V\xa6\xa9\x97\x94\xea;", 4), + }, + ] + }, +} +DATABASE_ROUTERS = [encryption.EncryptedRouter()] DATABASE_URL = os.environ.get("MONGODB_URI", "mongodb://localhost:27017") +KEY_VAULT_NAMESPACE = "encrypted.__keyvault" DATABASES = { "default": parse_uri( DATABASE_URL, db_name="test", ), - ENCRYPTED_DB_ALIAS: parse_uri( + "my_encrypted_database": parse_uri( DATABASE_URL, - options={"auto_encryption_opts": AUTO_ENCRYPTION_OPTS}, - db_name=ENCRYPTED_DB_ALIAS, + options={ + "auto_encryption_opts": AutoEncryptionOpts( + key_vault_namespace=KEY_VAULT_NAMESPACE, + kms_providers=encryption.KMS_PROVIDERS, + # schema_map=EXPECTED_ENCRYPTED_FIELDS_MAP, + ) + }, + db_name="my_encrypted_database", ), } +DATABASES["my_encrypted_database"]["KMS_CREDENTIALS"] = encryption.KMS_CREDENTIALS DEFAULT_AUTO_FIELD = "django_mongodb_backend.fields.ObjectIdAutoField" PASSWORD_HASHERS = ("django.contrib.auth.hashers.MD5PasswordHasher",) SECRET_KEY = "django_tests_secret_key" USE_TZ = False - -DATABASE_ROUTERS = [encryption.EncryptedRouter()] diff --git a/test/settings/django_migrations.py b/test/settings/django_migrations.py new file mode 100644 index 0000000..2737996 --- /dev/null +++ b/test/settings/django_migrations.py @@ -0,0 +1,42 @@ +import sys +import os + +from django_mongodb_backend import encryption, parse_uri + +# Queryable Encryption settings +KEY_VAULT_NAMESPACE = encryption.get_key_vault_namespace() +KMS_PROVIDERS = encryption.get_kms_providers() +KMS_PROVIDER = encryption.KMS_PROVIDER +AUTO_ENCRYPTION_OPTS = encryption.get_auto_encryption_opts( + key_vault_namespace=KEY_VAULT_NAMESPACE, + kms_providers=KMS_PROVIDERS, +) + +ENCRYPTED_DB_ALIAS = encryption.ENCRYPTED_DB_ALIAS +ENCRYPTED_APPS = encryption.ENCRYPTED_APPS + +DATABASE_URL = os.environ.get("MONGODB_URI", "mongodb://localhost:27017") +DATABASES = { + "default": parse_uri( + DATABASE_URL, + db_name="test", + ), + ENCRYPTED_DB_ALIAS: parse_uri( + DATABASE_URL, + options={"auto_encryption_opts": AUTO_ENCRYPTION_OPTS}, + db_name=ENCRYPTED_DB_ALIAS, + ), +} + +DEFAULT_AUTO_FIELD = "django_mongodb_backend.fields.ObjectIdAutoField" +PASSWORD_HASHERS = ("django.contrib.auth.hashers.MD5PasswordHasher",) +SECRET_KEY = "django_tests_secret_key" +USE_TZ = False + +DATABASE_ROUTERS = [encryption.EncryptedRouter()] + +sys.path.append(os.path.join(os.curdir, "src", "django-mongodb-backend", "tests")) + +INSTALLED_APPS = [ + "encryption_", +]