Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a new app command #8

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions src/dj_beat_drop/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# my_package/__main__.py
from .cli import main as cli_main


def main():
cli_main()


if __name__ == "__main__":
main()
12 changes: 11 additions & 1 deletion src/dj_beat_drop/main_cli.py → src/dj_beat_drop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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')."),
Expand Down
47 changes: 10 additions & 37 deletions src/dj_beat_drop/new.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
44 changes: 44 additions & 0 deletions src/dj_beat_drop/new_app.py
Original file line number Diff line number Diff line change
@@ -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
Empty file.
3 changes: 3 additions & 0 deletions src/dj_beat_drop/templates/app_template/admin.py-tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.contrib import admin

# Register your models here.
6 changes: 6 additions & 0 deletions src/dj_beat_drop/templates/app_template/apps.py-tpl
Original file line number Diff line number Diff line change
@@ -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 }}'
Empty file.
3 changes: 3 additions & 0 deletions src/dj_beat_drop/templates/app_template/models.py-tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.db import models

# Create your models here.
3 changes: 3 additions & 0 deletions src/dj_beat_drop/templates/app_template/tests.py-tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.test import TestCase

# Create your tests here.
3 changes: 3 additions & 0 deletions src/dj_beat_drop/templates/app_template/views.py-tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.shortcuts import render

# Create your views here.
43 changes: 43 additions & 0 deletions src/dj_beat_drop/utils.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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)])
10 changes: 10 additions & 0 deletions tests/base_test.py
Original file line number Diff line number Diff line change
@@ -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): ...
80 changes: 80 additions & 0 deletions tests/test_new_app.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 2 additions & 8 deletions tests/test_new_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down