djangx 1.5.5__tar.gz → 1.5.6__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.
Files changed (82) hide show
  1. {djangx-1.5.5 → djangx-1.5.6}/PKG-INFO +2 -1
  2. {djangx-1.5.5 → djangx-1.5.6}/pyproject.toml +2 -1
  3. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/__init__.py +14 -6
  4. djangx-1.5.5/src/djangx/management/commands/generators/file.py → djangx-1.5.6/src/djangx/management/commands/generate.py +59 -6
  5. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/commands/startproject.py +370 -61
  6. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/settings/apps.py +2 -1
  7. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/urls.py +5 -1
  8. djangx-1.5.5/src/djangx/management/commands/generate.py +0 -61
  9. djangx-1.5.5/src/djangx/management/commands/generators/__init__.py +0 -1
  10. {djangx-1.5.5 → djangx-1.5.6}/LICENSE +0 -0
  11. {djangx-1.5.5 → djangx-1.5.6}/README.md +0 -0
  12. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/api/__init__.py +0 -0
  13. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/api/backends/__init__.py +0 -0
  14. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/api/backends/auth.py +0 -0
  15. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/api/backends/server/__init__.py +0 -0
  16. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/api/backends/server/asgi.py +0 -0
  17. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/api/backends/server/wsgi.py +0 -0
  18. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/api/backends/storages.py +0 -0
  19. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/api/settings/__init__.py +0 -0
  20. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/api/settings/auth.py +0 -0
  21. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/api/settings/databases.py +0 -0
  22. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/api/settings/server.py +0 -0
  23. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/api/settings/storages.py +0 -0
  24. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/api/types/__init__.py +0 -0
  25. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/api/types/auth.py +0 -0
  26. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/api/types/databases.py +0 -0
  27. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/api/types/storages.py +0 -0
  28. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/api/urls.py +0 -0
  29. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/__init__.py +0 -0
  30. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/cli.py +0 -0
  31. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/commands/__init__.py +0 -0
  32. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/commands/collectstatic.py +0 -0
  33. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/commands/helpers/__init__.py +0 -0
  34. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/commands/helpers/art.py +0 -0
  35. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/commands/helpers/run.py +0 -0
  36. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/commands/runbuild.py +0 -0
  37. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/commands/runinstall.py +0 -0
  38. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/commands/runserver.py +0 -0
  39. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/commands/tailwind.py +0 -0
  40. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/settings/__init__.py +0 -0
  41. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/settings/runcommands.py +0 -0
  42. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/settings/security.py +0 -0
  43. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/settings/tailwind.py +0 -0
  44. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/types/__init__.py +0 -0
  45. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/management/types/apps.py +0 -0
  46. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/py.typed +0 -0
  47. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/settings.py +0 -0
  48. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/types.py +0 -0
  49. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/__init__.py +0 -0
  50. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/admin.py +0 -0
  51. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/settings/__init__.py +0 -0
  52. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/settings/contactinfo.py +0 -0
  53. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/settings/org.py +0 -0
  54. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/settings/social.py +0 -0
  55. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/static/ui/css/.gitignore +0 -0
  56. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/static/ui/css/aos.css +0 -0
  57. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/static/ui/css/bootstrap-icons.min.css +0 -0
  58. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/static/ui/css/fonts/bootstrap-icons.woff +0 -0
  59. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/static/ui/css/fonts/bootstrap-icons.woff2 +0 -0
  60. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/static/ui/img/apple-touch-icon.png +0 -0
  61. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/static/ui/img/favicon.ico +0 -0
  62. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/static/ui/img/logo.png +0 -0
  63. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/static/ui/js/aos-init.js +0 -0
  64. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/static/ui/js/aos.js +0 -0
  65. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/static/ui/js/preloader.js +0 -0
  66. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/static/ui/js/scroll-top.js +0 -0
  67. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/templates/ui/footer.html +0 -0
  68. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/templates/ui/header.html +0 -0
  69. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/templates/ui/hero.html +0 -0
  70. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/templates/ui/index.html +0 -0
  71. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/templates/ui/preloader.html +0 -0
  72. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/templates/ui/scroll-top.html +0 -0
  73. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/templatetags/__init__.py +0 -0
  74. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/templatetags/contactinfo.py +0 -0
  75. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/templatetags/org.py +0 -0
  76. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/templatetags/social.py +0 -0
  77. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/templatetags/tailwind_css.py +0 -0
  78. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/types/__init__.py +0 -0
  79. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/types/contactinfo.py +0 -0
  80. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/types/org.py +0 -0
  81. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/types/social.py +0 -0
  82. {djangx-1.5.5 → djangx-1.5.6}/src/djangx/ui/urls.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: djangx
3
- Version: 1.5.5
3
+ Version: 1.5.6
4
4
  Summary: Build and deploy Django apps with confidence.
5
5
  Author: Kevin Wasike Wakhisi
6
6
  Author-email: Kevin Wasike Wakhisi <kevin@christianwhocodes.space>
@@ -34,6 +34,7 @@ Requires-Dist: django-phonenumber-field[phonenumberslite]>=8.4.0
34
34
  Requires-Dist: django-watchfiles>=1.4.0
35
35
  Requires-Dist: pyperclip>=1.11.0
36
36
  Requires-Dist: python-dotenv>=1.2.1
