Skip to content
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
11 changes: 4 additions & 7 deletions .github/workflows/startproject.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,22 +51,19 @@ jobs:
libpq-dev

# Base Python tooling
pip install invoke poetry django
pip install invoke poetry django typer pyyaml

- name: Create test project
run: |
django-admin startproject \
--template template/ \
--extension py,Dockerfile,env,toml,yml \
test_project
python src/booster.py --config sample_config.yml

- name: Install test project
run: |
cd test_project
cd output/test_project
poetry run invoke install

- name: Check test project is valid
run: |
cd test_project
cd output/test_project
cp example.env .env
poetry run invoke check
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ coverage.xml
data/
dist/
htmlcov/
output/
site/
src/booster.egg-info/
.DS_Store
44 changes: 27 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,35 +13,45 @@ This project is intended as:

## Usage

To create a new project from this template, you must have the most recent stable version
of Django installed in order to run the `startproject` command. The simplest way to do
this is with [pipx][pipx]:
To create a new Django project using this template, you have two options:

```shell
pipx install django
### Option 1: Build the Package and Run the Installed booster Command (WIP)

1. Build the package:

```bash
python -m build
```

2. Install it with `pipx`:

```bash
pipx install dist/booster-<version>.whl
```

This will ensure that the `django-admin` command is available in your shell. From there
you can create a new project with the following command:
1. Run the booster command:

```shell
django-admin startproject --template path/to/django-template/template/ --extension py,env,sh,toml,yml --exclude nothing <project_name>
```bash
booster --config path_to_config.yml
```

### Option 2: Run Directly Without Building

If you don't want to build the package, you can run the script directly from the source:

```bash
python src/booster.py fire --config sample_config.yml
```

Both options allow you to generate a new project dynamically based on your configuration file.

> [!NOTE]
> When initiating a Django project with a custom template, be aware that directories starting with
> a dot (e.g., `.github` for GitHub Actions workflows) are not included by default. A workaround
> from Django 4.0 onwards involves using the `--exclude` option with the `startproject` command.
> Oddly, specifying `--exclude` with a non-existent directory can allow these dot-prefixed
> directories to be copied. This trick can ensure that essential configurations like `.github` are
> included in your project setup.
> The config file should specify details such as the project name. Refer to `sample_config.yml` for an example. If no configuration file is provided, the CLI will default to using `sample_config.yml`.

> [!WARNING]
> The name of your project _must_ be a valid Python package name - that means
> underscores (`_`) not hyphens (`-`) for name separators please.

Running the Django admin command will create a new project in the folder specified in
with `<project_name>`.

## Contributing

Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/0003-custom-user-model.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 0002 Use Custom User Model
# 0003 Use Custom User Model

- Date: 2024-04-10
- Author(s): [Chen Zhang][chen]
Expand Down
78 changes: 78 additions & 0 deletions docs/decisions/0004-cli-tool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# 0004 Replacing startproject with a CLI Wrapper

- Date: 2024-11-22
- Author(s): [Zahra Alizadeh][zahra]
- Status: `Draft`

## Decision

We have decided to build a custom CLI Django's built-in `startproject` command. This wrapper will run `startproject` to scaffold the initial project and then apply additional configuration and customizations based on a user-defined configuration file. This approach allows us to dynamically customize project structures based on configuration files and support reusable variants.

## Context

### Background

Django's `startproject` command provides a basic project scaffolding mechanism. While this is sufficient for standard projects, it falls short when projects require:

- Dynamic configurations, such as database engines, general settings e.g. timezone, and optional components and apps.
- Customizable directory and file structures tailored to team or project-specific needs.
- Reusability across multiple projects with different configurations or variants (e.g., Docker, Celery, or CI/CD pipelines).

### Existing Approach


The current approach uses `django-admin startproject` with the `--template` option to specify a custom project template. While this allows some customization, it introduces several issues:

1. __Complex Command Syntax__:
The command requires specifying a full template path along with multiple options like `--extension` and `--exclude`. Example:

```shell
django-admin startproject --template path/to/django-template/template/ --extension py,env,sh,toml,yml --exclude nothing <project_name>
```
This is verbose, error-prone, and hard to remember.

2. __Limited Customization__:
- The `--template` option does not support dynamic rendering of file names or content, or conditional inclusion of components.
- Custom features (e.g., Celery support or different configurations) requires separate templates or post-processing.


This approach is overly complicated, lacks flexibility, and is not scalable for dynamic, reusable templates. A simpler, more customizable solution is needed to improve developer productivity and enforce consistency.

## New Approach


The custom CLI wrapper enhances this process by:

1. Running the `startproject` command to scaffold the project.
2. Applying additional setup steps, such as:
- Modifying settings.py to add optional configurations (e.g., databases, installed apps).
- Adding optional files (e.g., .env, Dockerfiles) based on user preferences.
3. Allowing users to configure the setup via a YAML or TOML configuration file.


