3
\$\begingroup\$

What I'm Doing

I'm writing a command line utility for starting Django projects and apps with custom files.

The custom files build upon what Django offers, to make sure things like a custom auth user model and the ability to read environment variables from a .env file are added to projects from outset.

Why I'm Doing It

This is something I'm doing to get more acquainted with Django, and to improve my testing game — yes, that's why I'm writing a fruglier version of django-cookiecutter :).

What I'm Asking For

I would love to get some brutally honest review on my code. I'd especially appreciate it if you could please focus on the tests because that's the area in which I want to improve the most.

Important Files

classy_start/start.py

import enum
import pathlib
import subprocess
import sys
from typing import List, Optional

from . import file_contents, paths


@enum.unique
class Startable(enum.Enum):
    PROJECT = 0
    APP = 1


def _start(what: Startable, name: str, directory: Optional[str] = None):
    directive = f"start{what.name.lower()}"
    cmd: List[str]
    cmd = ["django-admin", directive, name]

    if directory is not None:
        cmd.append(directory)

    templates_dir_name = f"{what.name}_TEMPLATES_DIR"
    cmd.extend(["--template", str(getattr(paths, templates_dir_name))])

    try:
        subprocess.run(cmd, check=True)
    except subprocess.CalledProcessError:
        sys.exit(1)


def start_app(name: str, directory: Optional[str] = None):
    _start(Startable.APP, name, directory)


def start_project(name: str, directory: Optional[str] = None):
    _start(Startable.PROJECT, name, directory)

    follow_up_start_project(name, directory)


def follow_up_start_project(name: str, directory: Optional[str] = None):
    if directory is None:
        manage_dir = pathlib.Path(".") / name
    else:
        manage_dir = pathlib.Path(directory)

    manage_dir.resolve(strict=True)
    name_change_map = {
        "secrets.py": ".env",
        "gitignore.py": ".gitignore",
        "requirements.py": "requirements.txt",
    }

    for (old_name, new_name) in name_change_map.items():
        rename_file(old_name, new_name, base_dir=manage_dir)

    create_accounts_app(manage_dir)


def rename_file(old_name: str, new_name: str, base_dir: pathlib.Path):
    (base_dir / old_name).rename(base_dir / new_name)


def create_accounts_app(directory: pathlib.Path):
    dest = directory / "accounts"
    dest.mkdir()

    start_app("accounts", dest)

    for (filename, content) in [
        ("models.py", file_contents.auth_user_model_file_content),
        ("admin.py", file_contents.auth_user_admin_file_content),
    ]:
        write_file(dest / filename, content)


def write_file(file: pathlib.Path, content: str):
    file.touch()
    file.write_text(content)

tests/test_start.py

import pathlib
import random
import shlex
import string
from unittest import mock

import pytest

from classy_start.file_contents import (
    auth_user_model_file_content,
    auth_user_admin_file_content,
)
from classy_start.paths import APP_TEMPLATES_DIR, PROJECT_TEMPLATES_DIR
from classy_start.start import (
    start_app,
    start_project,
    follow_up_start_project,
    rename_file,
    create_accounts_app,
    write_file,
)

def test_start_app(fake_process):

    fake_process.keep_last_process(True)
    fake_process.register_subprocess([fake_process.any()])

    start_app("appify")

    count = fake_process.call_count(
        shlex.split(f"django-admin startapp appify --template '{APP_TEMPLATES_DIR!s}'")
    )
    assert count == 1


@mock.patch("classy_start.start.follow_up_start_project")
def test_start_project(mock_follow_up, fake_process):

    fake_process.keep_last_process(True)
    fake_process.register_subprocess([fake_process.any()])

    start_project("projectible", ".")

    count = fake_process.call_count(
        shlex.split(
            f"django-admin startproject projectible . --template '{PROJECT_TEMPLATES_DIR!s}'"
        )
    )
    assert count == 1

    mock_follow_up.assert_called_once_with("projectible", ".")


@mock.patch("pathlib.Path.resolve")
@mock.patch("classy_start.start.rename_file")
@mock.patch("classy_start.start.create_accounts_app")
def test_follow_up_start_project(
    mock_create_accounts_app, mock_rename_file, _mock_resolve
):
    """
    Assert that ~.follow_up_start_project() calls ~.rename_file() the correct number of
    times and with the correct arguments. And that it also calls
    ~.create_accounts_app() with the correct arguments.
    """
    follow_up_start_project("projectible")

    assert mock_rename_file.call_count == 3
    mock_rename_file.assert_has_calls(
        [
            mock.call("secrets.py", ".env", base_dir=pathlib.Path("projectible")),
            mock.call(
                "gitignore.py", ".gitignore", base_dir=pathlib.Path("projectible")
            ),
            mock.call(
                "requirements.py",
                "requirements.txt",
                base_dir=pathlib.Path("projectible"),
            ),
        ]
    )
    mock_create_accounts_app.assert_called_once_with(pathlib.Path("projectible"))

    # reset the **all used** mocks
    mock_rename_file.reset_mock()
    mock_create_accounts_app.reset_mock()

    follow_up_start_project("projectible", pathlib.Path("."))

    assert mock_rename_file.call_count == 3
    mock_rename_file.assert_has_calls(
        [
            mock.call("secrets.py", ".env", base_dir=pathlib.Path(".")),
            mock.call("gitignore.py", ".gitignore", base_dir=pathlib.Path(".")),
            mock.call(
                "requirements.py", "requirements.txt", base_dir=pathlib.Path("."),
            ),
        ]
    )
    mock_create_accounts_app.assert_called_once_with(pathlib.Path("."))


@mock.patch("pathlib.Path.rename")
def test_rename_file(mock_rename):
    """
    Assert that ~.rename_file() calls pathlib.Path.rename with the correct target.
    """
    base_dir = pathlib.Path(".") / "projectible"
    rename_file("old_name", "new_name", base_dir)

    mock_rename.assert_called_once_with(base_dir / "new_name")


@pytest.mark.parametrize(
    "manage_dir", [pathlib.Path("."), pathlib.Path(".") / "projectible"]
)
@mock.patch("pathlib.Path.mkdir")
@mock.patch("classy_start.start.start_app")
@mock.patch("classy_start.start.write_file")
def test_create_accounts_app(mock_write_file, mock_start_app, _mock_mkdir, manage_dir):
    """
    Assert that ~.create_accoung_app() calls ~.start_app() with the
    correct arguments. And that it calls pathlib.Path.write_text the
    correct number of times and with the correct arguments.
    """
    create_accounts_app(manage_dir)

    accounts_app_dir = manage_dir / "accounts"
    model_file = accounts_app_dir / "models.py"
    admin_file = accounts_app_dir / "admin.py"

    mock_start_app.assert_called_once_with("accounts", accounts_app_dir)

    assert mock_write_file.call_count == 2
    assert mock_write_file.has_calls(
        [
            mock.call(model_file, auth_user_model_file_content,),
            mock.call(admin_file, auth_user_admin_file_content,),
        ]
    )


@mock.patch("pathlib.Path.touch")
@mock.patch("pathlib.Path.write_text")
def test_write_file(mock_write_text, mock_touch):
    file = pathlib.Path("project") / "app" / "an_app_file.py"
    content = "".join(random.sample(string.printable, 64))

    write_file(file, content)

    mock_touch.assert_called_once()
    mock_write_text.assert_called_once_with(content)

GitHub Repository

https://github.com/mfonism/django-classy-start

\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Browse other questions tagged or ask your own question.