37
+ Requires-Dist: rich>=14.3.2
37
38
  Requires-Python: >=3.12
38
39
  Project-URL: homepage, https://github.com/christianwhocodes/djangx#readme
39
40
  Project-URL: repository, https://github.com/christianwhocodes/djangx
@@ -13,7 +13,7 @@ djx = "djangx.management.cli:main"
13
13
 
14
14
  [project]
15
15
  name = "djangx"
16
- version = "1.5.5"
16
+ version = "1.5.6"
17
17
  description = "Build and deploy Django apps with confidence."
18
18
  readme = "README.md"
19
19
  license = { file = "LICENSE" }
@@ -31,6 +31,7 @@ dependencies = [
31
31
  "django-watchfiles>=1.4.0",
32
32
  "pyperclip>=1.11.0",
33
33
  "python-dotenv>=1.2.1",
34
+ "rich>=14.3.2",
34
35
  ]
35
36
 
36
37
  [dependency-groups]
@@ -43,6 +43,10 @@ PROJECT_INIT_NAME: str = PROJECT_DIR.name
43
43
 
44
44
  PROJECT_MAIN_APP_NAME: str = "home"
45
45
 
46
+ # bools
47
+
48
+ INCLUDE_PROJECT_MAIN_APP: bool = PROJECT_MAIN_APP_DIR.exists() and PROJECT_MAIN_APP_DIR.is_dir()
49
+
46
50
  # Settings configuration classes
47
51
 
48
52
  _ValueType: TypeAlias = Optional[str | bool | list[str] | pathlib.Path | int]
@@ -206,14 +210,18 @@ class Conf:
206
210
 
207
211
  except (FileNotFoundError, KeyError, ValueError) as e:
208
212
  cls._validated = False
213
+ print(f"Not in a valid {PKG_DISPLAY_NAME} project directory.", Text.ERROR)
209
214
  print(
210
- f"Are you currently executing in a {PKG_DISPLAY_NAME} project base directory?\n"
211
- f"If not, navigate to your project's root or create a new {PKG_DISPLAY_NAME} project to run the command.\n\n"
212
- "A valid project requires:\n"
213
- f" - `pyproject.toml` file with a 'tool.{PKG_NAME}' section (even if empty)\n"
214
- f"Validation failed: {e}",
215
- Text.WARNING,
215
+ f"A valid project requires: pyproject.toml with a 'tool.{PKG_NAME}' section (even if empty)",
216
+ Text.INFO,
217
+ )
218
+ print(
219
+ [
220
+ ("Create a new project: ", None),
221
+ (f"uvx {PKG_NAME} startproject (if uv is installed.)", Text.HIGHLIGHT),
222
+ ]
216
223
  )
224
+ print(f"Validation error: {e}")
217
225
 
218
226
  except Exception as e:
219
227
  cls._validated = False
@@ -1,13 +1,21 @@
1
1
  import builtins
2
2
  import pathlib
3
+ from enum import StrEnum
3
4
  from typing import Any, Optional, cast
4
5
 
5
- from christianwhocodes.generators import FileGenerator
6
+ from christianwhocodes.generators import (
7
+ FileGenerator,
8
+ FileGeneratorOption,
9
+ PgPassFileGenerator,
10
+ PgServiceFileGenerator,
11
+ SSHConfigFileGenerator,
12
+ )
13
+ from django.core.management.base import BaseCommand, CommandParser
6
14
 
7
- from .... import PKG_DISPLAY_NAME, PKG_NAME, PROJECT_API_DIR, PROJECT_DIR, Conf
15
+ from ... import PKG_DISPLAY_NAME, PKG_NAME, PROJECT_API_DIR, PROJECT_DIR, Conf
8
16
 
9
17
 
10
- class ServerFileGenerator(FileGenerator):
18
+ class _ServerFileGenerator(FileGenerator):
11
19
  f"""
12
20
  Generator for ASGI / WSGI configuration in api/server.py file.
13
21
 
@@ -27,7 +35,7 @@ class ServerFileGenerator(FileGenerator):
27
35
  return f"from {PKG_NAME}.api.backends.server import application\n\napp = application\n"
28
36
 
29
37
 
30
- class VercelFileGenerator(FileGenerator):
38
+ class _VercelFileGenerator(FileGenerator):
31
39
  """
32
40
  Generator for Vercel configuration file (vercel.json).
33
41
 
@@ -59,7 +67,7 @@ class VercelFileGenerator(FileGenerator):
59
67
  return "\n".join(lines) + "\n"
60
68
 
61
69
 
