djrocket 1.0.0a2__py3-none-any.whl
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.
- djangorocket/__init__.py +0 -0
- djangorocket/__main__.py +4 -0
- djangorocket/cli.py +212 -0
- djangorocket/components.py +39 -0
- djangorocket/django.py +344 -0
- djangorocket/templates/__init__.py +0 -0
- djangorocket/templates/projects/__init__.py +0 -0
- djangorocket/templates/projects/base/cookiecutter.json +7 -0
- djangorocket/templates/projects/base/hooks/post_gen_project.py +84 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/.coveragerc +10 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/.env.example +43 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/.flake8 +19 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/.gitignore +58 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/.isort.cfg +9 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/Makefile +107 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/README.md +131 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/docker-compose.yml +57 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/pyproject.toml +27 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/pytest.ini +5 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/requirements/requirements-dev.txt +1 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/requirements/requirements-docs.txt +1 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/requirements/requirements-linting.txt +4 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/requirements/requirements-testing.txt +8 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/requirements/requirements.txt +32 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/requirements.txt +5 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/runtime.txt +1 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/manage.py +22 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/static/CACHE/manifest.json +0 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/static/img/favicon/favicon.png +0 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/static/img/favicon/favicon.svg +46 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/__init__.py +0 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/apps.py +5 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/static/css/dist/styles.css +2765 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/static_src/.gitignore +1 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/static_src/package-lock.json +1880 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/static_src/package.json +29 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/static_src/postcss.config.js +7 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/static_src/src/styles.css +41 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/static_src/tailwind.config.js +53 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/templates/base.html +20 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/templates/base.html +27 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/templates/base_settings.html +21 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/templates/components/app_header.html +85 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/templates/components/footer.html +59 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/templates/components/header.html +30 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/templates/components/settings_desktop_sidebar.html +127 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/templates/pages/index.html +40 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/__init__.py +3 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/asgi.py +9 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/__init__.py +0 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/admin.py +1 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/apps.py +7 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/factories.py +11 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/forms.py +91 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/migrations/0001_initial.py +116 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/migrations/__init__.py +0 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/models.py +81 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/templates/auth/forms/login_form.html +42 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/templates/auth/forms/register_form.html +80 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/templates/auth/forms/update_password_form.html +57 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/templates/auth/forms/update_user_form.html +24 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/templates/auth/pages/account_settings.html +33 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/templates/auth/pages/email_settings.html +31 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/templates/auth/pages/login.html +60 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/templates/auth/pages/register.html +59 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/templates/auth/pages/security_settings.html +35 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/tests/__init__.py +0 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/tests/views/__init__.py +0 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/tests/views/login_view/__init__.py +0 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/tests/views/login_view/tests.py +110 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/tests/views/logout_view/__init__.py +0 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/tests/views/logout_view/tests.py +74 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/tests/views/register_view/__init__.py +0 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/tests/views/register_view/tests.py +170 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/urls.py +14 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/utils.py +25 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/views.py +180 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/__init__.py +0 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/admin.py +4 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/apps.py +7 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/forms.py +39 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/migrations/0001_initial.py +41 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/migrations/__init__.py +0 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/models.py +11 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/templates/billing/forms/update_billing_information_form.html +117 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/templates/billing/pages/billing_settings.html +141 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/urls.py +24 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/utils.py +30 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/views.py +182 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/celery.py +14 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/context_processors.py +9 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/model_loaders.py +5 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/permissions.py +23 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/search/__init__.py +0 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/search/api_urls.py +13 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/search/api_views.py +27 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/search/apps.py +7 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/search/serializers.py +26 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/search/utils.py +20 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/serializers.py +23 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/settings.py +418 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/urls.py +27 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/utils/__init__.py +5 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/utils/apps.py +7 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/utils/templatetags/__init__.py +0 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/utils/templatetags/{{ cookiecutter.project_slug }}_utils_math.py +51 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/utils/templatetags/{{ cookiecutter.project_slug }}_utils_timestamp.py +10 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/utils/timezone/__init__.py +5 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/views.py +7 -0
- djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/wsgi.py +9 -0
- djangorocket/templates/projects/base.zip +0 -0
- djangorocket/templates/ui/__init__.py +0 -0
- djangorocket/templates/ui/accordion/README.md +0 -0
- djangorocket/templates/ui/accordion/cookiecutter.json +3 -0
- djangorocket/templates/ui/accordion/{{ cookiecutter.project_slug }}/accordion.html +3 -0
- djangorocket/templates/ui/accordion.zip +0 -0
- djrocket-1.0.0a2.dist-info/METADATA +114 -0
- djrocket-1.0.0a2.dist-info/RECORD +122 -0
- djrocket-1.0.0a2.dist-info/WHEEL +5 -0
- djrocket-1.0.0a2.dist-info/entry_points.txt +2 -0
- djrocket-1.0.0a2.dist-info/licenses/LICENSE.md +201 -0
- djrocket-1.0.0a2.dist-info/top_level.txt +1 -0
djangorocket/__init__.py
ADDED
|
File without changes
|
djangorocket/__main__.py
ADDED
djangorocket/cli.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import importlib.resources
|
|
3
|
+
import itertools
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
from cookiecutter.main import cookiecutter
|
|
14
|
+
from cookiecutter.exceptions import OutputDirExistsException
|
|
15
|
+
|
|
16
|
+
from djangorocket.components import add_components
|
|
17
|
+
from djangorocket.django import DjangoSettingsManager
|
|
18
|
+
|
|
19
|
+
@click.group()
|
|
20
|
+
def main():
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@contextlib.contextmanager
|
|
25
|
+
def _spinner(message):
|
|
26
|
+
"""Show an animated 'working' indicator while a slow step runs.
|
|
27
|
+
|
|
28
|
+
Generating a project renders well over a hundred files with no output of its
|
|
29
|
+
own, which makes the command look hung. This keeps the terminal alive with a
|
|
30
|
+
spinner on a TTY, and falls back to a single plain line otherwise (e.g. when
|
|
31
|
+
output is piped or running in CI).
|
|
32
|
+
"""
|
|
33
|
+
if not sys.stderr.isatty():
|
|
34
|
+
click.echo(message, err=True)
|
|
35
|
+
yield
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
done = threading.Event()
|
|
39
|
+
|
|
40
|
+
def spin():
|
|
41
|
+
for frame in itertools.cycle("|/-\\"):
|
|
42
|
+
if done.is_set():
|
|
43
|
+
break
|
|
44
|
+
click.echo(f"\r{frame} {message}", nl=False, err=True)
|
|
45
|
+
time.sleep(0.1)
|
|
46
|
+
click.echo("\r\033[K", nl=False, err=True) # clear the spinner line
|
|
47
|
+
|
|
48
|
+
thread = threading.Thread(target=spin)
|
|
49
|
+
thread.start()
|
|
50
|
+
try:
|
|
51
|
+
yield
|
|
52
|
+
finally:
|
|
53
|
+
done.set()
|
|
54
|
+
thread.join()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _slugify_dirname(project_name):
|
|
58
|
+
"""Kebab-case a project name for use as a directory / docker identifier."""
|
|
59
|
+
slug = re.sub(r"[^a-z0-9]+", "-", project_name.lower()).strip("-")
|
|
60
|
+
return slug or "project"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _existing_docker_volume_names():
|
|
64
|
+
"""Names of existing docker volumes, or empty set when docker is unavailable."""
|
|
65
|
+
try:
|
|
66
|
+
result = subprocess.run(
|
|
67
|
+
["docker", "volume", "ls", "--format", "{{.Name}}"],
|
|
68
|
+
capture_output=True,
|
|
69
|
+
text=True,
|
|
70
|
+
timeout=10,
|
|
71
|
+
check=True,
|
|
72
|
+
)
|
|
73
|
+
except (OSError, subprocess.SubprocessError):
|
|
74
|
+
return set()
|
|
75
|
+
return set(result.stdout.split())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _available_project_dirname(base, output_dir, taken_volumes):
|
|
79
|
+
"""First of ``base``, ``base-1``, ``base-2``, ... free on disk and in docker.
|
|
80
|
+
|
|
81
|
+
A generated project's docker-compose names its volumes ``<dirname>_postgres_data``
|
|
82
|
+
and ``<dirname>_redis_data``, so we also skip a name whose volumes already exist
|
|
83
|
+
even when the directory does not (e.g. a previous project was removed but its
|
|
84
|
+
data volumes lingered).
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def is_taken(name):
|
|
88
|
+
if os.path.exists(os.path.join(output_dir, name)):
|
|
89
|
+
return True
|
|
90
|
+
return any(
|
|
91
|
+
"{0}_{1}".format(name, suffix) in taken_volumes
|
|
92
|
+
for suffix in ("postgres_data", "redis_data")
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
candidate = base
|
|
96
|
+
counter = 0
|
|
97
|
+
while is_taken(candidate):
|
|
98
|
+
counter += 1
|
|
99
|
+
candidate = "{0}-{1}".format(base, counter)
|
|
100
|
+
return candidate
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _port_is_free(port):
|
|
104
|
+
"""True if ``port`` can be bound on this host right now."""
|
|
105
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
106
|
+
try:
|
|
107
|
+
sock.bind(("", port))
|
|
108
|
+
return True
|
|
109
|
+
except OSError:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _reserved_sibling_ports(output_dir):
|
|
114
|
+
"""Host ports already assigned to sibling projects' ``.env`` files.
|
|
115
|
+
|
|
116
|
+
``init`` picks ports that are free *now*, but a project created a moment ago
|
|
117
|
+
may not have its containers up yet, so its port would still look free. Reading
|
|
118
|
+
the ports it reserved in ``.env`` lets us hand out distinct ones to each
|
|
119
|
+
project generated in the same directory.
|
|
120
|
+
"""
|
|
121
|
+
reserved = set()
|
|
122
|
+
try:
|
|
123
|
+
entries = os.listdir(output_dir)
|
|
124
|
+
except OSError:
|
|
125
|
+
return reserved
|
|
126
|
+
for entry in entries:
|
|
127
|
+
env_path = os.path.join(output_dir, entry, ".env")
|
|
128
|
+
if not os.path.isfile(env_path):
|
|
129
|
+
continue
|
|
130
|
+
try:
|
|
131
|
+
with open(env_path) as env_file:
|
|
132
|
+
for line in env_file:
|
|
133
|
+
match = re.match(r"(?:POSTGRES_PORT|REDIS_PORT)=(\d+)\s*$", line)
|
|
134
|
+
if match:
|
|
135
|
+
reserved.add(int(match.group(1)))
|
|
136
|
+
except OSError:
|
|
137
|
+
continue
|
|
138
|
+
return reserved
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _pick_host_port(preferred, reserved):
|
|
142
|
+
"""First port from ``preferred`` up that is free and not already reserved.
|
|
143
|
+
|
|
144
|
+
Mutates ``reserved`` so the same call site can pick several distinct ports
|
|
145
|
+
(e.g. Postgres then Redis) without handing out a duplicate.
|
|
146
|
+
"""
|
|
147
|
+
port = preferred
|
|
148
|
+
while port in reserved or not _port_is_free(port):
|
|
149
|
+
port += 1
|
|
150
|
+
reserved.add(port)
|
|
151
|
+
return port
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@main.command()
|
|
155
|
+
def init():
|
|
156
|
+
"""Scaffold a new DjangoRocket project in the current directory."""
|
|
157
|
+
project_name = click.prompt("Project name", default="My Project")
|
|
158
|
+
base_template = importlib.resources.files("djangorocket.templates.projects").joinpath("base")
|
|
159
|
+
|
|
160
|
+
output_dir = os.getcwd()
|
|
161
|
+
|
|
162
|
+
# Pick a project directory name that is free both on disk and as a docker
|
|
163
|
+
# volume prefix, so several generated projects can coexist on one machine.
|
|
164
|
+
project_dirname = _available_project_dirname(
|
|
165
|
+
_slugify_dirname(project_name),
|
|
166
|
+
output_dir,
|
|
167
|
+
_existing_docker_volume_names(),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Pick free host ports so several projects' Postgres/Redis can run at once.
|
|
171
|
+
reserved_ports = _reserved_sibling_ports(output_dir)
|
|
172
|
+
postgres_host_port = _pick_host_port(5432, reserved_ports)
|
|
173
|
+
redis_host_port = _pick_host_port(6379, reserved_ports)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
with _spinner("Creating your project..."):
|
|
177
|
+
project_dir = cookiecutter(
|
|
178
|
+
str(base_template),
|
|
179
|
+
no_input=True,
|
|
180
|
+
extra_context={
|
|
181
|
+
"project_name": project_name,
|
|
182
|
+
"project_dirname": project_dirname,
|
|
183
|
+
"postgres_host_port": str(postgres_host_port),
|
|
184
|
+
"redis_host_port": str(redis_host_port),
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
except OutputDirExistsException as e:
|
|
188
|
+
detail = str(e).removeprefix("Error: ")
|
|
189
|
+
raise click.ClickException(
|
|
190
|
+
f"{detail}. Remove it or pick a different project name and try again."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
click.echo(click.style(f"✓ Created {os.path.relpath(project_dir)}", fg="green"))
|
|
194
|
+
# Point at the absolute path: `init` may have been run from anywhere (e.g.
|
|
195
|
+
# `make playground` runs it in $PLAYGROUND_BASE_DIR), and the next step is
|
|
196
|
+
# opening the project in an editor, not cd-ing in this shell.
|
|
197
|
+
click.echo(f" Open the project in your editor: {os.path.abspath(project_dir)}")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@main.command()
|
|
201
|
+
@click.argument("components", nargs=-1)
|
|
202
|
+
@click.option("--templates-dir", default=None, help="Directory where the new template source file should be added to.")
|
|
203
|
+
def add(components, templates_dir):
|
|
204
|
+
"""Add a UI cookiecutter template to an existing DjangoRocket project."""
|
|
205
|
+
try:
|
|
206
|
+
if templates_dir is None:
|
|
207
|
+
django_settings = DjangoSettingsManager()
|
|
208
|
+
templates_dir = django_settings.get_templates_dir()
|
|
209
|
+
|
|
210
|
+
add_components(components, templates_dir)
|
|
211
|
+
except Exception as e:
|
|
212
|
+
click.echo(f"Error: {e}")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import importlib.resources
|
|
2
|
+
|
|
3
|
+
from cookiecutter.main import cookiecutter
|
|
4
|
+
|
|
5
|
+
UI_TEMPLATES_PACKAGE = "djangorocket.templates.ui"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def available_components():
|
|
9
|
+
"""Names of the UI templates packaged with this install.
|
|
10
|
+
|
|
11
|
+
Templates ship as ``<name>.zip`` built from ``templates/ui/<name>/`` by
|
|
12
|
+
``setup.py`` (see ``tools/zip_templates.py``), so the packaged zips are the
|
|
13
|
+
source of truth for what ``add`` can install.
|
|
14
|
+
"""
|
|
15
|
+
return sorted(
|
|
16
|
+
resource.name.removesuffix(".zip")
|
|
17
|
+
for resource in importlib.resources.files(UI_TEMPLATES_PACKAGE).iterdir()
|
|
18
|
+
if resource.name.endswith(".zip")
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def add_components(components, templates_dir=None):
|
|
23
|
+
for component_name in components:
|
|
24
|
+
add_component(component_name, templates_dir)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def add_component(component_name, templates_dir=None):
|
|
28
|
+
"""Render the ``component_name`` UI template into ``templates_dir``."""
|
|
29
|
+
resource = importlib.resources.files(UI_TEMPLATES_PACKAGE).joinpath(f"{component_name}.zip")
|
|
30
|
+
if not resource.is_file():
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"unknown component '{component_name}'. "
|
|
33
|
+
f"Available components: {', '.join(available_components()) or 'none'}."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# cookiecutter needs a real filesystem path, which a zipped install does not
|
|
37
|
+
# expose directly; ``as_file`` materialises one for the duration of the block.
|
|
38
|
+
with importlib.resources.as_file(resource) as template_path:
|
|
39
|
+
cookiecutter(str(template_path), output_dir=templates_dir)
|
djangorocket/django.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import os
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DjangoManageManager:
|
|
8
|
+
def __init__(self, manage_path=None):
|
|
9
|
+
"""
|
|
10
|
+
Initialize the DjangoManageManager with the path to the manage.py file.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
manage_path (str, optional): The path to the Django manage.py file. Defaults to None.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
if manage_path is not None:
|
|
17
|
+
if os.path.isfile(manage_path):
|
|
18
|
+
self.manage_path = manage_path
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
raise FileNotFoundError("manage.py not found in the passed `manage_path`.")
|
|
22
|
+
|
|
23
|
+
default_manage_module = [
|
|
24
|
+
os.path.join(os.getcwd(), "manage.py"),
|
|
25
|
+
os.path.join(os.getcwd(), "src", "manage.py"),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
for module in default_manage_module:
|
|
29
|
+
if os.path.isfile(module):
|
|
30
|
+
self.manage_path = module
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
raise FileNotFoundError("manage.py not found in the current directory or src subdirectory.")
|
|
34
|
+
|
|
35
|
+
def get_default_settings_module(self):
|
|
36
|
+
"""
|
|
37
|
+
Retrieve the value set in the os.environ.setdefault('DJANGO_SETTINGS_MODULE', ...) line.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
str: The value of the default settings module.
|
|
41
|
+
"""
|
|
42
|
+
with open(self.manage_path, "r") as file:
|
|
43
|
+
tree = ast.parse(file.read())
|
|
44
|
+
|
|
45
|
+
for node in ast.walk(tree):
|
|
46
|
+
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
|
|
47
|
+
func = node.value
|
|
48
|
+
if (
|
|
49
|
+
isinstance(func.func, ast.Attribute)
|
|
50
|
+
and func.func.attr == "setdefault"
|
|
51
|
+
and isinstance(func.func.value, ast.Attribute)
|
|
52
|
+
and func.func.value.attr == "environ"
|
|
53
|
+
and len(func.args) == 2
|
|
54
|
+
and isinstance(func.args[0], ast.Constant)
|
|
55
|
+
and func.args[0].value == "DJANGO_SETTINGS_MODULE"
|
|
56
|
+
):
|
|
57
|
+
if isinstance(func.args[1], ast.Constant):
|
|
58
|
+
return func.args[1].value
|
|
59
|
+
|
|
60
|
+
raise ValueError("DJANGO_SETTINGS_MODULE not found in manage.py")
|
|
61
|
+
|
|
62
|
+
def get_settings_path(self):
|
|
63
|
+
"""
|
|
64
|
+
Get the file path of the settings.py file based on the settings module.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
str: The absolute path to the settings.py file.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
FileNotFoundError: If the settings.py file does not exist.
|
|
71
|
+
"""
|
|
72
|
+
settings_module = self.get_default_settings_module()
|
|
73
|
+
manage_dir = os.path.dirname(self.manage_path)
|
|
74
|
+
|
|
75
|
+
settings_rel_path = settings_module.replace(".", os.sep) + ".py"
|
|
76
|
+
|
|
77
|
+
settings_path = os.path.join(manage_dir, settings_rel_path)
|
|
78
|
+
|
|
79
|
+
if not os.path.isfile(settings_path):
|
|
80
|
+
raise FileNotFoundError(f"Settings file not found: {settings_path}")
|
|
81
|
+
|
|
82
|
+
return settings_path
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class DjangoSettingsManager:
|
|
86
|
+
def __init__(self, settings_path=None):
|
|
87
|
+
"""
|
|
88
|
+
Initialize the DjangoSettingsManager with the path to the settings.py file.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
settings_path (str): The path to the Django settings.py file.
|
|
92
|
+
"""
|
|
93
|
+
if settings_path is not None:
|
|
94
|
+
if not os.path.isfile(settings_path):
|
|
95
|
+
raise FileNotFoundError(f"Settings file not found: {settings_path}")
|
|
96
|
+
else:
|
|
97
|
+
manage_module = DjangoManageManager()
|
|
98
|
+
settings_path = manage_module.get_settings_path()
|
|
99
|
+
|
|
100
|
+
self.settings_path = settings_path
|
|
101
|
+
self.tree = self._load_ast()
|
|
102
|
+
self.logger = logging.getLogger(__name__)
|
|
103
|
+
|
|
104
|
+
def _load_ast(self):
|
|
105
|
+
"""
|
|
106
|
+
Parse the settings.py file into an AST.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
ast.Module: The parsed AST of the settings.py file.
|
|
110
|
+
"""
|
|
111
|
+
with open(self.settings_path, "r") as file:
|
|
112
|
+
return ast.parse(file.read())
|
|
113
|
+
|
|
114
|
+
def _write_ast(self):
|
|
115
|
+
"""
|
|
116
|
+
Write the modified AST back to the settings.py file.
|
|
117
|
+
"""
|
|
118
|
+
with open(self.settings_path, "w") as file:
|
|
119
|
+
file.write(ast.unparse(self.tree))
|
|
120
|
+
|
|
121
|
+
def _find_base_dir_node(self):
|
|
122
|
+
"""
|
|
123
|
+
Find the definition of BASE_DIR in the settings.py file.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
ast.Name or None: The AST node representing BASE_DIR, or None if not found.
|
|
127
|
+
"""
|
|
128
|
+
for node in self.tree.body:
|
|
129
|
+
if isinstance(node, ast.Assign):
|
|
130
|
+
for target in node.targets:
|
|
131
|
+
if isinstance(target, ast.Name) and target.id == "BASE_DIR":
|
|
132
|
+
return node.value
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
def _count_path_steps_from_file(self, node):
|
|
136
|
+
"""
|
|
137
|
+
Count how many directory levels above the settings file an AST node points to.
|
|
138
|
+
|
|
139
|
+
Recognizes the two common ways a Django ``settings.py`` derives a path
|
|
140
|
+
from ``__file__``:
|
|
141
|
+
|
|
142
|
+
* ``Path(__file__).resolve().parent[...]`` - each ``.parent`` climbs
|
|
143
|
+
one level (``.resolve()``/``.absolute()`` are treated as no-ops);
|
|
144
|
+
* ``os.path.dirname(...)`` nested around ``os.path.abspath(__file__)``
|
|
145
|
+
- each ``os.path.dirname(...)`` climbs one level.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
node (ast.AST): The AST node to inspect.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
int or None: The number of levels above the settings file, or None
|
|
152
|
+
if the node does not derive a path from ``__file__``.
|
|
153
|
+
"""
|
|
154
|
+
# Base case: __file__ resolves to the settings file itself (0 levels up).
|
|
155
|
+
if isinstance(node, ast.Name) and node.id == "__file__":
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
# Attribute access without a call, e.g. ``.parent`` on a pathlib.Path.
|
|
159
|
+
if isinstance(node, ast.Attribute):
|
|
160
|
+
inner = self._count_path_steps_from_file(node.value)
|
|
161
|
+
if inner is None:
|
|
162
|
+
return None
|
|
163
|
+
return inner + 1 if node.attr == "parent" else inner
|
|
164
|
+
|
|
165
|
+
# Calls: Path(...), X.resolve(), os.path.dirname(...), os.path.abspath(...)
|
|
166
|
+
if isinstance(node, ast.Call):
|
|
167
|
+
func = node.func
|
|
168
|
+
# Method / os.path call, e.g. Path(__file__).resolve() or
|
|
169
|
+
# os.path.dirname(...).
|
|
170
|
+
if isinstance(func, ast.Attribute):
|
|
171
|
+
if func.attr in ("resolve", "absolute"):
|
|
172
|
+
return self._count_path_steps_from_file(func.value)
|
|
173
|
+
if func.attr == "abspath" and node.args:
|
|
174
|
+
return self._count_path_steps_from_file(node.args[0])
|
|
175
|
+
if func.attr == "dirname" and node.args:
|
|
176
|
+
inner = self._count_path_steps_from_file(node.args[0])
|
|
177
|
+
return None if inner is None else inner + 1
|
|
178
|
+
return None
|
|
179
|
+
# Plain call, e.g. Path(__file__).
|
|
180
|
+
if isinstance(func, ast.Name) and func.id == "Path" and node.args:
|
|
181
|
+
return self._count_path_steps_from_file(node.args[0])
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
def _resolve_base_dir(self, node):
|
|
187
|
+
"""
|
|
188
|
+
Resolve the value of BASE_DIR from its AST node.
|
|
189
|
+
|
|
190
|
+
Handles both a literal path (``BASE_DIR = "/path/to/project"``) and the
|
|
191
|
+
idioms that compute it relative to the settings file, e.g. the Django
|
|
192
|
+
default ``BASE_DIR = Path(__file__).resolve().parent.parent``.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
node (ast.AST or None): The AST node assigned to BASE_DIR.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
str or None: The resolved directory path, or None if it cannot be
|
|
199
|
+
determined.
|
|
200
|
+
"""
|
|
201
|
+
if node is None:
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
# Literal path, e.g. BASE_DIR = "/path/to/project".
|
|
205
|
+
try:
|
|
206
|
+
return ast.literal_eval(node)
|
|
207
|
+
except (ValueError, TypeError, SyntaxError):
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
# Path computed from this settings file, e.g. Path(__file__).parent.parent.
|
|
211
|
+
steps = self._count_path_steps_from_file(node)
|
|
212
|
+
if steps is not None:
|
|
213
|
+
base = Path(self.settings_path).resolve()
|
|
214
|
+
for _ in range(steps):
|
|
215
|
+
base = base.parent
|
|
216
|
+
return str(base)
|
|
217
|
+
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
def _find_installed_apps_node(self):
|
|
221
|
+
"""
|
|
222
|
+
Find the AST node for the INSTALLED_APPS variable.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
ast.List: The AST node representing the INSTALLED_APPS list.
|
|
226
|
+
"""
|
|
227
|
+
for node in self.tree.body:
|
|
228
|
+
if isinstance(node, ast.Assign):
|
|
229
|
+
for target in node.targets:
|
|
230
|
+
if isinstance(target, ast.Name) and target.id == "INSTALLED_APPS":
|
|
231
|
+
if isinstance(node.value, ast.List):
|
|
232
|
+
return node.value
|
|
233
|
+
raise ValueError("INSTALLED_APPS not found in settings.py")
|
|
234
|
+
|
|
235
|
+
def _find_templates_node(self):
|
|
236
|
+
"""
|
|
237
|
+
Find the AST node for the TEMPLATES variable.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
ast.Dict: The AST node representing the TEMPLATES dictionary.
|
|
241
|
+
"""
|
|
242
|
+
for node in self.tree.body:
|
|
243
|
+
if isinstance(node, ast.Assign):
|
|
244
|
+
for target in node.targets:
|
|
245
|
+
if isinstance(target, ast.Name) and target.id == "TEMPLATES":
|
|
246
|
+
if isinstance(node.value, ast.List) and len(node.value.elts) > 0:
|
|
247
|
+
first_element = node.value.elts[0]
|
|
248
|
+
if isinstance(first_element, ast.Dict):
|
|
249
|
+
return first_element
|
|
250
|
+
raise ValueError("TEMPLATES dictionary not found in settings.py")
|
|
251
|
+
|
|
252
|
+
def add_app(self, app_name):
|
|
253
|
+
"""
|
|
254
|
+
Add an app to the INSTALLED_APPS setting in the Django settings file.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
app_name (str): The name of the app to add.
|
|
258
|
+
"""
|
|
259
|
+
installed_apps_node = self._find_installed_apps_node()
|
|
260
|
+
|
|
261
|
+
# Check if the app is already in the list
|
|
262
|
+
for element in installed_apps_node.elts:
|
|
263
|
+
if isinstance(element, ast.Constant) and element.value == app_name:
|
|
264
|
+
self.logger.warning(f"App '{app_name}' is already in INSTALLED_APPS.")
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
# Add the app to the list
|
|
268
|
+
installed_apps_node.elts.append(ast.Constant(value=app_name))
|
|
269
|
+
self._write_ast()
|
|
270
|
+
self.logger.info(f"App '{app_name}' added to INSTALLED_APPS.")
|
|
271
|
+
|
|
272
|
+
def remove_app(self, app_name):
|
|
273
|
+
"""
|
|
274
|
+
Remove an app from the INSTALLED_APPS setting in the Django settings file.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
app_name (str): The name of the app to remove.
|
|
278
|
+
"""
|
|
279
|
+
installed_apps_node = self._find_installed_apps_node()
|
|
280
|
+
|
|
281
|
+
# Remove the app from the list
|
|
282
|
+
new_elements = [
|
|
283
|
+
element
|
|
284
|
+
for element in installed_apps_node.elts
|
|
285
|
+
if not (isinstance(element, ast.Constant) and element.value == app_name)
|
|
286
|
+
]
|
|
287
|
+
|
|
288
|
+
if len(new_elements) == len(installed_apps_node.elts):
|
|
289
|
+
self.logger.warning(f"App '{app_name}' is not in INSTALLED_APPS.")
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
installed_apps_node.elts = new_elements
|
|
293
|
+
self._write_ast()
|
|
294
|
+
self.logger.info(f"App '{app_name}' removed from INSTALLED_APPS.")
|
|
295
|
+
|
|
296
|
+
def get_templates_dirs(self):
|
|
297
|
+
"""
|
|
298
|
+
Retrieve the raw string values of directories specified in the TEMPLATES["DIRS"] setting.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
list: A list of raw string directory paths.
|
|
302
|
+
"""
|
|
303
|
+
# Find and resolve the BASE_DIR definition
|
|
304
|
+
base_dir_node = self._find_base_dir_node()
|
|
305
|
+
base_dir = self._resolve_base_dir(base_dir_node)
|
|
306
|
+
|
|
307
|
+
# Find the TEMPLATES node
|
|
308
|
+
templates_node = self._find_templates_node()
|
|
309
|
+
|
|
310
|
+
# Extract the DIRS key and compute raw string values
|
|
311
|
+
for key, value in zip(templates_node.keys, templates_node.values):
|
|
312
|
+
if isinstance(key, ast.Constant) and key.value == "DIRS":
|
|
313
|
+
if isinstance(value, ast.List):
|
|
314
|
+
raw_dirs = []
|
|
315
|
+
for element in value.elts:
|
|
316
|
+
if isinstance(element, ast.Call) and isinstance(element.func, ast.Attribute):
|
|
317
|
+
# Handle os.path.join(BASE_DIR, ...)
|
|
318
|
+
if element.func.attr == "join" and len(element.args) > 1:
|
|
319
|
+
if isinstance(element.args[0], ast.Name) and element.args[0].id == "BASE_DIR":
|
|
320
|
+
if base_dir is None:
|
|
321
|
+
raise ValueError("BASE_DIR is not defined in settings.py")
|
|
322
|
+
joined_path = os.path.join(base_dir, *[ast.literal_eval(arg) for arg in element.args[1:]])
|
|
323
|
+
raw_dirs.append(joined_path)
|
|
324
|
+
elif isinstance(element, ast.Constant):
|
|
325
|
+
# Handle plain string paths
|
|
326
|
+
raw_dirs.append(ast.literal_eval(element))
|
|
327
|
+
return raw_dirs
|
|
328
|
+
|
|
329
|
+
raise ValueError("DIRS key not found in TEMPLATES setting or is not a list.")
|
|
330
|
+
|
|
331
|
+
def get_templates_dir(self):
|
|
332
|
+
"""
|
|
333
|
+
Retrieve the primary templates directory from TEMPLATES["DIRS"].
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
str: The first directory configured in TEMPLATES["DIRS"].
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
ValueError: If no template directories are configured.
|
|
340
|
+
"""
|
|
341
|
+
templates_dirs = self.get_templates_dirs()
|
|
342
|
+
if not templates_dirs:
|
|
343
|
+
raise ValueError("No template directories are configured in TEMPLATES['DIRS'].")
|
|
344
|
+
return templates_dirs[0]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"project_name": "My Project",
|
|
3
|
+
"project_slug": "{{ cookiecutter.project_name.lower()|replace(' ', '_')|replace('-', '_')|replace('.', '_')|trim() }}",
|
|
4
|
+
"project_dirname": "{{ cookiecutter.project_name.lower()|replace(' ', '-')|replace('_', '-')|replace('.', '-')|trim() }}",
|
|
5
|
+
"postgres_host_port": "5432",
|
|
6
|
+
"redis_host_port": "6379"
|
|
7
|
+
}
|