## Implications

### Positive Implications

1. __Flexibility__:
- Supports dynamic configurations (e.g., different database backends, optional apps).
- Simplifies the addition of reusable variants like Docker or CI/CD pipelines.

2. __Consistency__:
- Enforces consistent project structures across teams and projects.
- Reduces human error in project setup.

3. __Future Scalability__:
- New variants or configurations can be added without disrupting existing workflows.

4. __Customization__:
- Allows user-defined settings through configuration files (e.g., YAML or TOML).

### Negative Implications

1. __Initial Overhead__: Developing and testing the CLI wrapper requires upfront investment
2. __Maintenance__: The templates and wrapper tool will need to be maintained as requirements evolve.
3. __Learning Curve__: Developers familiar with startproject will need to learn the new CLI tool.

<!-- Links -->
[zahra]: mailto:zahra.alizadeh@ackama.com
19 changes: 19 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "booster"
version = "0.1.0"
description = "A CLI tool for generating Django projects for Ackama."
readme = "README.md"
requires-python = ">=3.12"
license = {text = "BSD-3-Clause"}
dependencies = [
"typer[all]>=0.9.0",
"jinja2>=3.1.0",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: I don't think jinja2 is needed any more

If we don't need it - please remove.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoops!

"pyyaml>=6.0"
]

[project.scripts]
booster = "src.booster:app"
3 changes: 3 additions & 0 deletions sample_config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
project_name: test_project
template_path: template/
output_dir: ./output/
Empty file added src/__init__.py
Empty file.
84 changes: 84 additions & 0 deletions src/booster.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import shutil
import subprocess
from pathlib import Path

import typer
import yaml
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (non-blocking): ‏Can we use toml instead?

I have a pet peeve about yaml - I find it hard to format correctly for some reason. Toml is a simpler standard, we already use it (pyproject.toml) and as an added bonus - reading toml is built into recent versions of Python: https://docs.python.org/3/library/tomllib.html

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. I'll update it.


app = typer.Typer()

BASE_DIR = Path(__file__).resolve().parent.parent
OUTPUT_DIR = BASE_DIR / "output"
DEFAULT_CONFIG = BASE_DIR / "sample_config.yml"
Comment on lines +10 to +12
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Remove these defaults.

As already mentioned - I don't think a default config file makes sense. So I also think that the output dir should either be the current working directory or come from the now mandatory config file.

This is being designed as a stand alone script, installed from a wheel using pipx. As such the BASE_DIR as described here might not actually exist as it is being run from inside a pipx virtualenv all by itself. ‏



def load_config(config_path: Path):
"""Load project settings from YAML configuration file."""
with open(config_path, "r") as file:
config = yaml.safe_load(file)
return config


def django_start_project(config):
"""Create a Django project using the specified configuration."""
project_name = config["project_name"]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Check this is a valid Python package name

Since project_name is used as the package name, it needs to be a valid. We should do some basic checks here to ensure it is:

  • a-z, 0-9
  • Lowercase
  • underscores

template_path = config.get("template_path", "template/")
output_dir = Path(config.get("output_dir")) or OUTPUT_DIR
output_dir.mkdir(parents=True, exist_ok=True)
project_path = output_dir / project_name
if project_path.exists():
typer.echo(f"Directory '{project_path}' already exists.")
if typer.confirm(
"Do you want to clear this directory and start fresh?", default=False
):
# Delete the existing directory
shutil.rmtree(project_path)
typer.echo(f"Cleared the directory: {project_path}")
else:
typer.echo("Aborting project creation.")
raise SystemExit(1)
project_path.mkdir(parents=True, exist_ok=True)

# Call django-admin startproject
typer.echo(f"Starting Django project '{project_name}' in {project_path}...")

try:
subprocess.run(
[
"django-admin",
"startproject",
project_name,
str(project_path),
"--template",
template_path,
"--extension",
"py,env,sh,toml,yml",
"--exclude",
"nothing",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: ‏Perhaps a comment here as to why we are using --exclude nothing

],
check=True,
)
typer.echo(f"Django project '{project_name}' created successfully!")
except subprocess.CalledProcessError:
typer.echo(f"Failed to create the project '{project_name}'.", err=True)
raise SystemExit(1)


@app.command()
def fire(
config_path: Path = typer.Option(
DEFAULT_CONFIG,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: ‏Make this a mandatory argument

The script cannot proceed without a config file. A default for the name of a project for example is never going to be reasonable. So might as well make it a mandatory argument.

"--config",
help="Path to the project configuration YAML file",
)
):
"""
Start a new Django project using Ackama's django template
based on a YAML configuration file.
"""
config = load_config(config_path)
django_start_project(config)


if __name__ == "__main__":
app()