62
- class EnvFileGenerator(FileGenerator):
70
+ class _EnvFileGenerator(FileGenerator):
63
71
  """
64
72
  Generator for environment configuration file (.env.example).
65
73
 
@@ -214,4 +222,49 @@ class EnvFileGenerator(FileGenerator):
214
222
  return str(value)
215
223
 
216
224
 
217
- __all__: list[str] = ["EnvFileGenerator", "ServerFileGenerator", "VercelFileGenerator"]
225
+ class FileOption(StrEnum):
226
+ PG_SERVICE = FileGeneratorOption.PG_SERVICE.value
227
+ PGPASS = FileGeneratorOption.PGPASS.value
228
+ SSH_CONFIG = FileGeneratorOption.SSH_CONFIG.value
229
+ ENV = "env"
230
+ SERVER = "server"
231
+ VERCEL = "vercel"
232
+
233
+
234
+ class Command(BaseCommand):
235
+ help: str = "Generate configuration files (e.g., .env.example, vercel.json, asgi.py, wsgi.py, .pg_service.conf, pgpass.conf / .pgpass, ssh config)."
236
+
237
+ def add_arguments(self, parser: CommandParser) -> None:
238
+ parser.add_argument(
239
+ "-f",
240
+ "--file",
241
+ dest="file",
242
+ choices=[opt.value for opt in FileOption],
243
+ type=FileOption,
244
+ required=True,
245
+ help=f"Specify which file to generate (options: {', '.join(o.value for o in FileOption)}).",
246
+ )
247
+ parser.add_argument(
248
+ "-y",
249
+ "--force",
250
+ dest="force",
251
+ action="store_true",
252
+ help="Force overwrite without confirmation.",
253
+ )
254
+
255
+ def handle(self, *args: Any, **options: Any) -> None:
256
+ file_option: FileOption = FileOption(options["file"])
257
+ force: bool = options["force"]
258
+
259
+ generators: dict[FileOption, type[FileGenerator]] = {
260
+ FileOption.VERCEL: _VercelFileGenerator,
261
+ FileOption.SERVER: _ServerFileGenerator,
262
+ FileOption.PG_SERVICE: PgServiceFileGenerator,
263
+ FileOption.PGPASS: PgPassFileGenerator,
264
+ FileOption.SSH_CONFIG: SSHConfigFileGenerator,
265
+ FileOption.ENV: _EnvFileGenerator,
266
+ }
267
+
268
+ generator_class: type[FileGenerator] = generators[file_option]
269
+ generator = generator_class()
270
+ generator.create(force=force)
@@ -1,8 +1,12 @@
1
1
  from dataclasses import dataclass
2
2
  from enum import StrEnum
3
3
  from pathlib import Path
4
+ from typing import Final
4
5
 
5
6
  from christianwhocodes.core import ExitCode, Version
7
+ from rich.console import Console
8
+ from rich.progress import Progress, SpinnerColumn, TextColumn
9
+ from rich.prompt import Confirm, Prompt
6
10
 
7
11
  from ... import PKG_DISPLAY_NAME, PKG_NAME, PROJECT_DIR, PROJECT_INIT_NAME
8
12
 
@@ -12,6 +16,18 @@ __all__ = ["initialize"]
12
16
  # Configuration & Templates
13
17
  # ============================================================================
14
18
 
19
+ # VCS and common items that shouldn't prevent initialization
20
+ SAFE_DIRECTORY_ITEMS: Final[set[str]] = {
21
+ ".git",
22
+ ".gitignore",
23
+ ".gitattributes",
24
+ ".hg",
25
+ ".hgignore",
26
+ "LICENSE",
27
+ "LICENSE.txt",
28
+ "LICENSE.md",
29
+ }
30
+
15
31
 
16
32
  class PresetType(StrEnum):
17
33
  """Available project presets."""
@@ -94,7 +110,7 @@ A new project built with {PKG_DISPLAY_NAME}.
94
110
  ## Getting Started
95
111
 
96
112
  1. Install dependencies: `uv sync`
97
- 2. Run development server: `uv run djx runserver`
113
+ 2. Run development server: `djx runserver`
98
114
  """.strip()
99
115
 
100
116
  @staticmethod
@@ -168,6 +184,32 @@ class HomeView(TemplateView):
168
184
  template_name = "home/index.html"
169
185
  """
170
186
 
187
+ @staticmethod
188
+ def home_index_html() -> str:
189
+ """Generate home app index.html content."""
190
+ return """{% extends "ui/index.html" %}
191
+
192
+ {% load org %}
193
+
194
+ {% block title %}
195
+ <title>Welcome - {% org "name" %} App</title>
196
+ {% endblock title %}
197
+
198
+ {% block fonts %}
199
+ <link href="https://fonts.googleapis.com" rel="preconnect" />
200
+ <link href="https://fonts.gstatic.com" rel="preconnect" crossorigin />
201
+ <link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&family=Raleway:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Mulish:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
202
+ rel="stylesheet" />
203
+ {% endblock fonts %}
204
+
205
+ {% block main %}
206
+ <main>
207
+ <section class="container-full py-8">
208
+ </section>
209
+ </main>
210
+ {% endblock main %}
211
+ """
212
+
171
213
  @staticmethod
172
214
  def tailwind_css() -> str:
173
215
  """Generate Tailwind CSS content."""
@@ -289,79 +331,161 @@ class HomeView(TemplateView):
289
331
 
290
332
 
291
333
  # ============================================================================
292
- # File Generators
334
+ # File Management
293
335
  # ============================================================================
294
336
 
295
337
 
338
+ class _FileTracker:
339
+ """Tracks files and directories created during initialization for rollback."""
340
+
341
+ def __init__(self):
342
+ """Initialize the file tracker."""
343
+ self._created_paths: list[Path] = []
344
+
345
+ def track(self, path: Path) -> None:
346
+ """Track a created file or directory.
347
+
348
+ Args:
349
+ path: The path that was created.
350
+ """
351
+ if path not in self._created_paths:
352
+ self._created_paths.append(path)
353
+
354
+ def cleanup_all(self) -> None:
355
+ """Remove all tracked files and directories in reverse order of creation."""
356
+ from shutil import rmtree
357
+
358
+ # Reverse order to remove files before their parent directories
359
+ for path in reversed(self._created_paths):
360
+ try:
361
+ if path.exists():
362
+ if path.is_dir():
363
+ rmtree(path)
364
+ else:
365
+ path.unlink()
366
+ except Exception:
367
+ # Best effort cleanup - don't raise on cleanup failures
368
+ pass
369
+
370
+ self._created_paths.clear()
371
+
372
+
296
373
  class _ProjectFileWriter:
297
374
  """Handles file writing operations for project initialization."""
298
375
 
299
- def __init__(self, project_dir: Path):
376
+ def __init__(self, project_dir: Path, console: Console, tracker: _FileTracker):
300
377
  """Initialize the file writer.
