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.
Files changed (122) hide show
  1. djangorocket/__init__.py +0 -0
  2. djangorocket/__main__.py +4 -0
  3. djangorocket/cli.py +212 -0
  4. djangorocket/components.py +39 -0
  5. djangorocket/django.py +344 -0
  6. djangorocket/templates/__init__.py +0 -0
  7. djangorocket/templates/projects/__init__.py +0 -0
  8. djangorocket/templates/projects/base/cookiecutter.json +7 -0
  9. djangorocket/templates/projects/base/hooks/post_gen_project.py +84 -0
  10. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/.coveragerc +10 -0
  11. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/.env.example +43 -0
  12. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/.flake8 +19 -0
  13. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/.gitignore +58 -0
  14. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/.isort.cfg +9 -0
  15. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/Makefile +107 -0
  16. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/README.md +131 -0
  17. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/docker-compose.yml +57 -0
  18. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/pyproject.toml +27 -0
  19. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/pytest.ini +5 -0
  20. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/requirements/requirements-dev.txt +1 -0
  21. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/requirements/requirements-docs.txt +1 -0
  22. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/requirements/requirements-linting.txt +4 -0
  23. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/requirements/requirements-testing.txt +8 -0
  24. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/requirements/requirements.txt +32 -0
  25. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/requirements.txt +5 -0
  26. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/runtime.txt +1 -0
  27. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/manage.py +22 -0
  28. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/static/CACHE/manifest.json +0 -0
  29. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/static/img/favicon/favicon.png +0 -0
  30. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/static/img/favicon/favicon.svg +46 -0
  31. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/__init__.py +0 -0
  32. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/apps.py +5 -0
  33. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/static/css/dist/styles.css +2765 -0
  34. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/static_src/.gitignore +1 -0
  35. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/static_src/package-lock.json +1880 -0
  36. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/static_src/package.json +29 -0
  37. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/static_src/postcss.config.js +7 -0
  38. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/static_src/src/styles.css +41 -0
  39. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/static_src/tailwind.config.js +53 -0
  40. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/tailwind_theme/templates/base.html +20 -0
  41. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/templates/base.html +27 -0
  42. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/templates/base_settings.html +21 -0
  43. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/templates/components/app_header.html +85 -0
  44. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/templates/components/footer.html +59 -0
  45. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/templates/components/header.html +30 -0
  46. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/templates/components/settings_desktop_sidebar.html +127 -0
  47. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/templates/pages/index.html +40 -0
  48. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/__init__.py +3 -0
  49. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/asgi.py +9 -0
  50. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/__init__.py +0 -0
  51. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/admin.py +1 -0
  52. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/apps.py +7 -0
  53. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/factories.py +11 -0
  54. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/forms.py +91 -0
  55. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/migrations/0001_initial.py +116 -0
  56. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/migrations/__init__.py +0 -0
  57. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/models.py +81 -0
  58. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/templates/auth/forms/login_form.html +42 -0
  59. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/templates/auth/forms/register_form.html +80 -0
  60. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/templates/auth/forms/update_password_form.html +57 -0
  61. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/templates/auth/forms/update_user_form.html +24 -0
  62. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/templates/auth/pages/account_settings.html +33 -0
  63. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/templates/auth/pages/email_settings.html +31 -0
  64. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/templates/auth/pages/login.html +60 -0
  65. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/templates/auth/pages/register.html +59 -0
  66. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/templates/auth/pages/security_settings.html +35 -0
  67. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/tests/__init__.py +0 -0
  68. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/tests/views/__init__.py +0 -0
  69. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/tests/views/login_view/__init__.py +0 -0
  70. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/tests/views/login_view/tests.py +110 -0
  71. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/tests/views/logout_view/__init__.py +0 -0
  72. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/tests/views/logout_view/tests.py +74 -0
  73. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/tests/views/register_view/__init__.py +0 -0
  74. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/tests/views/register_view/tests.py +170 -0
  75. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/urls.py +14 -0
  76. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/utils.py +25 -0
  77. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/auth/views.py +180 -0
  78. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/__init__.py +0 -0
  79. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/admin.py +4 -0
  80. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/apps.py +7 -0
  81. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/forms.py +39 -0
  82. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/migrations/0001_initial.py +41 -0
  83. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/migrations/__init__.py +0 -0
  84. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/models.py +11 -0
  85. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/templates/billing/forms/update_billing_information_form.html +117 -0
  86. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/templates/billing/pages/billing_settings.html +141 -0
  87. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/urls.py +24 -0
  88. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/utils.py +30 -0
  89. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/billing/views.py +182 -0
  90. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/celery.py +14 -0
  91. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/context_processors.py +9 -0
  92. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/model_loaders.py +5 -0
  93. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/permissions.py +23 -0
  94. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/search/__init__.py +0 -0
  95. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/search/api_urls.py +13 -0
  96. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/search/api_views.py +27 -0
  97. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/search/apps.py +7 -0
  98. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/search/serializers.py +26 -0
  99. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/search/utils.py +20 -0
  100. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/serializers.py +23 -0
  101. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/settings.py +418 -0
  102. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/urls.py +27 -0
  103. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/utils/__init__.py +5 -0
  104. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/utils/apps.py +7 -0
  105. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/utils/templatetags/__init__.py +0 -0
  106. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/utils/templatetags/{{ cookiecutter.project_slug }}_utils_math.py +51 -0
  107. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/utils/templatetags/{{ cookiecutter.project_slug }}_utils_timestamp.py +10 -0
  108. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/utils/timezone/__init__.py +5 -0
  109. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/views.py +7 -0
  110. djangorocket/templates/projects/base/{{ cookiecutter.project_dirname }}/src/{{ cookiecutter.project_slug }}/wsgi.py +9 -0
  111. djangorocket/templates/projects/base.zip +0 -0
  112. djangorocket/templates/ui/__init__.py +0 -0
  113. djangorocket/templates/ui/accordion/README.md +0 -0
  114. djangorocket/templates/ui/accordion/cookiecutter.json +3 -0
  115. djangorocket/templates/ui/accordion/{{ cookiecutter.project_slug }}/accordion.html +3 -0
  116. djangorocket/templates/ui/accordion.zip +0 -0
  117. djrocket-1.0.0a2.dist-info/METADATA +114 -0
  118. djrocket-1.0.0a2.dist-info/RECORD +122 -0
  119. djrocket-1.0.0a2.dist-info/WHEEL +5 -0
  120. djrocket-1.0.0a2.dist-info/entry_points.txt +2 -0
  121. djrocket-1.0.0a2.dist-info/licenses/LICENSE.md +201 -0
  122. djrocket-1.0.0a2.dist-info/top_level.txt +1 -0
File without changes
@@ -0,0 +1,4 @@
1
+ from djangorocket.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main(prog_name="djangorocket")
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
+ }