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.
- django_starter_cdc-0.1.0/.gitignore +30 -0
- django_starter_cdc-0.1.0/LICENSE +22 -0
- django_starter_cdc-0.1.0/PKG-INFO +156 -0
- django_starter_cdc-0.1.0/README.md +133 -0
- django_starter_cdc-0.1.0/pyproject.toml +56 -0
- django_starter_cdc-0.1.0/src/django_starter_cdc/__init__.py +3 -0
- django_starter_cdc-0.1.0/src/django_starter_cdc/__main__.py +5 -0
- django_starter_cdc-0.1.0/src/django_starter_cdc/cli.py +147 -0
- django_starter_cdc-0.1.0/src/django_starter_cdc/fetch.py +108 -0
- django_starter_cdc-0.1.0/src/django_starter_cdc/render.py +192 -0
- django_starter_cdc-0.1.0/tests/test_render.py +242 -0
- django_starter_cdc-0.1.0/uv.lock +7 -0
|
@@ -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,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()
|