301
378
 
302
379
  Args:
303
380
  project_dir: The project directory path.
381
+ console: Rich console for output.
382
+ tracker: File tracker for rollback support.
304
383
  """
305
384
  self.project_dir = project_dir
385
+ self.console = console
386
+ self.tracker = tracker
306
387
 
307
- def write(self, filename: str, content: str) -> None:
308
- """Write content to a file.
388
+ def write_if_not_exists(self, filename: str, content: str) -> bool:
389
+ """Write content to a file only if it doesn't exist.
309
390
 
310
391
  Args:
311
392
  filename: Name of the file to create.
312
393
  content: Content to write.
313
394
 
395
+ Returns:
396
+ True if file was created, False if it already existed.
397
+
314
398
  Raises:
315
399
  IOError: If file cannot be written.
316
400
  PermissionError: If lacking permission to write.
317
401
  """
318
402
  file_path = self.project_dir / filename
403
+
404
+ if file_path.exists():
405
+ return False
406
+
319
407
  file_path.write_text(content.strip(), encoding="utf-8")
408
+ self.tracker.track(file_path)
409
+ return True
320
410
 
321
- def write_to_path(self, path: Path, content: str) -> None:
322
- """Write content to a specific path.
411
+ def write_to_path_if_not_exists(self, path: Path, content: str) -> bool:
412
+ """Write content to a specific path only if it doesn't exist.
323
413
 
324
414
  Args:
325
415
  path: Full path to the file.
326
416
  content: Content to write.
327
417
 
418
+ Returns:
419
+ True if file was created, False if it already existed.
420
+
328
421
  Raises:
329
422
  IOError: If file cannot be written.
330
423
  PermissionError: If lacking permission to write.
331
424
  """
425
+ if path.exists():
426
+ return False
427
+
332
428
  path.write_text(content.strip(), encoding="utf-8")
429
+ self.tracker.track(path)
430
+ return True
333
431
 
334
- def ensure_dir(self, path: Path) -> None:
432
+ def ensure_dir(self, path: Path) -> bool:
335
433
  """Ensure a directory exists.
336
434
 
337
435
  Args:
338
436
  path: Directory path to create.
437
+
438
+ Returns:
439
+ True if directory was created, False if it already existed.
339
440
  """
441
+ if path.exists():
442
+ return False
443
+
340
444
  path.mkdir(parents=True, exist_ok=True)
445
+ self.tracker.track(path)
446
+ return True
447
+
448
+
449
+ # ============================================================================
450
+ # Home App Creator
451
+ # ============================================================================
341
452
 
342
453
 
343
454
  class _HomeAppCreator:
344
455
  """Creates the default 'home' Django application."""
345
456
 
346
- def __init__(self, project_dir: Path, writer: _ProjectFileWriter, templates: _TemplateManager):
457
+ def __init__(
458
+ self,
459
+ project_dir: Path,
460
+ writer: _ProjectFileWriter,
461
+ templates: _TemplateManager,
462
+ console: Console,
463
+ ):
347
464
  """Initialize the home app creator.
348
465
 
349
466
  Args:
350
467
  project_dir: The project directory path.
351
468
  writer: File writer instance.
352
469
  templates: Template manager instance.
