diff --git a/justfile b/justfile index 1eaec45..2a83674 100644 --- a/justfile +++ b/justfile @@ -27,8 +27,8 @@ format: format_just format_python @pre_commit: format lint test -@test: - uv run pytest +@test *FLAGS: + uv run pytest {{ FLAGS }} @test_with_coverage: uv run pytest --cov --cov-config=pyproject.toml --cov-report=html diff --git a/pyproject.toml b/pyproject.toml index 8d729a4..84afb87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dev-dependencies = [ ] [project.scripts] -beatdrop = "dj_beat_drop.main_cli:main" +beatdrop = "dj_beat_drop.cli:main" [project.urls] Homepage = "https://github.com/epicserve/dj-beat-drop" diff --git a/src/dj_beat_drop/__main__.py b/src/dj_beat_drop/__main__.py new file mode 100644 index 0000000..92c7a9a --- /dev/null +++ b/src/dj_beat_drop/__main__.py @@ -0,0 +1,10 @@ +# my_package/__main__.py +from .cli import main as cli_main + + +def main(): + cli_main() + + +if __name__ == "__main__": + main() diff --git a/src/dj_beat_drop/main_cli.py b/src/dj_beat_drop/cli.py similarity index 87% rename from src/dj_beat_drop/main_cli.py rename to src/dj_beat_drop/cli.py index 325b0e9..d666f1d 100644 --- a/src/dj_beat_drop/main_cli.py +++ b/src/dj_beat_drop/cli.py @@ -5,7 +5,10 @@ from packaging.version import parse from dj_beat_drop.new import handle_new -from dj_beat_drop.utils import color +from dj_beat_drop.new_app import create_new_app +from dj_beat_drop.utils import ( + color, +) def get_ascii_logo(): @@ -60,6 +63,13 @@ def main_callback( pass +@main_command.command() +def new_app( + app_relative_path: str = typer.Argument(..., help="App relative path (e.g. 'accounts' or 'apps/accounts')."), +): + create_new_app(app_relative_path) + + @main_command.command() def new( name: str | None = typer.Argument(None, help="Project name (e.g. 'example_project' or 'example-project')."), diff --git a/src/dj_beat_drop/new.py b/src/dj_beat_drop/new.py index df0872c..7942118 100644 --- a/src/dj_beat_drop/new.py +++ b/src/dj_beat_drop/new.py @@ -6,16 +6,7 @@ from InquirerPy import inquirer from dj_beat_drop import utils -from dj_beat_drop.utils import color - - -def rename_template_files(project_dir): - # Rename .py-tpl files to .py - for file in project_dir.rglob("*"): - if file.is_file() is False: - continue - if file.name.endswith(".py-tpl"): - os.rename(file, file.with_name(file.name[:-4])) +from dj_beat_drop.utils import color, overwrite_directory_prompt, rename_template_files, replace_variables_in_directory def replace_settings_with_environs(content: str) -> str: @@ -57,21 +48,6 @@ def create_dot_envfile(project_dir, context: dict[str, str]): f.write(env_content) -def replace_variables(project_dir, context: dict[str, str], initialize_env): - for file in project_dir.rglob("*"): - if file.is_file() is False: - continue - with file.open() as f: - content = f.read() - for variable, value in context.items(): - content = content.replace(f"{{{{ {variable} }}}}", value) - if str(file.relative_to(project_dir)) == "config/settings.py" and initialize_env is True: - content = replace_settings_with_environs(content) - create_dot_envfile(project_dir, context) - with file.open("w") as f: - f.write(content) - - def create_new_project( *, name: str, use_lts: bool, project_dir: Path, initialize_uv: bool, initialize_env: bool ) -> dict[str, str]: @@ -82,11 +58,17 @@ def create_new_project( os.rename(project_dir / "project_name", project_dir / "config") rename_template_files(project_dir) - replace_variables( + replace_variables_in_directory( project_dir, template_context, - initialize_env, ) + if initialize_env is True: + with (project_dir / "config" / "settings.py").open("r") as f: + content = f.read() + content = replace_settings_with_environs(content) + with (project_dir / "config" / "settings.py").open("w") as f: + f.write(content) + create_dot_envfile(project_dir, template_context) if initialize_uv is True: os.chdir(project_dir) @@ -116,16 +98,7 @@ def handle_new(name: str, use_lts: bool, overwrite_target_dir: bool) -> None: return project_dir = Path.cwd() / name - if project_dir.exists(): - if overwrite_target_dir is False: - overwrite_response = inquirer.confirm( - message=f"The directory '{name}' already exists. Do you want to overwrite it?", - default=True, - ).execute() - if overwrite_response is False: - color.red("Operation cancelled.") - return - shutil.rmtree(project_dir) + overwrite_directory_prompt(project_dir, overwrite_target_dir) initialize_uv = inquirer.confirm(message="Initialize your project with UV?", default=True).execute() initialize_env = inquirer.confirm( diff --git a/src/dj_beat_drop/new_app.py b/src/dj_beat_drop/new_app.py new file mode 100644 index 0000000..4d3df37 --- /dev/null +++ b/src/dj_beat_drop/new_app.py @@ -0,0 +1,44 @@ +import shutil +from pathlib import Path + +import typer + +from dj_beat_drop.utils import ( + color, + overwrite_directory_prompt, + rename_template_files, + replace_variables_in_directory, + snake_or_kebab_to_camel_case, +) + + +def create_new_app( + app_rel_path: str = typer.Argument(..., help="App relative path (e.g. 'accounts' or 'apps/accounts')."), +) -> dict[str, str]: + template_dir_src = Path(__file__).parent / "templates" / "app_template" + + # normalize to lowercase + app_rel_path = app_rel_path.lower() + + app_directory = Path.cwd() / app_rel_path + + app_name = app_directory.name + app_name_space = app_rel_path.replace("/", ".") if "/" in app_rel_path else app_name + overwrite_directory_prompt(app_directory) + shutil.copytree(template_dir_src, app_directory) + rename_template_files(app_directory) + template_context = { + "app_name": app_name_space, + "camel_case_app_name": snake_or_kebab_to_camel_case(app_name), + } + replace_variables_in_directory(app_directory, template_context) + color.green(f"\nSuccessfully created app '{app_name}' in {app_directory}") + + # Reminder to register the app + color.orange("\nRemember to add your app to INSTALLED_APPS in your project's settings:") + print("\n INSTALLED_APPS = [") + print(" ...,") + print(f" '{app_name_space}',") + print(" ]") + + return template_context diff --git a/src/dj_beat_drop/templates/app_template/__init__.py-tpl b/src/dj_beat_drop/templates/app_template/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/dj_beat_drop/templates/app_template/admin.py-tpl b/src/dj_beat_drop/templates/app_template/admin.py-tpl new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/src/dj_beat_drop/templates/app_template/admin.py-tpl @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/src/dj_beat_drop/templates/app_template/apps.py-tpl b/src/dj_beat_drop/templates/app_template/apps.py-tpl new file mode 100644 index 0000000..b705352 --- /dev/null +++ b/src/dj_beat_drop/templates/app_template/apps.py-tpl @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class {{ camel_case_app_name }}Config(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = '{{ app_name }}' diff --git a/src/dj_beat_drop/templates/app_template/migrations/__init__.py-tpl b/src/dj_beat_drop/templates/app_template/migrations/__init__.py-tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/dj_beat_drop/templates/app_template/models.py-tpl b/src/dj_beat_drop/templates/app_template/models.py-tpl new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/src/dj_beat_drop/templates/app_template/models.py-tpl @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/src/dj_beat_drop/templates/app_template/tests.py-tpl b/src/dj_beat_drop/templates/app_template/tests.py-tpl new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/src/dj_beat_drop/templates/app_template/tests.py-tpl @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/src/dj_beat_drop/templates/app_template/views.py-tpl b/src/dj_beat_drop/templates/app_template/views.py-tpl new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/src/dj_beat_drop/templates/app_template/views.py-tpl @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/src/dj_beat_drop/utils.py b/src/dj_beat_drop/utils.py index 2d6b1a1..3c0f616 100644 --- a/src/dj_beat_drop/utils.py +++ b/src/dj_beat_drop/utils.py @@ -1,7 +1,12 @@ +import os +import re import secrets +import shutil from functools import lru_cache +from pathlib import Path import requests +from InquirerPy import inquirer class Color: @@ -72,3 +77,41 @@ def get_secret_key(): """Return a 50 character random string usable as a SECRET_KEY setting value.""" chars = "abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)" return "".join(secrets.choice(chars) for _ in range(50)) + + +def rename_template_files(project_dir): + # Rename .py-tpl files to .py + for file in project_dir.rglob("*"): + if file.is_file() is False: + continue + if file.name.endswith(".py-tpl"): + os.rename(file, file.with_name(file.name[:-4])) + + +def overwrite_directory_prompt(directory: Path, skip_confirm_prompt: bool = False) -> bool: + if directory.exists(): + if skip_confirm_prompt is False: + overwrite_response = inquirer.confirm( + message=f"The directory '{directory}' already exists. Do you want to overwrite it?", + default=True, + ).execute() + if overwrite_response is False: + color.red("Operation cancelled.") + return + shutil.rmtree(directory) + + +def replace_variables_in_directory(directory: Path, context: dict[str, str]): + for file in directory.rglob("*"): + if file.is_file() is False: + continue + with file.open() as f: + content = f.read() + for variable, value in context.items(): + content = content.replace(f"{{{{ {variable} }}}}", value) + with file.open("w") as f: + f.write(content) + + +def snake_or_kebab_to_camel_case(text): + return "".join([word.capitalize() for word in re.split(r"[-_]", text)]) diff --git a/tests/base_test.py b/tests/base_test.py new file mode 100644 index 0000000..28716de --- /dev/null +++ b/tests/base_test.py @@ -0,0 +1,10 @@ +from unittest import TestCase + + +class SafeDict(dict): + def __missing__(self, key): + """Make sure to keep the original placeholder if the key is missing.""" + return f"{{{key}}}" + + +class BaseTest(TestCase): ... diff --git a/tests/test_new_app.py b/tests/test_new_app.py new file mode 100644 index 0000000..90f3ca3 --- /dev/null +++ b/tests/test_new_app.py @@ -0,0 +1,80 @@ +import re +import shutil +from pathlib import Path + +from dj_beat_drop.new_app import create_new_app +from tests.base_test import BaseTest, SafeDict + +FILE_ASSERTIONS = { + "migrations/__init__.py": [], + "__init__.py": [], + "admin.py": [ + "from django.contrib import admin", + ], + "apps.py": [ + "class {{ camel_case_app_name }}Config(AppConfig):", + "default_auto_field = 'django.db.models.BigAutoField'", + "name = '{{ app_name }}'", + ], + "models.py": [ + "from django.db import models", + ], + "tests.py": [ + "from django.test import TestCase", + ], + "views.py": [ + "from django.shortcuts import render", + ], +} + + +class TestNewAppCommand(BaseTest): + app_dir: Path + + def remove_app_dir(self): + if self.app_dir.exists(): + shutil.rmtree(self.app_dir) + + def _setup_app_dir(self, app_rel_path: str): + self.app_dir = Path(__file__).parent / app_rel_path + self.addCleanup(self.remove_app_dir) + self.remove_app_dir() + + @staticmethod + def assert_files_are_correct( + *, + template_context: dict[str, str], + app_dir: Path, + ): + assertion_context = template_context.copy() + for file in app_dir.rglob("*"): + relative_path = str(file.relative_to(app_dir)) + assertions = [] + if relative_path in FILE_ASSERTIONS: + assertions.extend(FILE_ASSERTIONS.get(relative_path, [])) + assert file.exists() is True, f"File does not exist: {file}" + with file.open("r") as f: + content = f.read() + for assertion_pattern in assertions: + if re.match(r".*{{\s[_a-z]+\s}}.*", assertion_pattern) is None: + assertion = assertion_pattern + else: + formatted_assertion = assertion_pattern.replace("{{ ", "{").replace(" }}", "}") + assertion = formatted_assertion.format_map(SafeDict(assertion_context)) + assert assertion in content, f"Assertion failed for {relative_path}: {assertion}" + + def test_new_app_with_sub_dir(self): + app_rel_path = "apps/accounts" + self._setup_app_dir(app_rel_path) + kwargs = {} + kwargs["template_context"] = create_new_app(app_rel_path) + kwargs["app_dir"] = self.app_dir + self.assert_files_are_correct(**kwargs) + + def test_new_app_with_same_dir(self): + app_rel_path = "accounts" + self._setup_app_dir(app_rel_path) + kwargs = {} + kwargs["template_context"] = create_new_app(app_rel_path) + kwargs["app_dir"] = self.app_dir + self.assert_files_are_correct(**kwargs) diff --git a/tests/test_new_command.py b/tests/test_new_command.py index f089522..588603a 100644 --- a/tests/test_new_command.py +++ b/tests/test_new_command.py @@ -3,9 +3,9 @@ import shutil import string from pathlib import Path -from unittest import TestCase from dj_beat_drop.new import create_new_project +from tests.base_test import BaseTest, SafeDict ENV_SECRET_KEY_PATTERN = 'SECRET_KEY = env.str("SECRET_KEY")' # noqa: S105 FILE_ASSERTIONS = { @@ -58,13 +58,7 @@ } -class SafeDict(dict): - def __missing__(self, key): - """Make sure to keep the original placeholder if the key is missing.""" - return f"{{{key}}}" - - -class TestNewCommand(TestCase): +class TestNewCommand(BaseTest): @staticmethod def _generate_random_hash(length=6): characters = string.ascii_lowercase + string.digits