django-starter-cdc 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ .idea
2
+ .vscode
3
+ *.code-workspace
4
+ *.pyc
5
+ *~
6
+ .venv
7
+ venv
8
+ db.sqlite3
9
+ .sass-cache/
10
+ celerybeat-schedule
11
+ chrisdevcode/localsettings.py
12
+ chrisdevcode/local.py
13
+ static_root/
14
+ node_modules/
15
+ # ai assistants
16
+ .playwright-mcp/
17
+ .claude/settings.local.json
18
+ media/
19
+ docs/_build
20
+ _dist/
21
+ cli/dist/
22
+ .env
23
+ .env.prod
24
+ .env.production
25
+ .DS_Store
26
+ .direnv
27
+ .envrc
28
+ # built CSS and vendored JS/fonts (npm run build)
29
+ static/css/
30
+ static/vendor/
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Achinga
4
+ Copyright (c) 2026 Elodin Labs LLC (SaaS Pegasus)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-starter-cdc
3
+ Version: 0.1.0
4
+ Summary: Generate a new Django 6.1 project from django-starter: full-stack (HTMX, Alpine.js, Tailwind) or API-only.
5
+ Project-URL: Homepage, https://github.com/achingachris/django-starter
6
+ Project-URL: Source, https://github.com/achingachris/django-starter
7
+ Project-URL: Issues, https://github.com/achingachris/django-starter/issues
8
+ Author: Chris Achinga
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: allauth,django,drf,generator,htmx,starter,template
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Framework :: Django
15
+ Classifier: Framework :: Django :: 6.1
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Software Development :: Code Generators
21
+ Requires-Python: >=3.14
22
+ Description-Content-Type: text/markdown
23
+
24
+ # django-starter-cdc
25
+
26
+ Generate a new Django 6.1 project from [django-starter](https://github.com/achingachris/django-starter):
27
+ either a full-stack app (Django templates, HTMX, Alpine.js, Tailwind) or an API-only backend
28
+ (Django REST Framework + allauth headless).
29
+
30
+ The generator has no dependencies of its own. It needs **Python 3.14 or newer**: on an older Python,
31
+ pip stops with "requires a different Python". The projects it creates are managed with
32
+ [uv](https://docs.astral.sh/uv/) (plus npm for the full variant), so install uv however you install
33
+ the generator.
34
+
35
+ ## Install
36
+
37
+ **With uv (recommended).** `uvx` runs the generator in a throwaway environment, so there is nothing to
38
+ install or upgrade, and uv fetches Python 3.14 for you if needed:
39
+
40
+ ```bash
41
+ uvx django-starter-cdc new myproject
42
+ ```
43
+
44
+ **With pip and a virtual environment.** Following the
45
+ [Python Packaging User Guide](https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/),
46
+ create and activate a virtual environment with Python 3.14, then install the package into it:
47
+
48
+ ```bash
49
+ python3.14 -m venv .venv
50
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
51
+ python -m pip install --upgrade pip
52
+ python -m pip install django-starter-cdc
53
+ django-starter-cdc --version
54
+ ```
55
+
56
+ Run `deactivate` when you're done. `python -m django_starter_cdc` works too, in place of the
57
+ `django-starter-cdc` command. To upgrade later, run `python -m pip install --upgrade django-starter-cdc`.
58
+
59
+ **With pipx**, to keep the command available everywhere without managing a virtual environment yourself:
60
+
61
+ ```bash
62
+ pipx install --python python3.14 django-starter-cdc
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ ```bash
68
+ django-starter-cdc new myproject # full variant
69
+ django-starter-cdc new myproject --api # API-only variant
70
+ ```
71
+
72
+ (With uv, prefix these with `uvx`.)
73
+
74
+ This downloads the django-starter release that matches the generator's version, renders the
75
+ chosen variant into `./myproject`, writes a `.env` with a fresh `SECRET_KEY`, runs `git init`
76
+ and then `make init` (installs dependencies and migrates the SQLite database). Then:
77
+
78
+ ```bash
79
+ cd myproject
80
+ make start
81
+ ```
82
+
83
+ and open <http://localhost:8000/admin/> and <http://localhost:8000/api/schema/swagger-ui/>.
84
+
85
+ ### Options
86
+
87
+ ```
88
+ django-starter-cdc new <name> [--api] [--email EMAIL] [--ref REF | --source PATH]
89
+ [--no-install] [--no-git] [--dir PATH]
90
+ ```
91
+
92
+ | Option | What it does |
93
+ | --- | --- |
94
+ | `<name>` | Project name: a lowercase slug of letters, digits, `-` and `_` that starts with a letter. Used for the directory, the Python/npm package name and the `<NAME>_` env-var prefix. |
95
+ | `--api` | Generate the API-only variant (see below). |
96
+ | `--email EMAIL` | Address used for `ADMINS`, `DEFAULT_FROM_EMAIL` and the API contact (default `admin@example.com`). |
97
+ | `--ref REF` | Download a branch, tag or commit instead of the matching release, e.g. `--ref main`. |
98
+ | `--source PATH` | Copy a local django-starter checkout instead of downloading. `.git`, `node_modules`, `.venv`, `.env`, `db.sqlite3` and build output are left out. |
99
+ | `--no-install` | Skip `make init`; the manual setup steps are printed instead. |
100
+ | `--no-git` | Skip `git init`. |
101
+ | `--dir PATH` | Parent directory to create the project in (default: the current directory). |
102
+
103
+ The target directory must not exist or must be empty. If `make`, `uv` or (for the full variant) `npm`
104
+ is not installed, the generator skips `make init` and prints the equivalent steps:
105
+ `uv sync`, `npm install && npm run build` (full only) and `uv run python manage.py migrate`.
106
+
107
+ ## Variants
108
+
109
+ Both variants share the same core: Django 6.1 on Python 3.14, a custom user model, `django-allauth`
110
+ with headless mode, Django REST Framework with a `drf-spectacular` OpenAPI schema, Django's built-in
111
+ tasks framework, SQLite for local development and a Docker Compose stack (Postgres + Redis + gunicorn)
112
+ for production.
113
+
114
+ **Full (default)** adds the server-rendered web app: Django templates, HTMX and Alpine.js for
115
+ interactivity, Tailwind CSS 4 built with the Tailwind CLI (no JavaScript bundler), allauth's account
116
+ pages, a profile page, sitemaps and meta tags.
117
+
118
+ **API-only (`--api`)** has no templates (other than the allauth email templates), no front-end assets
119
+ and no npm. allauth runs with `HEADLESS_ONLY = True`, so authentication is a JSON API meant for a
120
+ separate front end or mobile app.
121
+
122
+ ## Authentication API
123
+
124
+ allauth headless serves its auth API under `/_allauth/`. Mobile and other non-browser clients use the
125
+ `app` client, which authenticates with a session token instead of cookies:
126
+
127
+ | Endpoint | Purpose |
128
+ | --- | --- |
129
+ | `POST /_allauth/app/v1/auth/signup` | Create an account. |
130
+ | `POST /_allauth/app/v1/auth/login` | Log in with email and password. |
131
+ | `GET /_allauth/app/v1/auth/session` | Check the current session. |
132
+ | `DELETE /_allauth/app/v1/auth/session` | Log out. |
133
+
134
+ A successful signup or login returns `meta.session_token`. Send it as the `X-Session-Token` header on
135
+ later requests, both to the allauth endpoints and to your own API views:
136
+
137
+ ```bash
138
+ curl -s -X POST http://localhost:8000/_allauth/app/v1/auth/login \
139
+ -H 'Content-Type: application/json' \
140
+ -d '{"email": "you@example.com", "password": "..."}'
141
+
142
+ curl -s http://localhost:8000/api/users/me/ -H 'X-Session-Token: <session_token>'
143
+ ```
144
+
145
+ `GET /api/users/me/` is the sample DRF endpoint: it returns the logged-in user. While `DEBUG` is on,
146
+ the full allauth API reference is served at <http://localhost:8000/_allauth/openapi.html>, and your
147
+ own DRF endpoints are documented at <http://localhost:8000/api/schema/swagger-ui/>.
148
+
149
+ Browser front ends can use the `browser` client (`/_allauth/browser/v1/...`), which uses Django's
150
+ session cookie and CSRF token. A browser SPA served from another origin (for example a Vite dev server
151
+ on port 5173) will also want [`django-cors-headers`](https://pypi.org/project/django-cors-headers/)
152
+ to allow its origin.
153
+
154
+ ## License
155
+
156
+ MIT
@@ -0,0 +1,133 @@
1
+ # django-starter-cdc
2
+
3
+ Generate a new Django 6.1 project from [django-starter](https://github.com/achingachris/django-starter):
4
+ either a full-stack app (Django templates, HTMX, Alpine.js, Tailwind) or an API-only backend
5
+ (Django REST Framework + allauth headless).
6
+
7
+ The generator has no dependencies of its own. It needs **Python 3.14 or newer**: on an older Python,
8
+ pip stops with "requires a different Python". The projects it creates are managed with
9
+ [uv](https://docs.astral.sh/uv/) (plus npm for the full variant), so install uv however you install
10
+ the generator.
11
+
12
+ ## Install
13
+
14
+ **With uv (recommended).** `uvx` runs the generator in a throwaway environment, so there is nothing to
15
+ install or upgrade, and uv fetches Python 3.14 for you if needed:
16
+
17
+ ```bash
18
+ uvx django-starter-cdc new myproject
19
+ ```
20
+
21
+ **With pip and a virtual environment.** Following the
22
+ [Python Packaging User Guide](https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/),
23
+ create and activate a virtual environment with Python 3.14, then install the package into it:
24
+
25
+ ```bash
26
+ python3.14 -m venv .venv
27
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
28
+ python -m pip install --upgrade pip
29
+ python -m pip install django-starter-cdc
30
+ django-starter-cdc --version
31
+ ```
32
+
33
+ Run `deactivate` when you're done. `python -m django_starter_cdc` works too, in place of the
34
+ `django-starter-cdc` command. To upgrade later, run `python -m pip install --upgrade django-starter-cdc`.
35
+
36
+ **With pipx**, to keep the command available everywhere without managing a virtual environment yourself:
37
+
38
+ ```bash
39
+ pipx install --python python3.14 django-starter-cdc
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ```bash
45
+ django-starter-cdc new myproject # full variant
46
+ django-starter-cdc new myproject --api # API-only variant
47
+ ```
48
+
49
+ (With uv, prefix these with `uvx`.)
50
+
51
+ This downloads the django-starter release that matches the generator's version, renders the
52
+ chosen variant into `./myproject`, writes a `.env` with a fresh `SECRET_KEY`, runs `git init`
53
+ and then `make init` (installs dependencies and migrates the SQLite database). Then:
54
+
55
+ ```bash
56
+ cd myproject
57
+ make start
58
+ ```
59
+
60
+ and open <http://localhost:8000/admin/> and <http://localhost:8000/api/schema/swagger-ui/>.
61
+
62
+ ### Options
63
+
64
+ ```
65
+ django-starter-cdc new <name> [--api] [--email EMAIL] [--ref REF | --source PATH]
66
+ [--no-install] [--no-git] [--dir PATH]
67
+ ```
68
+
69
+ | Option | What it does |
70
+ | --- | --- |
71
+ | `<name>` | Project name: a lowercase slug of letters, digits, `-` and `_` that starts with a letter. Used for the directory, the Python/npm package name and the `<NAME>_` env-var prefix. |
72
+ | `--api` | Generate the API-only variant (see below). |
73
+ | `--email EMAIL` | Address used for `ADMINS`, `DEFAULT_FROM_EMAIL` and the API contact (default `admin@example.com`). |
74
+ | `--ref REF` | Download a branch, tag or commit instead of the matching release, e.g. `--ref main`. |
75
+ | `--source PATH` | Copy a local django-starter checkout instead of downloading. `.git`, `node_modules`, `.venv`, `.env`, `db.sqlite3` and build output are left out. |
76
+ | `--no-install` | Skip `make init`; the manual setup steps are printed instead. |
77
+ | `--no-git` | Skip `git init`. |
78
+ | `--dir PATH` | Parent directory to create the project in (default: the current directory). |
79
+
80
+ The target directory must not exist or must be empty. If `make`, `uv` or (for the full variant) `npm`
81
+ is not installed, the generator skips `make init` and prints the equivalent steps:
82
+ `uv sync`, `npm install && npm run build` (full only) and `uv run python manage.py migrate`.
83
+
84
+ ## Variants
85
+
86
+ Both variants share the same core: Django 6.1 on Python 3.14, a custom user model, `django-allauth`
87
+ with headless mode, Django REST Framework with a `drf-spectacular` OpenAPI schema, Django's built-in
88
+ tasks framework, SQLite for local development and a Docker Compose stack (Postgres + Redis + gunicorn)
89
+ for production.
90
+
91
+ **Full (default)** adds the server-rendered web app: Django templates, HTMX and Alpine.js for
92
+ interactivity, Tailwind CSS 4 built with the Tailwind CLI (no JavaScript bundler), allauth's account
93
+ pages, a profile page, sitemaps and meta tags.
94
+
95
+ **API-only (`--api`)** has no templates (other than the allauth email templates), no front-end assets
96
+ and no npm. allauth runs with `HEADLESS_ONLY = True`, so authentication is a JSON API meant for a
97
+ separate front end or mobile app.
98
+
99
+ ## Authentication API
100
+
101
+ allauth headless serves its auth API under `/_allauth/`. Mobile and other non-browser clients use the
102
+ `app` client, which authenticates with a session token instead of cookies:
103
+
104
+ | Endpoint | Purpose |
105
+ | --- | --- |
106
+ | `POST /_allauth/app/v1/auth/signup` | Create an account. |
107
+ | `POST /_allauth/app/v1/auth/login` | Log in with email and password. |
108
+ | `GET /_allauth/app/v1/auth/session` | Check the current session. |
109
+ | `DELETE /_allauth/app/v1/auth/session` | Log out. |
110
+
111
+ A successful signup or login returns `meta.session_token`. Send it as the `X-Session-Token` header on
112
+ later requests, both to the allauth endpoints and to your own API views:
113
+
114
+ ```bash
115
+ curl -s -X POST http://localhost:8000/_allauth/app/v1/auth/login \
116
+ -H 'Content-Type: application/json' \
117
+ -d '{"email": "you@example.com", "password": "..."}'
118
+
119
+ curl -s http://localhost:8000/api/users/me/ -H 'X-Session-Token: <session_token>'
120
+ ```
121
+
122
+ `GET /api/users/me/` is the sample DRF endpoint: it returns the logged-in user. While `DEBUG` is on,
123
+ the full allauth API reference is served at <http://localhost:8000/_allauth/openapi.html>, and your
124
+ own DRF endpoints are documented at <http://localhost:8000/api/schema/swagger-ui/>.
125
+
126
+ Browser front ends can use the `browser` client (`/_allauth/browser/v1/...`), which uses Django's
127
+ session cookie and CSRF token. A browser SPA served from another origin (for example a Vite dev server
128
+ on port 5173) will also want [`django-cors-headers`](https://pypi.org/project/django-cors-headers/)
129
+ to allow its origin.
130
+
131
+ ## License
132
+
133
+ MIT
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "django-starter-cdc"
7
+ dynamic = ["version"]
8
+ description = "Generate a new Django 6.1 project from django-starter: full-stack (HTMX, Alpine.js, Tailwind) or API-only."
9
+ readme = "README.md"
10
+ requires-python = ">=3.14"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Chris Achinga" }]
14
+ keywords = ["django", "starter", "template", "generator", "htmx", "allauth", "drf"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Framework :: Django",
19
+ "Framework :: Django :: 6.1",
20
+ "Intended Audience :: Developers",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Topic :: Software Development :: Code Generators",
25
+ ]
26
+ dependencies = []
27
+
28
+ [project.scripts]
29
+ django-starter-cdc = "django_starter_cdc.cli:main"
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/achingachris/django-starter"
33
+ Source = "https://github.com/achingachris/django-starter"
34
+ Issues = "https://github.com/achingachris/django-starter/issues"
35
+
36
+ # The version is read from __version__, which the CLI also uses to pick the release tarball to download.
37
+ [tool.hatch.version]
38
+ path = "src/django_starter_cdc/__init__.py"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/django_starter_cdc"]
42
+
43
+ [tool.uv]
44
+ # uv rebuilds a local path package only when its cache keys change (pyproject.toml by default), so
45
+ # `uvx --from ./cli` would keep running a stale build after edits to the source. Include the source.
46
+ cache-keys = [{ file = "pyproject.toml" }, { file = "README.md" }, { file = "src/**/*.py" }]
47
+
48
+ [tool.ruff]
49
+ line-length = 120
50
+ target-version = "py314"
51
+
52
+ [tool.ruff.lint]
53
+ select = ["E", "F", "I", "UP", "B", "SIM"]
54
+
55
+ [tool.ruff.format]
56
+ quote-style = "double"
@@ -0,0 +1,3 @@
1
+ """Generate a new Django project from the django-starter template."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from django_starter_cdc.cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,147 @@
1
+ """Command line entry point: ``django-starter-cdc new <name> [options]``."""
2
+
3
+ import argparse
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ from django_starter_cdc import __version__
12
+ from django_starter_cdc.fetch import FetchError, archive_url, copy_local, download
13
+ from django_starter_cdc.render import DEFAULT_EMAIL, RenderError, render_project
14
+
15
+ # A lowercase slug that starts with a letter and ends with a letter or digit, e.g. "shop" or "my-shop_2".
16
+ NAME_RE = re.compile(r"^[a-z](?:[a-z0-9_-]*[a-z0-9])?$")
17
+ EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
18
+
19
+
20
+ class CLIError(Exception):
21
+ """Invalid input from the command line."""
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(
26
+ prog="django-starter-cdc",
27
+ description="Generate a new Django 6.1 project from django-starter.",
28
+ )
29
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
30
+ commands = parser.add_subparsers(dest="command", required=True, metavar="command")
31
+
32
+ new = commands.add_parser(
33
+ "new",
34
+ help="create a new project",
35
+ description="Create a new project. The full variant has templates, HTMX, Alpine.js and Tailwind; "
36
+ "--api gives an API-only project (DRF + allauth headless, no templates or npm).",
37
+ )
38
+ new.add_argument("name", help="project name: a lowercase slug of letters, digits, hyphens and underscores")
39
+ new.add_argument("--api", action="store_true", help="API-only variant: no templates, front end or npm")
40
+ new.add_argument(
41
+ "--email", default=DEFAULT_EMAIL, help=f"admin and default from-address email (default: {DEFAULT_EMAIL})"
42
+ )
43
+ source = new.add_mutually_exclusive_group()
44
+ source.add_argument(
45
+ "--ref",
46
+ help=f"git ref (branch, tag or commit) to download instead of the v{__version__} release, e.g. main",
47
+ )
48
+ source.add_argument("--source", type=Path, metavar="PATH", help="copy a local django-starter checkout instead")
49
+ new.add_argument("--no-install", action="store_true", help="skip `make init` (dependencies and migrations)")
50
+ new.add_argument("--no-git", action="store_true", help="skip `git init`")
51
+ new.add_argument(
52
+ "--dir", type=Path, default=Path("."), metavar="PATH", help="parent directory for the project (default: .)"
53
+ )
54
+ return parser
55
+
56
+
57
+ def validate(args: argparse.Namespace) -> Path:
58
+ """Check the name, email and target directory. Returns the project directory to create."""
59
+ if not NAME_RE.match(args.name):
60
+ raise CLIError(
61
+ f"Invalid project name {args.name!r}: use lowercase letters, digits, hyphens and underscores, "
62
+ "starting with a letter and ending with a letter or digit."
63
+ )
64
+ if not EMAIL_RE.match(args.email):
65
+ raise CLIError(f"Invalid email address {args.email!r}.")
66
+ target = args.dir.expanduser().resolve() / args.name
67
+ if target.exists() and (not target.is_dir() or any(target.iterdir())):
68
+ raise CLIError(f"{target} already exists and is not an empty directory.")
69
+ return target
70
+
71
+
72
+ def run(command: list[str], cwd: Path) -> bool:
73
+ print(f"$ {' '.join(command)}")
74
+ return subprocess.run(command, cwd=cwd).returncode == 0
75
+
76
+
77
+ def manual_steps(name: str, api: bool) -> list[str]:
78
+ steps = [f"cd {name}", "uv sync"]
79
+ if not api:
80
+ steps.append("npm install && npm run build")
81
+ steps.append("uv run python manage.py migrate")
82
+ return steps
83
+
84
+
85
+ def install(target: Path, api: bool) -> bool:
86
+ """Run `make init` when its tools are available. Returns False if the user has to finish setup by hand."""
87
+ tools = ["make", "uv"] if api else ["make", "uv", "npm"]
88
+ missing = [tool for tool in tools if shutil.which(tool) is None]
89
+ if missing:
90
+ print(f"\nSkipping `make init`: {', '.join(missing)} not found on PATH.")
91
+ return False
92
+ if run(["make", "init"], target):
93
+ return True
94
+ print("\n`make init` failed; see the output above.", file=sys.stderr)
95
+ return False
96
+
97
+
98
+ def print_next_steps(target: Path, api: bool, installed: bool) -> None:
99
+ print(f"\nCreated {'API-only' if api else 'full'} project in {target}\n")
100
+ if installed:
101
+ print("Next steps:\n")
102
+ print(f" cd {target.name}")
103
+ else:
104
+ print("Finish the setup:\n")
105
+ print("\n".join(f" {step}" for step in manual_steps(target.name, api)))
106
+ print(" make start\n")
107
+ print("Then open:")
108
+ print(" http://localhost:8000/admin/ Django admin")
109
+ print(" http://localhost:8000/api/schema/swagger-ui/ API docs")
110
+ if api:
111
+ print(" http://localhost:8000/_allauth/openapi.html auth API docs (allauth headless)")
112
+
113
+
114
+ def create_project(args: argparse.Namespace) -> int:
115
+ target = validate(args)
116
+ target.parent.mkdir(parents=True, exist_ok=True)
117
+ with tempfile.TemporaryDirectory(prefix=f".{args.name}-", dir=target.parent) as tmp:
118
+ stage = Path(tmp) / args.name
119
+ if args.source:
120
+ print(f"Copying {args.source.resolve()}")
121
+ copy_local(args.source, stage, exclude=(Path(tmp), target))
122
+ else:
123
+ print(f"Downloading {archive_url(args.ref)}")
124
+ download(stage, args.ref)
125
+ print(f"Rendering the {'API-only' if args.api else 'full'} variant")
126
+ render_project(stage, api=args.api, slug=args.name, email=args.email)
127
+ if target.exists():
128
+ target.rmdir()
129
+ stage.rename(target)
130
+
131
+ if not args.no_git:
132
+ if shutil.which("git"):
133
+ run(["git", "init", "-q"], target)
134
+ else:
135
+ print("Skipping `git init`: git not found on PATH.")
136
+ installed = not args.no_install and install(target, args.api)
137
+ print_next_steps(target, args.api, installed)
138
+ return 0
139
+
140
+
141
+ def main(argv: list[str] | None = None) -> int:
142
+ args = build_parser().parse_args(argv)
143
+ try:
144
+ return create_project(args)
145
+ except (CLIError, FetchError, RenderError, OSError) as e:
146
+ print(f"error: {e}", file=sys.stderr)
147
+ return 1
@@ -0,0 +1,108 @@
1
+ """Get the django-starter source tree: from a local checkout or a GitHub release tarball."""
2
+
3
+ import shutil
4
+ import tarfile
5
+ import tempfile
6
+ import urllib.error
7
+ import urllib.request
8
+ from collections.abc import Iterable
9
+ from pathlib import Path
10
+
11
+ from django_starter_cdc import __version__
12
+
13
+ REPO_URL = "https://github.com/achingachris/django-starter"
14
+
15
+ # Skipped by name wherever they appear in a local checkout.
16
+ IGNORED_NAMES = frozenset(
17
+ {
18
+ ".git",
19
+ "node_modules",
20
+ ".venv",
21
+ "__pycache__",
22
+ ".ruff_cache",
23
+ ".mypy_cache",
24
+ ".pytest_cache",
25
+ ".DS_Store",
26
+ ".idea",
27
+ ".vscode",
28
+ ".playwright-mcp",
29
+ }
30
+ )
31
+
32
+ # Skipped only at these paths, relative to the checkout root: build output, local data and secrets.
33
+ IGNORED_PATHS = frozenset(
34
+ {
35
+ "cli",
36
+ "db.sqlite3",
37
+ "static/vendor",
38
+ "static/css",
39
+ "static_root",
40
+ "media",
41
+ ".env",
42
+ ".env.prod",
43
+ ".claude/settings.local.json",
44
+ }
45
+ )
46
+
47
+
48
+ class FetchError(Exception):
49
+ """The template source could not be copied or downloaded."""
50
+
51
+
52
+ def archive_url(ref: str | None = None) -> str:
53
+ """URL of the source tarball: the release tag matching this CLI's version, or any git ref."""
54
+ if ref:
55
+ return f"{REPO_URL}/archive/{ref}.tar.gz"
56
+ return f"{REPO_URL}/archive/refs/tags/v{__version__}.tar.gz"
57
+
58
+
59
+ def copy_local(source: Path, dest: Path, exclude: Iterable[Path] = ()) -> None:
60
+ """Copy a local checkout into ``dest``, leaving out VCS data, dependencies, build output and secrets.
61
+
62
+ ``exclude`` lists further paths to skip. The CLI passes its staging directory and the target, which sit
63
+ inside the checkout when a project is generated there (e.g. ``new sample --source .``); copying them
64
+ would make the copy recurse into itself.
65
+ """
66
+ source = source.resolve()
67
+ if not (source / "manage.py").is_file():
68
+ raise FetchError(f"{source} does not look like a django-starter checkout (no manage.py).")
69
+ excluded = {Path(p).resolve() for p in exclude}
70
+
71
+ def ignore(directory: str, names: list[str]) -> set[str]:
72
+ rel_dir = Path(directory).resolve().relative_to(source)
73
+ ignored = set()
74
+ for name in names:
75
+ rel = (rel_dir / name).as_posix()
76
+ if name in IGNORED_NAMES or rel in IGNORED_PATHS or (Path(directory) / name).resolve() in excluded:
77
+ ignored.add(name)
78
+ return ignored
79
+
80
+ shutil.copytree(source, dest, ignore=ignore, symlinks=True)
81
+
82
+
83
+ def download(dest: Path, ref: str | None = None) -> None:
84
+ """Download and extract the source tarball into ``dest``, stripping its single top-level folder."""
85
+ url = archive_url(ref)
86
+ with tempfile.TemporaryDirectory(prefix="django-starter-cdc-") as tmp:
87
+ archive = Path(tmp) / "source.tar.gz"
88
+ request = urllib.request.Request(url, headers={"User-Agent": f"django-starter-cdc/{__version__}"})
89
+ try:
90
+ with urllib.request.urlopen(request, timeout=60) as response, archive.open("wb") as fh:
91
+ shutil.copyfileobj(response, fh)
92
+ except urllib.error.HTTPError as e:
93
+ hint = "" if ref else " Pass --ref main to use the latest code instead of a release."
94
+ raise FetchError(f"Could not download {url}: HTTP {e.code}.{hint}") from e
95
+ except urllib.error.URLError as e:
96
+ raise FetchError(f"Could not download {url}: {e.reason}") from e
97
+
98
+ extracted = Path(tmp) / "extracted"
99
+ try:
100
+ with tarfile.open(archive, "r:gz") as tar:
101
+ tar.extractall(extracted, filter="data")
102
+ except (tarfile.TarError, OSError) as e:
103
+ raise FetchError(f"Could not extract {url}: {e}") from e
104
+
105
+ entries = list(extracted.iterdir())
106
+ if len(entries) != 1 or not entries[0].is_dir():
107
+ raise FetchError(f"Unexpected archive layout in {url}: expected a single top-level folder.")
108
+ shutil.move(entries[0], dest)
@@ -0,0 +1,192 @@
1
+ """Turn a django-starter source tree into a new project: variant markers, pruning, renaming and ``.env``."""
2
+
3
+ import os
4
+ import re
5
+ import secrets
6
+ from pathlib import Path
7
+
8
+ # `# >>> web`, `<!-- >>> web -->` or `{# >>> web #}` opens a block; `<<< web` closes it. A `web` block is kept
9
+ # (without its markers) in the full variant and dropped from the API variant. A `starter` block holds text that
10
+ # only makes sense in the starter repo itself and is always dropped.
11
+ BLOCK_MARKER = re.compile(r"^\s*(#|<!--|\{#)\s*(>>>|<<<)\s*(web|starter)\b")
12
+
13
+ # `# api-only: <code>` is a commented-out line that the API variant uncomments.
14
+ API_ONLY = re.compile(r"^(\s*)# api-only: (.*)$")
15
+
16
+ # Removed from the API variant. Entries ending in "/" are directories.
17
+ API_REMOVED_PATHS = (
18
+ "templates/",
19
+ "assets/",
20
+ "static/css/",
21
+ "static/javascript/",
22
+ "static/images/undraw/",
23
+ "static/images/web/",
24
+ "package.json",
25
+ "package-lock.json",
26
+ ".npmrc",
27
+ ".github/workflows/build_frontend.yml",
28
+ "apps/web/views.py",
29
+ "apps/web/urls.py",
30
+ "apps/web/forms.py",
31
+ "apps/web/sitemaps.py",
32
+ "apps/web/meta.py",
33
+ "apps/web/context_processors.py",
34
+ "apps/web/templatetags/",
35
+ "apps/users/views.py",
36
+ "apps/users/urls.py",
37
+ "apps/users/forms.py",
38
+ "apps/web/tests/test_basic_views.py",
39
+ "apps/web/tests/test_logged_in_views.py",
40
+ "apps/web/tests/test_meta_tags.py",
41
+ "apps/web/tests/test_admin_dashboard.py",
42
+ "apps/users/tests/test_profile.py",
43
+ ".cursor/rules/django-templates.mdc",
44
+ ".cursor/rules/javascript-guidelines.mdc",
45
+ )
46
+
47
+ # Kept in the API variant even though a parent is in API_REMOVED_PATHS: allauth still sends these emails.
48
+ API_KEPT_PATHS = ("templates/account/email/",)
49
+
50
+ # Removed from every generated project: the generator itself and the workflows that publish it.
51
+ ALWAYS_REMOVED_PATHS = (
52
+ "cli/",
53
+ ".github/workflows/publish_cli.yml",
54
+ ".github/workflows/starter_cli.yml",
55
+ )
56
+
57
+ DEFAULT_EMAIL = "admin@example.com"
58
+
59
+
60
+ class RenderError(Exception):
61
+ """The source tree could not be rendered, e.g. because of unbalanced variant markers."""
62
+
63
+
64
+ def process_text(text: str, api: bool, path: str = "<text>") -> str:
65
+ """Apply the web/starter blocks and api-only markers for the chosen variant to one file's text."""
66
+ out = []
67
+ # Line number of the open block for each tag, or None. Blocks of different tags may overlap.
68
+ open_blocks: dict[str, int | None] = {"web": None, "starter": None}
69
+ for lineno, line in enumerate(text.splitlines(keepends=True), start=1):
70
+ body = line.rstrip("\r\n")
71
+ marker = BLOCK_MARKER.match(body)
72
+ if marker:
73
+ direction, tag = marker.group(2), marker.group(3)
74
+ start = open_blocks[tag]
75
+ if direction == ">>>":
76
+ if start is not None:
77
+ raise RenderError(f"{path}:{lineno}: '>>> {tag}' inside the block opened on line {start}.")
78
+ open_blocks[tag] = lineno
79
+ else:
80
+ if start is None:
81
+ raise RenderError(f"{path}:{lineno}: '<<< {tag}' without a matching '>>> {tag}'.")
82
+ open_blocks[tag] = None
83
+ continue
84
+ if open_blocks["starter"] is not None or (api and open_blocks["web"] is not None):
85
+ continue
86
+ api_only = API_ONLY.match(body)
87
+ if api_only:
88
+ if api:
89
+ out.append(api_only.group(1) + api_only.group(2) + line[len(body) :])
90
+ continue
91
+ out.append(line)
92
+ for tag, start in open_blocks.items():
93
+ if start is not None:
94
+ raise RenderError(f"{path}:{start}: '>>> {tag}' is never closed with '<<< {tag}'.")
95
+ return "".join(out)
96
+
97
+
98
+ def _matches(rel: str, patterns: tuple[str, ...]) -> bool:
99
+ return any(rel.startswith(p) if p.endswith("/") else rel == p for p in patterns)
100
+
101
+
102
+ def removed_paths(api: bool) -> tuple[str, ...]:
103
+ return ALWAYS_REMOVED_PATHS + API_REMOVED_PATHS if api else ALWAYS_REMOVED_PATHS
104
+
105
+
106
+ def prune(root: Path, api: bool) -> list[str]:
107
+ """Delete the paths that do not belong in the chosen variant. Returns the removed files, relative to root."""
108
+ patterns = removed_paths(api)
109
+ removed = []
110
+ emptied_dirs = set()
111
+ for dirpath, dirnames, filenames in os.walk(root):
112
+ directory = Path(dirpath)
113
+ # Symlinks to directories are listed in dirnames but not walked; remove them like files.
114
+ links = [d for d in dirnames if (directory / d).is_symlink()]
115
+ for name in filenames + links:
116
+ path = directory / name
117
+ rel = path.relative_to(root).as_posix()
118
+ if _matches(rel, patterns) and not _matches(rel, API_KEPT_PATHS if api else ()):
119
+ path.unlink()
120
+ removed.append(rel)
121
+ emptied_dirs.add(directory)
122
+ for directory in sorted(emptied_dirs, key=lambda d: len(d.parts), reverse=True):
123
+ while directory != root and directory.is_dir() and not any(directory.iterdir()):
124
+ directory.rmdir()
125
+ directory = directory.parent
126
+ return sorted(removed)
127
+
128
+
129
+ def rename_map(slug: str, email: str = DEFAULT_EMAIL) -> dict[str, str]:
130
+ """Template names and contact details mapped to the new project's values."""
131
+ return {
132
+ "CHRISDEVCODE_": slug.upper().replace("-", "_") + "_",
133
+ "chrisdevcode": slug,
134
+ "django-starter": slug,
135
+ "django-template": slug,
136
+ "achinga.chris@gmail.com": email,
137
+ }
138
+
139
+
140
+ def rename(text: str, slug: str, email: str = DEFAULT_EMAIL) -> str:
141
+ """Replace the template's names in one pass, so a slug containing an old name is not replaced again."""
142
+ mapping = rename_map(slug, email)
143
+ pattern = re.compile("|".join(re.escape(k) for k in sorted(mapping, key=len, reverse=True)))
144
+ return pattern.sub(lambda m: mapping[m.group(0)], text)
145
+
146
+
147
+ def read_text(path: Path) -> str | None:
148
+ """The file's text, or None for binary or non-UTF-8 files."""
149
+ data = path.read_bytes()
150
+ if b"\0" in data:
151
+ return None
152
+ try:
153
+ return data.decode("utf-8")
154
+ except UnicodeDecodeError:
155
+ return None
156
+
157
+
158
+ def render_tree(root: Path, api: bool, slug: str, email: str = DEFAULT_EMAIL) -> None:
159
+ """Apply the variant markers and renaming to every UTF-8 text file under root."""
160
+ for dirpath, _dirnames, filenames in os.walk(root):
161
+ for name in filenames:
162
+ path = Path(dirpath) / name
163
+ if path.is_symlink():
164
+ continue
165
+ text = read_text(path)
166
+ if text is None:
167
+ continue
168
+ rel = path.relative_to(root).as_posix()
169
+ new = rename(process_text(text, api, rel), slug, email)
170
+ if new != text:
171
+ path.write_text(new, encoding="utf-8", newline="")
172
+
173
+
174
+ def write_env(root: Path) -> bool:
175
+ """Create .env from .env.example with a fresh SECRET_KEY. Returns False if there is no .env.example."""
176
+ example = root / ".env.example"
177
+ if not example.is_file():
178
+ return False
179
+ text = example.read_bytes().decode("utf-8")
180
+ secret_line = f'SECRET_KEY="{secrets.token_urlsafe(50)}"'
181
+ text, count = re.subn(r"^SECRET_KEY=[^\r\n]*", lambda _: secret_line, text, count=1, flags=re.MULTILINE)
182
+ if not count:
183
+ text = text.rstrip("\n") + f"\n{secret_line}\n"
184
+ (root / ".env").write_text(text, encoding="utf-8", newline="")
185
+ return True
186
+
187
+
188
+ def render_project(root: Path, *, api: bool, slug: str, email: str = DEFAULT_EMAIL) -> None:
189
+ """Turn a copied source tree into the chosen variant of a new project, in place."""
190
+ prune(root, api)
191
+ render_tree(root, api, slug, email)
192
+ write_env(root)
@@ -0,0 +1,242 @@
1
+ import contextlib
2
+ import io
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ from django_starter_cdc.cli import NAME_RE, main
8
+ from django_starter_cdc.fetch import archive_url, copy_local
9
+ from django_starter_cdc.render import RenderError, process_text, prune, rename, render_project, write_env
10
+
11
+ SETTINGS = """\
12
+ INSTALLED_APPS = [
13
+ "apps.users",
14
+ # >>> web
15
+ "apps.web",
16
+ # <<< web
17
+ ]
18
+ # api-only: HEADLESS_ONLY = True
19
+ """
20
+
21
+ TEMPLATE = """\
22
+ <body>
23
+ <!-- >>> web -->
24
+ <nav></nav>
25
+ <!-- <<< web -->
26
+ {# >>> web #}
27
+ <footer></footer>
28
+ {# <<< web #}
29
+ </body>
30
+ """
31
+
32
+
33
+ class ProcessTextTests(unittest.TestCase):
34
+ def test_full_mode_keeps_blocks_and_drops_markers(self):
35
+ self.assertEqual(
36
+ process_text(SETTINGS, api=False),
37
+ 'INSTALLED_APPS = [\n "apps.users",\n "apps.web",\n]\n',
38
+ )
39
+
40
+ def test_api_mode_drops_blocks(self):
41
+ self.assertEqual(
42
+ process_text(SETTINGS, api=True),
43
+ 'INSTALLED_APPS = [\n "apps.users",\n]\nHEADLESS_ONLY = True\n',
44
+ )
45
+
46
+ def test_html_and_django_comment_markers(self):
47
+ self.assertEqual(process_text(TEMPLATE, api=False), "<body>\n<nav></nav>\n<footer></footer>\n</body>\n")
48
+ self.assertEqual(process_text(TEMPLATE, api=True), "<body>\n</body>\n")
49
+
50
+ def test_api_only_keeps_indentation_and_line_endings(self):
51
+ text = "if True:\r\n # api-only: x = 1\r\n y = 2\r\n"
52
+ self.assertEqual(process_text(text, api=True), "if True:\r\n x = 1\r\n y = 2\r\n")
53
+ self.assertEqual(process_text(text, api=False), "if True:\r\n y = 2\r\n")
54
+
55
+ def test_api_only_inside_web_block_is_dropped_in_api_mode(self):
56
+ text = "# >>> web\n# api-only: x = 1\n# <<< web\n"
57
+ self.assertEqual(process_text(text, api=True), "")
58
+
59
+ def test_text_without_markers_is_unchanged(self):
60
+ text = "# a web comment\nwebsite = 'web'\n# >>> website\n"
61
+ self.assertEqual(process_text(text, api=True), text)
62
+
63
+ def test_unclosed_block(self):
64
+ with self.assertRaisesRegex(RenderError, r"settings.py:2: '>>> web' is never closed"):
65
+ process_text("x = 1\n# >>> web\ny = 2\n", api=True, path="settings.py")
66
+
67
+ def test_close_without_open(self):
68
+ with self.assertRaisesRegex(RenderError, r"urls.py:1: '<<< web' without a matching"):
69
+ process_text("# <<< web\n", api=False, path="urls.py")
70
+
71
+ def test_nested_open(self):
72
+ with self.assertRaisesRegex(RenderError, r"Makefile:3: '>>> web' inside the block opened on line 1"):
73
+ process_text("# >>> web\na\n# >>> web\n# <<< web\n", api=False, path="Makefile")
74
+
75
+ def test_starter_blocks_are_dropped_in_both_variants(self):
76
+ text = (
77
+ "# Docs\n<!-- >>> starter -->\nRun uvx django-starter-cdc.\n<!-- <<< starter -->\n"
78
+ "{# >>> starter #}\nx\n{# <<< starter #}\nEnd\n"
79
+ )
80
+ for api in (False, True):
81
+ self.assertEqual(process_text(text, api=api), "# Docs\nEnd\n")
82
+
83
+ def test_web_block_inside_starter_block(self):
84
+ text = "a\n# >>> starter\nb\n# >>> web\nc\n# <<< web\n# <<< starter\nd\n"
85
+ self.assertEqual(process_text(text, api=False), "a\nd\n")
86
+
87
+ def test_unbalanced_starter_markers(self):
88
+ with self.assertRaisesRegex(RenderError, r"README.md:1: '>>> starter' is never closed"):
89
+ process_text("<!-- >>> starter -->\n", api=False, path="README.md")
90
+ with self.assertRaisesRegex(RenderError, r"README.md:2: '<<< starter' without a matching"):
91
+ process_text("<!-- >>> web -->\n<!-- <<< starter -->\n", api=False, path="README.md")
92
+
93
+ def test_makefile_markers_at_column_zero(self):
94
+ text = (
95
+ "init:\n\t@uv sync\n# >>> web\n\t@npm install\n# <<< web\n\t@$(MAKE) migrate\n"
96
+ "# api-only: upgrade: requirements migrate ## Update\n"
97
+ )
98
+ self.assertEqual(
99
+ process_text(text, api=True),
100
+ "init:\n\t@uv sync\n\t@$(MAKE) migrate\nupgrade: requirements migrate ## Update\n",
101
+ )
102
+ self.assertEqual(process_text(text, api=False), "init:\n\t@uv sync\n\t@npm install\n\t@$(MAKE) migrate\n")
103
+
104
+
105
+ class RenameTests(unittest.TestCase):
106
+ def test_names_env_prefix_and_email(self):
107
+ text = (
108
+ 'name = "chrisdevcode"\nimage: django-starter-app\n"""django-template URLs"""\n'
109
+ 'env("CHRISDEVCODE_LOG_LEVEL")\nADMINS = ["achinga.chris@gmail.com"]\n'
110
+ )
111
+ self.assertEqual(
112
+ rename(text, "my-shop", "me@shop.test"),
113
+ 'name = "my-shop"\nimage: my-shop-app\n"""my-shop URLs"""\n'
114
+ 'env("MY_SHOP_LOG_LEVEL")\nADMINS = ["me@shop.test"]\n',
115
+ )
116
+
117
+ def test_default_email(self):
118
+ self.assertEqual(rename("achinga.chris@gmail.com", "shop"), "admin@example.com")
119
+
120
+ def test_slug_containing_an_old_name_is_not_replaced_twice(self):
121
+ self.assertEqual(
122
+ rename("django-starter chrisdevcode", "django-template-x"), "django-template-x django-template-x"
123
+ )
124
+
125
+
126
+ class TreeTests(unittest.TestCase):
127
+ def setUp(self):
128
+ self._tmp = tempfile.TemporaryDirectory()
129
+ self.root = Path(self._tmp.name) / "project"
130
+ files = {
131
+ "manage.py": "print('chrisdevcode')\n",
132
+ "package.json": '{"name": "chrisdevcode"}\n',
133
+ "templates/web/base.html": "<html></html>\n",
134
+ "templates/account/email/base.txt": "{# >>> web #}\nweb\n{# <<< web #}\nemail\n",
135
+ "apps/web/views.py": "",
136
+ "apps/web/models.py": "",
137
+ "apps/web/templatetags/meta_tags.py": "",
138
+ "apps/users/views.py": "",
139
+ "apps/users/api.py": "",
140
+ "cli/pyproject.toml": "",
141
+ ".github/workflows/tests.yml": "",
142
+ ".github/workflows/publish_cli.yml": "",
143
+ ".cursor/rules/django-templates.mdc": "",
144
+ ".cursor/rules/python.mdc": "",
145
+ ".env.example": '# header\nSECRET_KEY="django-insecure-x"\nDEBUG=True\n',
146
+ }
147
+ for rel, text in files.items():
148
+ path = self.root / rel
149
+ path.parent.mkdir(parents=True, exist_ok=True)
150
+ path.write_text(text)
151
+ (self.root / "static/images").mkdir(parents=True)
152
+ (self.root / "static/images/logo.png").write_bytes(b"\x89PNG\r\n\x1a\n\0\0chrisdevcode")
153
+
154
+ def tearDown(self):
155
+ self._tmp.cleanup()
156
+
157
+ def files(self):
158
+ return sorted(p.relative_to(self.root).as_posix() for p in self.root.rglob("*") if p.is_file())
159
+
160
+ def test_prune_full_removes_only_the_cli(self):
161
+ removed = prune(self.root, api=False)
162
+ self.assertEqual(removed, [".github/workflows/publish_cli.yml", "cli/pyproject.toml"])
163
+ self.assertFalse((self.root / "cli").exists())
164
+ self.assertTrue((self.root / "templates/web/base.html").exists())
165
+
166
+ def test_prune_api(self):
167
+ prune(self.root, api=True)
168
+ self.assertEqual(
169
+ self.files(),
170
+ [
171
+ ".cursor/rules/python.mdc",
172
+ ".env.example",
173
+ ".github/workflows/tests.yml",
174
+ "apps/users/api.py",
175
+ "apps/web/models.py",
176
+ "manage.py",
177
+ "static/images/logo.png",
178
+ "templates/account/email/base.txt",
179
+ ],
180
+ )
181
+ self.assertFalse((self.root / "templates/web").exists())
182
+ self.assertFalse((self.root / "apps/web/templatetags").exists())
183
+
184
+ def test_render_project_api(self):
185
+ render_project(self.root, api=True, slug="shop", email="me@shop.test")
186
+ self.assertEqual((self.root / "manage.py").read_text(), "print('shop')\n")
187
+ self.assertEqual((self.root / "templates/account/email/base.txt").read_text(), "email\n")
188
+ self.assertIn(b"chrisdevcode", (self.root / "static/images/logo.png").read_bytes())
189
+ env = (self.root / ".env").read_text()
190
+ self.assertNotIn("django-insecure", env)
191
+ self.assertRegex(env, r'SECRET_KEY="[\w-]{60,}"\nDEBUG=True\n')
192
+
193
+ def test_render_project_reports_the_file_with_bad_markers(self):
194
+ (self.root / "apps/web/models.py").write_text("# >>> web\n")
195
+ with self.assertRaisesRegex(RenderError, r"apps/web/models.py:1"):
196
+ render_project(self.root, api=False, slug="shop")
197
+
198
+ def test_write_env_without_example(self):
199
+ (self.root / ".env.example").unlink()
200
+ self.assertFalse(write_env(self.root))
201
+
202
+ def test_copy_local_skips_build_output_and_secrets(self):
203
+ for rel in (".env", "db.sqlite3", "node_modules/x.js", "static/css/site.css", "apps/__pycache__/a.pyc"):
204
+ path = self.root / rel
205
+ path.parent.mkdir(parents=True, exist_ok=True)
206
+ path.write_text("x")
207
+ dest = Path(self._tmp.name) / "copy"
208
+ copy_local(self.root, dest)
209
+ copied = sorted(p.relative_to(dest).as_posix() for p in dest.rglob("*") if p.is_file())
210
+ self.assertNotIn(".env", copied)
211
+ self.assertNotIn("db.sqlite3", copied)
212
+ self.assertFalse(any(p.startswith(("cli/", "node_modules/", "static/css/")) for p in copied))
213
+ self.assertFalse(any("__pycache__" in p for p in copied))
214
+ self.assertIn(".env.example", copied)
215
+
216
+ def test_new_project_inside_the_source_checkout(self):
217
+ # `new sample --source .` with the default `--dir .`: the staging directory and the target both
218
+ # live inside the checkout being copied, and must not be copied into the project.
219
+ with contextlib.redirect_stdout(io.StringIO()):
220
+ args = ["new", "sample", "--source", str(self.root), "--dir", str(self.root), "--no-git", "--no-install"]
221
+ code = main(args)
222
+ self.assertEqual(code, 0)
223
+ project = self.root / "sample"
224
+ self.assertTrue((project / "manage.py").is_file())
225
+ self.assertEqual([p.name for p in project.iterdir() if p.name == "sample" or p.name.startswith(".sample-")], [])
226
+ self.assertEqual([p.name for p in self.root.iterdir() if p.name.startswith(".sample-")], [])
227
+
228
+
229
+ class CLITests(unittest.TestCase):
230
+ def test_name_validation(self):
231
+ for name in ("shop", "my-shop", "my_shop2", "a"):
232
+ self.assertRegex(name, NAME_RE)
233
+ for name in ("Shop", "2shop", "my shop", "shop-", "-shop", "my.shop", ""):
234
+ self.assertNotRegex(name, NAME_RE)
235
+
236
+ def test_archive_url(self):
237
+ self.assertEqual(archive_url("main"), "https://github.com/achingachris/django-starter/archive/main.tar.gz")
238
+ self.assertRegex(archive_url(), r"/archive/refs/tags/v\d+\.\d+\.\d+\.tar\.gz$")
239
+
240
+
241
+ if __name__ == "__main__":
242
+ unittest.main()
@@ -0,0 +1,7 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.14"
4
+
5
+ [[package]]
6
+ name = "django-starter-cdc"
7
+ source = { editable = "." }