470
+ console: Rich console for output.
353
471
  """
354
472
  self.project_dir = project_dir
355
473
  self.writer = writer
356
474
  self.templates = templates
475
+ self.console = console
357
476
  self.home_dir = project_dir / "home"
358
477
 
359
478
  def create(self) -> None:
360
479
  """Create the home app with all necessary files and directories."""
361
- from django.core.management import call_command
480
+ # Check if home app already exists
481
+ if self.home_dir.exists():
482
+ self.console.print(
483
+ "[yellow]Home app directory already exists, skipping app creation[/yellow]"
484
+ )
485
+ return
362
486
 
363
487
  # Create the Django app structure
364
- call_command("startapp", "home")
488
+ self._create_app_structure()
365
489
 
366
490
  # Create app files
367
491
  self._create_urls()
@@ -369,15 +493,36 @@ class _HomeAppCreator:
369
493
  self._create_templates()
370
494
  self._create_static_files()
371
495
 
496
+ def _create_app_structure(self) -> None:
497
+ """Create the basic Django app structure using startapp command."""
498
+ from django.core.management import call_command
499
+
500
+ try:
501
+ call_command("startapp", "home")
502
+ # Track the created directory
503
+ self.writer.tracker.track(self.home_dir)
504
+ except Exception as e:
505
+ raise IOError(f"Failed to create home app: {e}") from e
506
+
372
507
  def _create_urls(self) -> None:
373
508
  """Create urls.py for the home app."""
374
509
  urls_path = self.home_dir / "urls.py"
375
- self.writer.write_to_path(urls_path, self.templates.home_urls())
510
+ if self.writer.write_to_path_if_not_exists(urls_path, self.templates.home_urls()):
511
+ pass # File was created
512
+ else:
513
+ self.console.print("[dim]urls.py already exists, skipping[/dim]")
376
514
 
377
515
  def _create_views(self) -> None:
378
516
  """Create views.py for the home app."""
379
517
  views_path = self.home_dir / "views.py"
380
- self.writer.write_to_path(views_path, self.templates.home_views())
518
+ # Only overwrite if it's the default Django startapp content
519
+ if views_path.exists():
520
+ content = views_path.read_text(encoding="utf-8")
521
+ # Check if it's the default Django views.py (contains only imports or is minimal)
522
+ if len(content.strip()) < 100: # Default Django file is very short
523
+ views_path.write_text(self.templates.home_views().strip(), encoding="utf-8")
524
+ else:
525
+ self.writer.write_to_path_if_not_exists(views_path, self.templates.home_views())
381
526
 
382
527
  def _create_templates(self) -> None:
383
528
  """Create template directory structure and files."""
@@ -385,7 +530,7 @@ class _HomeAppCreator:
385
530
  self.writer.ensure_dir(templates_dir)
386
531
 
387
532
  index_path = templates_dir / "index.html"
388
- self.writer.write_to_path(index_path, "")
533
+ self.writer.write_to_path_if_not_exists(index_path, self.templates.home_index_html())
389
534
 
390
535
  def _create_static_files(self) -> None:
391
536
  """Create static directory structure and CSS files."""
@@ -393,7 +538,7 @@ class _HomeAppCreator:
393
538
  self.writer.ensure_dir(static_dir)
394
539
 
395
540
  css_path = static_dir / "tailwind.css"
396
- self.writer.write_to_path(css_path, self.templates.tailwind_css())
541
+ self.writer.write_to_path_if_not_exists(css_path, self.templates.tailwind_css())
397
542
 
398
543
 
399
544
  # ============================================================================
@@ -415,6 +560,7 @@ class _ProjectInitializer:
415
560
  project_dir: Path,
416
561
  dependencies: _ProjectDependencies | None = None,
417
562
  templates: _TemplateManager | None = None,
563
+ console: Console | None = None,
418
564
  ):
419
565
  """Initialize the project initializer.
420
566
 
@@ -422,22 +568,54 @@ class _ProjectInitializer:
422
568
  project_dir: The directory where the project will be created.
423
569
  dependencies: Dependency manager (uses default if None).
424
570
  templates: Template manager (uses default if None).
571
+ console: Rich console for output (creates new if None).
425
572
  """
426
573
  self.project_dir = Path(project_dir)
427
574
  self.dependencies = dependencies or _ProjectDependencies()
428
575
  self.templates = templates or _TemplateManager()
429
- self.writer = _ProjectFileWriter(self.project_dir)
576
+ self.console = console or Console()
577
+ self.tracker = _FileTracker()
578
+ self.writer = _ProjectFileWriter(self.project_dir, self.console, self.tracker)
430
579
 
431
- def _validate_directory(self) -> None:
580
+ def _validate_directory(self, force: bool = False) -> None:
432
581
  """Validate that the directory is suitable for initialization.
433
582
 
583
+ Args:
584
+ force: Skip validation and proceed regardless.
585
+
434
586
  Raises:
435
- ProjectInitializationError: If directory is not empty.
587
+ ProjectInitializationError: If directory contains non-VCS files and user declines.
436
588
  """
437
- if any(self.project_dir.iterdir()):
438
- raise ProjectInitializationError(
439
- f"Directory is not empty. Please choose an empty directory to start a new {PKG_DISPLAY_NAME} project."
440
- )
589
+ if force:
590
+ return
591
+
592
+ existing_items = list(self.project_dir.iterdir())
593
+ if not existing_items:
594
+ return
595
+
596
+ # Filter out safe items
597
+ problematic_items = [
598
+ item for item in existing_items if item.name not in SAFE_DIRECTORY_ITEMS
599
+ ]
600
+
601
+ if not problematic_items:
602
+ # Only safe items present - proceed without prompting
603
+ return
604
+
605
+ # Show what exists and ask for confirmation
606
+ items_list = "\n - ".join(item.name for item in problematic_items)
607
+
608
+ self.console.print(f"[yellow]Directory is not empty. Found:[/yellow]\n - {items_list}\n")
609
+
610
+ should_proceed = Confirm.ask(
611
+ f"Initialize {PKG_DISPLAY_NAME} project anyway? "
612
+ "This will skip existing files and create new ones",
613
+ default=False,
614
+ console=self.console,
615
+ )
616
+
617
+ if not should_proceed:
618
+ raise ProjectInitializationError("Initialization cancelled by user.")
441
619
 
442
620
  def _get_preset_choice(self, preset: str | None = None) -> PresetType:
443
621
  """Get the user's preset choice or validate the provided one.
@@ -451,8 +629,6 @@ class _ProjectInitializer:
451
629
  Raises:
452
630
  ValueError: If preset is invalid.
453
631
  """
454
- from rich.prompt import Prompt
455
-
456
632
  if preset:
457
633
  try:
458
634
  return PresetType(preset)
@@ -460,12 +636,13 @@ class _ProjectInitializer:
460
636
  valid_presets = [p.value for p in PresetType]
461
637
  raise ValueError(
462
638
  f"Invalid preset '{preset}'. Must be one of: {', '.join(valid_presets)}"
463
- )
639
+ ) from None
464
640
 
465
641
  choice = Prompt.ask(
466
642
  "Choose a preset",
467
643
  choices=[p.value for p in PresetType],
468
644
  default=PresetType.DEFAULT.value,
645
+ console=self.console,
469
646
  )
470
647
  return PresetType(choice)
471
648
 
@@ -480,79 +657,202 @@ class _ProjectInitializer:
480
657
  """
481
658
  dependencies = self.dependencies.get_for_preset(preset)
482
659
 
483
- self.writer.write("pyproject.toml", self.templates.pyproject_toml(preset, dependencies))
484
- self.writer.write(".gitignore", self.templates.gitignore())
485
- self.writer.write("README.md", self.templates.readme())
660
+ # Create files only if they don't exist
661
+ files_to_create = {
662
+ "pyproject.toml": self.templates.pyproject_toml(preset, dependencies),
663
+ ".gitignore": self.templates.gitignore(),
664
+ "README.md": self.templates.readme(),
665
+ }
666
+
667
+ for filename, content in files_to_create.items():
668
+ if self.writer.write_if_not_exists(filename, content):
669
+ pass # File was created
670
+ else:
671
+ self.console.print(f"[dim]{filename} already exists, skipping[/dim]")
486
672
 
487
- def _configure_preset_files(self, preset: PresetType) -> None:
673
+ def _configure_preset_files_and_env_example(self, preset: PresetType) -> None:
488
674
  """Configure files based on the chosen preset.
489
675
 
490
676
  Args:
491
677
  preset: The preset configuration to apply.
678
+
679
+ Raises:
680
+ IOError: If preset-specific file generation fails.
492
681
  """
493
- if preset == PresetType.VERCEL:
494
- # Import here to avoid circular dependencies
495
- from .generators import ServerFileGenerator, VercelFileGenerator
682
+ from subprocess import CalledProcessError, run
496
683
 
497
- VercelFileGenerator().create()
498
- ServerFileGenerator().create()
684
+ try:
685
+ match preset:
686
+ case PresetType.VERCEL:
687
+ # Only generate vercel.json if it doesn't exist
688
+ if not (self.project_dir / "vercel.json").exists():
689
+ run(
690
+ [PKG_NAME, "generate", "-f", "vercel", "-y"],
691
+ cwd=self.project_dir,
692
+ check=True,
693
+ capture_output=True,
694
+ )
695
+ # Track the created file
696
+ self.tracker.track(self.project_dir / "vercel.json")
697
+ else:
698
+ self.console.print("[dim]vercel.json already exists, skipping[/dim]")
699
+
700
+ # Only generate api/server.py if it doesn't exist
701
+ server_path = self.project_dir / "api" / "server.py"
702
+ if not server_path.exists():
703
+ run(
704
+ [PKG_NAME, "generate", "-f", "server", "-y"],
705
+ cwd=self.project_dir,
706
+ check=True,
707
+ capture_output=True,
708
+ )
709
+ # Track the created files
710
+ self.tracker.track(self.project_dir / "api")
711
+ else:
712
+ self.console.print("[dim]api/server.py already exists, skipping[/dim]")
713
+ case _:
714
+ # Future presets will be added here
715
+ pass
716
+
717
+ except CalledProcessError as e:
718
+ error_msg = e.stderr.decode() if e.stderr else str(e)
719
+ raise IOError(f"Failed to generate preset-specific files: {error_msg}") from e
720
+ except FileNotFoundError as e:
721
+ raise IOError(
722
+ f"Command '{PKG_NAME}' not found. Ensure {PKG_DISPLAY_NAME} is properly installed."
723
+ ) from e
724
+
725
+ # Only generate .env.example file if it doesn't exist
726
+ env_example_path = self.project_dir / ".env.example"
727
+ if not env_example_path.exists():
728
+ try:
729
+ run(
730
+ [PKG_NAME, "generate", "-f", "env", "-y"],
731
+ cwd=self.project_dir,
732
+ check=True,
733
+ capture_output=True,
734
+ )
735
+ # Track the created file
736
+ self.tracker.track(env_example_path)
737
+ except CalledProcessError as e:
738
+ error_msg = e.stderr.decode() if e.stderr else str(e)
739
+ raise IOError(f"Failed to generate .env file: {error_msg}") from e
740
+ except FileNotFoundError as e:
741
+ raise IOError(
742
+ f"Command '{PKG_NAME}' not found. Ensure {PKG_DISPLAY_NAME} is properly installed."
743
+ ) from e
744
+ else:
745
+ self.console.print("[dim].env.example already exists, skipping[/dim]")
499
746
 
500
747
  def _create_home_app(self) -> None:
501
748
  """Create the default home application."""
502
- home_creator = _HomeAppCreator(self.project_dir, self.writer, self.templates)
749
+ home_creator = _HomeAppCreator(self.project_dir, self.writer, self.templates, self.console)
503
750
  home_creator.create()
504
751
 
505
- def create(self, preset: str | None = None) -> ExitCode:
752
+ def _show_next_steps(self, preset: PresetType) -> None:
753
+ """Display next steps for the user after successful initialization.
754
+
755
+ Args:
756
+ preset: The preset that was used.
757
+ """
758
+ from rich.panel import Panel
759
+
760
+ next_steps = [
761
+ "1. Install dependencies: [bold cyan]uv sync[/bold cyan]",
762
+ "2. Copy [bold].env.example[/bold] to [bold].env[/bold] and configure",
763
+ ]
764
+
765
+ if preset == PresetType.VERCEL:
766
+ next_steps.append(
767
+ "3. Configure Vercel blob token in [bold]pyproject.toml[/bold] or [bold].env[/bold]"
768
+ )
769
+ next_steps.append("4. Run development server: [bold cyan]djx runserver[/bold cyan]")
770
+ else:
771
+ next_steps.append("3. Run development server: [bold cyan]djx runserver[/bold cyan]")
772
+
773
+ panel = Panel(
774
+ "\n".join(next_steps),
775
+ title=f"[bold green]✓ {PKG_DISPLAY_NAME} project initialized successfully![/bold green]",
776
+ border_style="green",
777
+ padding=(1, 2),
778
+ )
779
+
780
+ self.console.print("\n")
781
+ self.console.print(panel)
782
+
783
+ def create(self, preset: str | None = None, force: bool = False) -> ExitCode:
506
784
  """Execute the full project initialization workflow.
507
785
 
508
786
  Args:
509
787
  preset: Optional preset to use without prompting.
788
+ force: Skip directory validation.
510
789
 
511
790
  Returns:
512
791
  ExitCode indicating success or failure.
513
792
  """
514
- from christianwhocodes.io import Text, print
515
-
516
793
  try:
517
- # Validate directory is empty
518
- self._validate_directory()
794
+ # Validate directory is empty or acceptable
795
+ self._validate_directory(force=force)
519
796
 
520
797
  # Get and validate preset choice
521
798
  chosen_preset = self._get_preset_choice(preset)
522
799
 
523
- # Create core configuration files
524
- self._create_core_files(chosen_preset)
525
-
526
- # Configure preset-specific files
527
- self._configure_preset_files(chosen_preset)
528
-
529
- # Create default app
530
- self._create_home_app()
531
-
532
- print(
533
- f"✓ {PKG_DISPLAY_NAME} project initialized successfully!",
534
- Text.SUCCESS,
535
- )
800
+ with Progress(
801
+ SpinnerColumn(),
802
+ TextColumn("[progress.description]{task.description}"),
803
+ console=self.console,
804
+ transient=True,
805
+ ) as progress:
806
+ # Create core configuration files
807
+ task = progress.add_task("Creating project files...", total=None)
808
+ self._create_core_files(chosen_preset)
809
+ progress.update(task, completed=True)
810
+
811
+ # Configure preset-specific files
812
+ task = progress.add_task("Configuring preset files...", total=None)
813
+ self._configure_preset_files_and_env_example(chosen_preset)
814
+ progress.update(task, completed=True)
815
+
816
+ # Create default app
817
+ task = progress.add_task("Creating home app...", total=None)
818
+ self._create_home_app()
819
+ progress.update(task, completed=True)
820
+
821
+ self._show_next_steps(chosen_preset)
536
822
  return ExitCode.SUCCESS
537
823
 
538
824
  except KeyboardInterrupt:
539
- print("\nProject initialization cancelled.", Text.WARNING)
825
+ self.tracker.cleanup_all()
826
+ self.console.print(
827
+ "\n[yellow]Project initialization cancelled. Cleaned up partial files.[/yellow]"
828
+ )
540
829
  return ExitCode.ERROR
541
830
 
542
831
  except ProjectInitializationError as e:
543
- print(str(e), Text.WARNING)
832
+ # User declined to proceed - no cleanup needed since nothing was created
833
+ self.console.print(f"[yellow]{e}[/yellow]")
544
834
  return ExitCode.ERROR
545
835
 
546
836
  except ValueError as e:
547
- print(f"Configuration error: {e}", Text.ERROR)
837
+ self.tracker.cleanup_all()
838
+ self.console.print(
839
+ f"[red]Configuration error:[/red] {e}\n[yellow]Cleaned up partial files.[/yellow]"
840
+ )
548
841
  return ExitCode.ERROR
549
842
 
550
843
  except (IOError, PermissionError) as e:
551
- print(f"File system error: {e}", Text.ERROR)
844
+ self.tracker.cleanup_all()
845
+ self.console.print(
846
+ f"[red]File system error:[/red] {e}\n[yellow]Cleaned up partial files.[/yellow]"
847
+ )
552
848
  return ExitCode.ERROR
553
849
 
554
850
  except Exception as e:
555
- print(f"Unexpected error during initialization: {e}", Text.ERROR)
851
+ self.tracker.cleanup_all()
852
+ self.console.print(
853
+ f"[red]Unexpected error during initialization:[/red] {e}\n"
854
+ f"[yellow]Cleaned up partial files.[/yellow]"
855
+ )
556
856
  return ExitCode.ERROR
557
857
 
558
858
 
@@ -561,13 +861,22 @@ class _ProjectInitializer:
561
861
  # ============================================================================
562
862
 
563
863
 
564
- def initialize(preset: str | None = None) -> ExitCode:
864
+ def initialize(preset: str | None = None, force: bool = False) -> ExitCode:
565
865
  """Main entry point for project initialization.
566
866
 
867
+ Creates a new project with the specified preset configuration.
868
+
567
869
  Args:
568
870
  preset: Optional preset to use without prompting.
871
+ Available presets: 'default', 'vercel'.
872
+ force: Skip directory validation and proceed even if directory is not empty.
569
873
 
570
874
  Returns:
571
- ExitCode indicating success or failure.
875
+ ExitCode.SUCCESS if initialization completed successfully,
876
+ ExitCode.ERROR otherwise.
877
+
878
+ Example:
879
+ >>> initialize(preset="vercel")
880
+ >>> initialize(force=True)
572
881
  """
573
- return _ProjectInitializer(PROJECT_DIR).create(preset=preset)
882
+ return _ProjectInitializer(PROJECT_DIR).create(preset=preset, force=force)
@@ -4,6 +4,7 @@
4
4
  from enum import StrEnum
5
5
 
6
6
  from ... import (
7
+ INCLUDE_PROJECT_MAIN_APP,
7
8
  PKG_API_NAME,
8
9
  PKG_MANAGEMENT_NAME,
9
10
  PKG_NAME,
@@ -62,7 +63,7 @@ def _get_installed_apps() -> list[str]:
62
63
  """
63
64
 
64
65
  base_apps: list[str] = [
65
- PROJECT_MAIN_APP_NAME,
66
+ *([PROJECT_MAIN_APP_NAME] if INCLUDE_PROJECT_MAIN_APP else []),
66
67
  PKG_NAME,
67
68
  f"{PKG_NAME}.{PKG_API_NAME}",
68
69
  f"{PKG_NAME}.{PKG_UI_NAME}",
@@ -11,5 +11,9 @@ urlpatterns: list[URLPattern | URLResolver] = [
11
11
  ),
12
12
  path("api/", include(f"{PKG_NAME}.api.urls")),
13
13
  path("ui/", include(f"{PKG_NAME}.ui.urls")),
14
- path("", include(f"{PROJECT_MAIN_APP_NAME}.urls")),
14
+ *(
15
+ [path("", include(f"{PROJECT_MAIN_APP_NAME}.urls"))]
16
+ if PROJECT_MAIN_APP_NAME in INSTALLED_APPS
17
+ else []
18
+ ),
15
19
  ]
@@ -1,61 +0,0 @@
1
- from enum import StrEnum
2
- from typing import Any
3
-
4
- from christianwhocodes.generators import (
5
- FileGenerator,
6
- FileGeneratorOption,
7
- PgPassFileGenerator,
8
- PgServiceFileGenerator,
9
- SSHConfigFileGenerator,
10
- )
11
- from django.core.management.base import BaseCommand, CommandParser
12
-
13
- from .generators import EnvFileGenerator, ServerFileGenerator, VercelFileGenerator
14
-
15
-
16
- class FileOption(StrEnum):
17
- PG_SERVICE = FileGeneratorOption.PG_SERVICE.value
18
- PGPASS = FileGeneratorOption.PGPASS.value
19
- SSH_CONFIG = FileGeneratorOption.SSH_CONFIG.value
20
- ENV = "env"
21
- SERVER = "server"
22
- VERCEL = "vercel"
23
-
24
-
25
- class Command(BaseCommand):
26
- help: str = "Generate configuration files (e.g., .env.example, vercel.json, asgi.py, wsgi.py, .pg_service.conf, pgpass.conf / .pgpass, ssh config)."
27
-
28
- def add_arguments(self, parser: CommandParser) -> None:
29
- parser.add_argument(
30
- "-f",
31
- "--file",
32
- dest="file",
33
- choices=[opt.value for opt in FileOption],
34
- type=FileOption,
35
- required=True,
36
- help=f"Specify which file to generate (options: {', '.join(o.value for o in FileOption)}).",
37
- )
38
- parser.add_argument(
39
- "-y",
40
- "--force",
41
- dest="force",
42
- action="store_true",
43
- help="Force overwrite without confirmation.",
44
- )
45
-
46
- def handle(self, *args: Any, **options: Any) -> None:
47
- file_option: FileOption = FileOption(options["file"])
48
- force: bool = options["force"]
49
-
50
- generators: dict[FileOption, type[FileGenerator]] = {
51
- FileOption.VERCEL: VercelFileGenerator,
52
- FileOption.SERVER: ServerFileGenerator,
53
- FileOption.PG_SERVICE: PgServiceFileGenerator,
54
- FileOption.PGPASS: PgPassFileGenerator,
55
- FileOption.SSH_CONFIG: SSHConfigFileGenerator,
56
- FileOption.ENV: EnvFileGenerator,
57
- }
58
-
59
- generator_class: type[FileGenerator] = generators[file_option]
60
- generator = generator_class()
61
- generator.create(force=force)
@@ -1 +0,0 @@
1
- from .file import * # noqa: F403
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes