djangx 1.5.5__py3-none-any.whl → 1.5.7__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.
@@ -1,10 +1,14 @@
1
1
  from dataclasses import dataclass
2
- from enum import StrEnum
3
2
  from pathlib import Path
3
+ from typing import Final
4
4
 
5
5
  from christianwhocodes.core import ExitCode, Version
6
+ from rich.console import Console
7
+ from rich.progress import Progress, SpinnerColumn, TextColumn
8
+ from rich.prompt import Confirm, Prompt
6
9
 
7
10
  from ... import PKG_DISPLAY_NAME, PKG_NAME, PROJECT_DIR, PROJECT_INIT_NAME
11
+ from ...enums import DatabaseBackend, PresetType
8
12
 
9
13
  __all__ = ["initialize"]
10
14
 
@@ -12,30 +16,34 @@ __all__ = ["initialize"]
12
16
  # Configuration & Templates
13
17
  # ============================================================================
14
18
 
15
-
16
- class PresetType(StrEnum):
17
- """Available project presets."""
18
-
19
- DEFAULT = "default"
20
- VERCEL = "vercel"
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
+ }
21
30
 
22
31
 
23
32
  @dataclass(frozen=True)
24
33
  class _ProjectDependencies:
25
34
  """Manages project dependencies."""
26
35
 
27
- base: tuple[str, ...] = (
28
- "pillow>=12.1.0",
29
- "psycopg[binary,pool]>=3.3.2",
30
- )
36
+ base: tuple[str, ...] = ("pillow>=12.1.0",)
31
37
  dev: tuple[str, ...] = ("djlint>=1.36.4", "ruff>=0.15.0")
38
+ postgresql: tuple[str, ...] = ("psycopg[binary,pool]>=3.3.2",)
32
39
  vercel: tuple[str, ...] = ("vercel>=0.3.8",)
33
40
 
34
- def get_for_preset(self, preset: PresetType) -> list[str]:
35
- """Get dependencies for a specific preset.
41
+ def get_for_config(self, preset: PresetType, database: DatabaseBackend) -> list[str]:
42
+ """Get dependencies for a specific configuration.
36
43
 
37
44
  Args:
38
45
  preset: The preset type.
46
+ database: The database backend.
39
47
 
40
48
  Returns:
41
49
  List of dependency strings including base dependencies.
@@ -43,6 +51,11 @@ class _ProjectDependencies:
43
51
  deps = list(self.base)
44
52
  deps.append(f"{PKG_NAME}>={Version.get(PKG_NAME)[0]}")
45
53
 
54
+ # Add database-specific dependencies
55
+ if database == DatabaseBackend.POSTGRESQL:
56
+ deps.extend(self.postgresql)
57
+
58
+ # Add preset-specific dependencies
46
59
  if preset == PresetType.VERCEL:
47
60
  deps.extend(self.vercel)
48
61
 
@@ -55,7 +68,7 @@ class _TemplateManager:
55
68
  @staticmethod
56
69
  def gitignore() -> str:
57
70
  """Generate .gitignore content."""
58
- return """
71
+ return f"""
59
72
  # Python-generated files
60
73
  __pycache__/
61
74
  *.py[oc]
@@ -79,8 +92,8 @@ wheels/
79
92
  # Environment variables files
80
93
  /.env*
81
94
 
82
- # SQLite database file
83
- /db.sqlite3
95
+ # {DatabaseBackend.SQLITE3.value.capitalize()} database file
96
+ /db.{DatabaseBackend.SQLITE3.value}
84
97
  """.strip()
85
98
 
86
99
  @staticmethod
@@ -98,12 +111,19 @@ A new project built with {PKG_DISPLAY_NAME}.
98
111
  """.strip()
99
112
 
100
113
  @staticmethod
101
- def pyproject_toml(preset: PresetType, dependencies: list[str]) -> str:
114
+ def pyproject_toml(
115
+ preset: PresetType,
116
+ database: DatabaseBackend,
117
+ dependencies: list[str],
118
+ use_postgres_env_vars: bool = False,
119
+ ) -> str:
102
120
  """Generate pyproject.toml content.
103
121
 
104
122
  Args:
105
123
  preset: The preset type.
124
+ database: The database backend.
106
125
  dependencies: List of project dependencies.
126
+ use_postgres_env_vars: Whether to use env vars for PostgreSQL config.
107
127
 
108
128
  Returns:
109
129
  Formatted pyproject.toml content.
@@ -112,15 +132,25 @@ A new project built with {PKG_DISPLAY_NAME}.
112
132
  deps_formatted = ",\n ".join(f'"{dep}"' for dep in dependencies)
113
133
  dev_deps_formatted = ",\n ".join(f'"{dep}"' for dep in deps.dev)
114
134
 
115
- # Preset-specific tool configuration
135
+ # Build tool configuration based on preset and database
136
+ tool_config_parts: list[str] = []
137
+
138
+ # Database configuration
139
+ if database == DatabaseBackend.POSTGRESQL:
140
+ tool_config_parts.append(
141
+ f'db = {{ backend = "{DatabaseBackend.POSTGRESQL.value}", use-vars = {str(use_postgres_env_vars).lower()} }}'
142
+ )
143
+
144
+ # Storage configuration (Vercel-specific)
145
+ if preset == PresetType.VERCEL:
146
+ tool_config_parts.append(
147
+ 'storage = { backend = "vercel", blob-token = "keep-your-vercel-blob-token-secret-in-env" }'
148
+ )
149
+
150
+ # Format the tool config section
116
151
  tool_config = ""
117
- match preset:
118
- case PresetType.VERCEL:
119
- tool_config = (
120
- '\nstorage = { backend = "vercel", blob-token = "your-vercel-blob-token" }\n'
121
- )
122
- case _:
123
- pass
152
+ if tool_config_parts:
153
+ tool_config = "\n" + "\n".join(tool_config_parts) + "\n"
124
154
 
125
155
  return f"""[project]
126
156
  name = "{PROJECT_INIT_NAME}"
@@ -168,21 +198,47 @@ class HomeView(TemplateView):
168
198
  template_name = "home/index.html"
169
199
  """
170
200
 
201
+ @staticmethod
202
+ def home_index_html() -> str:
203
+ """Generate home app index.html content."""
204
+ return """{% extends "ui/index.html" %}
205
+
206
+ {% load org %}
207
+
208
+ {% block title %}
209
+ <title>Welcome - {% org "name" %} App</title>
210
+ {% endblock title %}
211
+
212
+ {% block fonts %}
213
+ <link href="https://fonts.googleapis.com" rel="preconnect" />
214
+ <link href="https://fonts.gstatic.com" rel="preconnect" crossorigin />
215
+ <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"
216
+ rel="stylesheet" />
217
+ {% endblock fonts %}
218
+
219
+ {% block main %}
220
+ <main>
221
+ <section class="container-full py-8">
222
+ </section>
223
+ </main>
224
+ {% endblock main %}
225
+ """
226
+
171
227
  @staticmethod
172
228
  def tailwind_css() -> str:
173
229
  """Generate Tailwind CSS content."""
174
- return """@import "tailwindcss";
230
+ return f"""@import "tailwindcss";
175
231
 
176
232
  /* =============================================================================
177
233
  SOURCE FILES
178
234
  ============================================================================= */
179
- @source "../../../../.venv/**/djangx/ui/templates/ui/**/*.html";
235
+ @source "../../../../.venv/**/{PKG_NAME}/ui/templates/ui/**/*.html";
180
236
  @source "../../../templates/home/**/*.html";
181
237
 
182
238
  /* =============================================================================
183
239
  THEME CONFIGURATION
184
240
  ============================================================================= */
185
- @theme {
241
+ @theme {{
186
242
  /* ---------------------------------------------------------------------------
187
243
  TYPOGRAPHY
188
244
  --------------------------------------------------------------------------- */
@@ -221,147 +277,229 @@ class HomeView(TemplateView):
221
277
  --color-nav-dropdown-bg: #2e2e2e; /* Dropdown background */
222
278
  --color-nav-dropdown: #d9d9d9; /* Dropdown text color */
223
279
  --color-nav-dropdown-hover: #ff4d4f; /* Dropdown hover state */
224
- }
280
+ }}
225
281
 
226
282
  /* =============================================================================
227
283
  LIGHT THEME OVERRIDES
228
284
  ============================================================================= */
229
- @theme light {
285
+ @theme light {{
230
286
  --color-background: rgba(41, 41, 41, 0.8);
231
287
  --color-surface: #484848;
232
- }
288
+ }}
233
289
 
234
290
  /* =============================================================================
235
291
  DARK THEME OVERRIDES
236
292
  ============================================================================= */
237
- @theme dark {
293
+ @theme dark {{
238
294
  --color-background: #060606;
239
295
  --color-surface: #252525;
240
296
  --color-default: #ffffff;
241
297
  --color-heading: #ffffff;
242
- }
298
+ }}
243
299
 
244
300
  /* =============================================================================
245
301
  UTILITY CLASSES
246
302
  ============================================================================= */
247
- @layer utilities {
303
+ @layer utilities {{
248
304
  /* Full-width container */
249
- .container-full {
305
+ .container-full {{
250
306
  @apply mx-auto w-full px-8;
251
- }
307
+ }}
252
308
 
253
309
  /* Responsive container (Mobile→SM→MD→LG→XL→2XL: 100%→92%→83%→80%→75%→1400px max) */
254
- .container {
310
+ .container {{
255
311
  @apply mx-auto w-full px-8 sm:w-11/12 sm:px-4 md:w-5/6 lg:w-4/5 xl:w-3/4 xl:px-0 2xl:max-w-[1400px];
256
- }
257
- }
312
+ }}
313
+ }}
258
314
 
259
315
  /* =============================================================================
260
316
  BASE STYLES - Global element styling
261
317
  ============================================================================= */
262
- @layer base {
263
- :root {
318
+ @layer base {{
319
+ :root {{
264
320
  @apply scroll-smooth;
265
- }
321
+ }}
266
322
 
267
- body {
323
+ body {{
268
324
  @apply bg-background text-default font-default antialiased;
269
- }
325
+ }}
270
326
 
271
327
  h1,
272
328
  h2,
273
329
  h3,
274
330
  h4,
275
331
  h5,
276
- h6 {
332
+ h6 {{
277
333
  @apply text-heading font-heading text-balance;
278
- }
334
+ }}
279
335
 
280
- a {
336
+ a {{
281
337
  @apply text-accent no-underline transition-colors duration-200 ease-in-out;
282
- }
338
+ }}
283
339
 
284
- a:hover {
340
+ a:hover {{
285
341
  color: color-mix(in srgb, var(--color-accent), white 15%);
286
- }
287
- }
342
+ }}
343
+ }}
288
344
  """
289
345
 
290
346
 
291
347
  # ============================================================================
292
- # File Generators
348
+ # File Management
293
349
  # ============================================================================
294
350
 
295
351
 
352
+ class _FileTracker:
353
+ """Tracks files and directories created during initialization for rollback."""
354
+
355
+ def __init__(self):
356
+ """Initialize the file tracker."""
357
+ self._created_paths: list[Path] = []
358
+
359
+ def track(self, path: Path) -> None:
360
+ """Track a created file or directory.
361
+
362
+ Args:
363
+ path: The path that was created.
364
+ """
365
+ if path not in self._created_paths:
366
+ self._created_paths.append(path)
367
+
368
+ def cleanup_all(self) -> None:
369
+ """Remove all tracked files and directories in reverse order of creation."""
370
+ from shutil import rmtree
371
+
372
+ # Reverse order to remove files before their parent directories
373
+ for path in reversed(self._created_paths):
374
+ try:
375
+ if path.exists():
376
+ if path.is_dir():
377
+ rmtree(path)
378
+ else:
379
+ path.unlink()
380
+ except Exception:
381
+ # Best effort cleanup - don't raise on cleanup failures
382
+ pass
383
+
384
+ self._created_paths.clear()
385
+
386
+
296
387
  class _ProjectFileWriter:
297
388
  """Handles file writing operations for project initialization."""
298
389
 
299
- def __init__(self, project_dir: Path):
390
+ def __init__(self, project_dir: Path, console: Console, tracker: _FileTracker):
300
391
  """Initialize the file writer.
301
392
 
302
393
  Args:
303
394
  project_dir: The project directory path.
395
+ console: Rich console for output.
396
+ tracker: File tracker for rollback support.
304
397
  """
305
398
  self.project_dir = project_dir
399
+ self.console = console
400
+ self.tracker = tracker
306
401
 
307
- def write(self, filename: str, content: str) -> None:
308
- """Write content to a file.
402
+ def write_if_not_exists(self, filename: str, content: str) -> bool:
403
+ """Write content to a file only if it doesn't exist.
309
404
 
310
405
  Args:
311
406
  filename: Name of the file to create.
312
407
  content: Content to write.
313
408
 
409
+ Returns:
410
+ True if file was created, False if it already existed.
411
+
314
412
  Raises:
315
413
  IOError: If file cannot be written.
316
414
  PermissionError: If lacking permission to write.
317
415
  """
318
416
  file_path = self.project_dir / filename
417
+
418
+ if file_path.exists():
419
+ return False
420
+
319
421
  file_path.write_text(content.strip(), encoding="utf-8")
422
+ self.tracker.track(file_path)
423
+ return True
320
424
 
321
- def write_to_path(self, path: Path, content: str) -> None:
322
- """Write content to a specific path.
425
+ def write_to_path_if_not_exists(self, path: Path, content: str) -> bool:
426
+ """Write content to a specific path only if it doesn't exist.
323
427
 
324
428
  Args:
325
429
  path: Full path to the file.
326
430
  content: Content to write.
327
431
 
432
+ Returns:
433
+ True if file was created, False if it already existed.
434
+
328
435
  Raises:
329
436
  IOError: If file cannot be written.
330
437
  PermissionError: If lacking permission to write.
331
438
  """
439
+ if path.exists():
440
+ return False
441
+
332
442
  path.write_text(content.strip(), encoding="utf-8")
443
+ self.tracker.track(path)
444
+ return True
333
445
 
334
- def ensure_dir(self, path: Path) -> None:
446
+ def ensure_dir(self, path: Path) -> bool:
335
447
  """Ensure a directory exists.
336
448
 
337
449
  Args:
338
450
  path: Directory path to create.
451
+
452
+ Returns:
453
+ True if directory was created, False if it already existed.
339
454
  """
455
+ if path.exists():
456
+ return False
457
+
340
458
  path.mkdir(parents=True, exist_ok=True)
459
+ self.tracker.track(path)
460
+ return True
461
+
462
+
463
+ # ============================================================================
464
+ # Home App Creator
465
+ # ============================================================================
341
466
 
342
467
 
343
468
  class _HomeAppCreator:
344
469
  """Creates the default 'home' Django application."""
345
470
 
346
- def __init__(self, project_dir: Path, writer: _ProjectFileWriter, templates: _TemplateManager):
471
+ def __init__(
472
+ self,
473
+ project_dir: Path,
474
+ writer: _ProjectFileWriter,
475
+ templates: _TemplateManager,
476
+ console: Console,
477
+ ):
347
478
  """Initialize the home app creator.
348
479
 
349
480
  Args:
350
481
  project_dir: The project directory path.
351
482
  writer: File writer instance.
352
483
  templates: Template manager instance.
484
+ console: Rich console for output.
353
485
  """
354
486
  self.project_dir = project_dir
355
487
  self.writer = writer
356
488
  self.templates = templates
489
+ self.console = console
357
490
  self.home_dir = project_dir / "home"
358
491
 
359
492
  def create(self) -> None:
360
493
  """Create the home app with all necessary files and directories."""
361
- from django.core.management import call_command
494
+ # Check if home app already exists
495
+ if self.home_dir.exists():
496
+ self.console.print(
497
+ "[yellow]Home app directory already exists, skipping app creation[/yellow]"
498
+ )
499
+ return
362
500
 
363
501
  # Create the Django app structure
364
- call_command("startapp", "home")
502
+ self._create_app_structure()
365
503
 
366
504
  # Create app files
367
505
  self._create_urls()
@@ -369,15 +507,36 @@ class _HomeAppCreator:
369
507
  self._create_templates()
370
508
  self._create_static_files()
371
509
 
510
+ def _create_app_structure(self) -> None:
511
+ """Create the basic Django app structure using startapp command."""
512
+ from django.core.management import call_command
513
+
514
+ try:
515
+ call_command("startapp", "home")
516
+ # Track the created directory
517
+ self.writer.tracker.track(self.home_dir)
518
+ except Exception as e:
519
+ raise IOError(f"Failed to create home app: {e}") from e
520
+
372
521
  def _create_urls(self) -> None:
373
522
  """Create urls.py for the home app."""
374
523
  urls_path = self.home_dir / "urls.py"
375
- self.writer.write_to_path(urls_path, self.templates.home_urls())
524
+ if self.writer.write_to_path_if_not_exists(urls_path, self.templates.home_urls()):
525
+ pass # File was created
526
+ else:
527
+ self.console.print("[dim]urls.py already exists, skipping[/dim]")
376
528
 
377
529
  def _create_views(self) -> None:
378
530
  """Create views.py for the home app."""
379
531
  views_path = self.home_dir / "views.py"
380
- self.writer.write_to_path(views_path, self.templates.home_views())
532
+ # Only overwrite if it's the default Django startapp content
533
+ if views_path.exists():
534
+ content = views_path.read_text(encoding="utf-8")
535
+ # Check if it's the default Django views.py (contains only imports or is minimal)
536
+ if len(content.strip()) < 100: # Default Django file is very short
537
+ views_path.write_text(self.templates.home_views().strip(), encoding="utf-8")
538
+ else:
539
+ self.writer.write_to_path_if_not_exists(views_path, self.templates.home_views())
381
540
 
382
541
  def _create_templates(self) -> None:
383
542
  """Create template directory structure and files."""
@@ -385,7 +544,7 @@ class _HomeAppCreator:
385
544
  self.writer.ensure_dir(templates_dir)
386
545
 
387
546
  index_path = templates_dir / "index.html"
388
- self.writer.write_to_path(index_path, "")
547
+ self.writer.write_to_path_if_not_exists(index_path, self.templates.home_index_html())
389
548
 
390
549
  def _create_static_files(self) -> None:
391
550
  """Create static directory structure and CSS files."""
@@ -393,7 +552,7 @@ class _HomeAppCreator:
393
552
  self.writer.ensure_dir(static_dir)
394
553
 
395
554
  css_path = static_dir / "tailwind.css"
396
- self.writer.write_to_path(css_path, self.templates.tailwind_css())
555
+ self.writer.write_to_path_if_not_exists(css_path, self.templates.tailwind_css())
397
556
 
398
557
 
399
558
  # ============================================================================
@@ -415,6 +574,7 @@ class _ProjectInitializer:
415
574
  project_dir: Path,
416
575
  dependencies: _ProjectDependencies | None = None,
417
576
  templates: _TemplateManager | None = None,
577
+ console: Console | None = None,
418
578
  ):
419
579
  """Initialize the project initializer.
420
580
 
@@ -422,22 +582,54 @@ class _ProjectInitializer:
422
582
  project_dir: The directory where the project will be created.
423
583
  dependencies: Dependency manager (uses default if None).
424
584
  templates: Template manager (uses default if None).
585
+ console: Rich console for output (creates new if None).
425
586
  """
426
587
  self.project_dir = Path(project_dir)
427
588
  self.dependencies = dependencies or _ProjectDependencies()
428
589
  self.templates = templates or _TemplateManager()
429
- self.writer = _ProjectFileWriter(self.project_dir)
590
+ self.console = console or Console()
591
+ self.tracker = _FileTracker()
592
+ self.writer = _ProjectFileWriter(self.project_dir, self.console, self.tracker)
430
593
 
431
- def _validate_directory(self) -> None:
594
+ def _validate_directory(self, force: bool = False) -> None:
432
595
  """Validate that the directory is suitable for initialization.
433
596
 
597
+ Args:
598
+ force: Skip validation and proceed regardless.
599
+
434
600
  Raises:
435
- ProjectInitializationError: If directory is not empty.
601
+ ProjectInitializationError: If directory contains non-VCS files and user declines.
436
602
  """
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
- )
603
+ if force:
604
+ return
605
+
606
+ existing_items = list(self.project_dir.iterdir())
607
+ if not existing_items:
608
+ return
609
+
610
+ # Filter out safe items
611
+ problematic_items = [
612
+ item for item in existing_items if item.name not in SAFE_DIRECTORY_ITEMS
613
+ ]
614
+
615
+ if not problematic_items:
616
+ # Only safe items present - proceed without prompting
617
+ return
618
+
619
+ # Show what exists and ask for confirmation
620
+ items_list = "\n - ".join(item.name for item in problematic_items)
621
+
622
+ self.console.print(f"[yellow]Directory is not empty. Found:[/yellow]\n - {items_list}\n")
623
+
624
+ should_proceed = Confirm.ask(
625
+ f"Initialize {PKG_DISPLAY_NAME} project anyway? "
626
+ "This will skip existing files and create new ones",
627
+ default=False,
628
+ console=self.console,
629
+ )
630
+
631
+ if not should_proceed:
632
+ raise ProjectInitializationError("Initialization cancelled by user.")
441
633
 
442
634
  def _get_preset_choice(self, preset: str | None = None) -> PresetType:
443
635
  """Get the user's preset choice or validate the provided one.
@@ -451,8 +643,6 @@ class _ProjectInitializer:
451
643
  Raises:
452
644
  ValueError: If preset is invalid.
453
645
  """
454
- from rich.prompt import Prompt
455
-
456
646
  if preset:
457
647
  try:
458
648
  return PresetType(preset)
@@ -460,99 +650,340 @@ class _ProjectInitializer:
460
650
  valid_presets = [p.value for p in PresetType]
461
651
  raise ValueError(
462
652
  f"Invalid preset '{preset}'. Must be one of: {', '.join(valid_presets)}"
463
- )
653
+ ) from None
464
654
 
465
655
  choice = Prompt.ask(
466
656
  "Choose a preset",
467
657
  choices=[p.value for p in PresetType],
468
658
  default=PresetType.DEFAULT.value,
659
+ console=self.console,
469
660
  )
470
661
  return PresetType(choice)
471
662
 
472
- def _create_core_files(self, preset: PresetType) -> None:
663
+ def _get_postgres_config_choice(self, preset: PresetType) -> bool:
664
+ """Get whether to use environment variables for PostgreSQL.
665
+
666
+ Args:
667
+ preset: The preset type (Vercel always uses env vars).
668
+
669
+ Returns:
670
+ True if using environment variables, False for pg_service/pgpass files.
671
+ """
672
+ # Vercel requires environment variables (no filesystem access)
673
+ if preset == PresetType.VERCEL:
674
+ return True
675
+
676
+ # For default preset, let user choose
677
+ self.console.print("\n[bold]PostgreSQL Configuration Method:[/bold]")
678
+ self.console.print(" • [cyan]Environment variables[/cyan]: Store credentials in .env file")
679
+ self.console.print(
680
+ " • [cyan]PostgreSQL service files[/cyan]: Use pg_service.conf and .pgpass"
681
+ )
682
+
683
+ use_env_vars = Confirm.ask(
684
+ "Use environment variables for PostgreSQL configuration?",
685
+ default=True,
686
+ console=self.console,
687
+ )
688
+
689
+ return use_env_vars
690
+
691
+ def _get_database_choice(
692
+ self, preset: PresetType, database: str | None = None
693
+ ) -> tuple[DatabaseBackend, bool]:
694
+ """Get the user's database choice and PostgreSQL config method.
695
+
696
+ Args:
697
+ preset: The preset type (Vercel requires PostgreSQL).
698
+ database: Optional database to use without prompting.
699
+
700
+ Returns:
701
+ Tuple of (database backend, use_env_vars_for_postgres).
702
+
703
+ Raises:
704
+ ValueError: If database is invalid or incompatible with preset.
705
+ """
706
+ # Vercel preset requires PostgreSQL (no file system access on Vercel)
707
+ if preset == PresetType.VERCEL:
708
+ if database and database != DatabaseBackend.POSTGRESQL:
709
+ raise ValueError(f"Vercel preset requires PostgreSQL database (got: {database})")
710
+ return DatabaseBackend.POSTGRESQL, True # Always use env vars with Vercel
711
+
712
+ # If database explicitly provided, validate it
713
+ if database:
714
+ try:
715
+ db_backend = DatabaseBackend(database)
716
+ except ValueError:
717
+ valid_databases = [db.value for db in DatabaseBackend]
718
+ raise ValueError(
719
+ f"Invalid database '{database}'. Must be one of: {', '.join(valid_databases)}"
720
+ ) from None
721
+
722
+ # Ask about PostgreSQL config method if applicable
723
+ use_env_vars = (
724
+ self._get_postgres_config_choice(preset)
725
+ if db_backend == DatabaseBackend.POSTGRESQL
726
+ else False
727
+ )
728
+ return db_backend, use_env_vars
729
+
730
+ # Interactive prompt for default preset
731
+ choice = Prompt.ask(
732
+ "Choose a database",
733
+ choices=[db.value for db in DatabaseBackend],
734
+ default=DatabaseBackend.SQLITE3.value,
735
+ console=self.console,
736
+ )
737
+ db_backend = DatabaseBackend(choice)
738
+
739
+ # Ask about PostgreSQL config method if applicable
740
+ use_env_vars = (
741
+ self._get_postgres_config_choice(preset)
742
+ if db_backend == DatabaseBackend.POSTGRESQL
743
+ else False
744
+ )
745
+ return db_backend, use_env_vars
746
+
747
+ def _create_core_files(
748
+ self, preset: PresetType, database: DatabaseBackend, use_postgres_env_vars: bool
749
+ ) -> None:
473
750
  """Create core project configuration files.
474
751
 
475
752
  Args:
476
753
  preset: The preset type to use.
754
+ database: The database backend to use.
755
+ use_postgres_env_vars: Whether to use env vars for PostgreSQL config.
477
756
 
478
757
  Raises:
479
758
  IOError: If files cannot be created.
480
759
  """
481
- dependencies = self.dependencies.get_for_preset(preset)
482
-
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())
486
-
487
- def _configure_preset_files(self, preset: PresetType) -> None:
760
+ dependencies = self.dependencies.get_for_config(preset, database)
761
+
762
+ # Create files only if they don't exist
763
+ files_to_create = {
764
+ "pyproject.toml": self.templates.pyproject_toml(
765
+ preset, database, dependencies, use_postgres_env_vars
766
+ ),
767
+ ".gitignore": self.templates.gitignore(),
768
+ "README.md": self.templates.readme(),
769
+ }
770
+
771
+ for filename, content in files_to_create.items():
772
+ if self.writer.write_if_not_exists(filename, content):
773
+ pass # File was created
774
+ else:
775
+ self.console.print(f"[dim]{filename} already exists, skipping[/dim]")
776
+
777
+ def _configure_preset_files_and_env_example(self, preset: PresetType) -> None:
488
778
  """Configure files based on the chosen preset.
489
779
 
490
780
  Args:
491
781
  preset: The preset configuration to apply.
782
+
783
+ Raises:
784
+ IOError: If preset-specific file generation fails.
492
785
  """
493
- if preset == PresetType.VERCEL:
494
- # Import here to avoid circular dependencies
495
- from .generators import ServerFileGenerator, VercelFileGenerator
786
+ from subprocess import CalledProcessError, run
496
787
 
497
- VercelFileGenerator().create()
498
- ServerFileGenerator().create()
788
+ try:
789
+ match preset:
790
+ case PresetType.VERCEL:
791
+ # Only generate vercel.json if it doesn't exist
792
+ if not (self.project_dir / "vercel.json").exists():
793
+ run(
794
+ [PKG_NAME, "generate", "-f", "vercel", "-y"],
795
+ cwd=self.project_dir,
796
+ check=True,
797
+ capture_output=True,
798
+ )
799
+ # Track the created file
800
+ self.tracker.track(self.project_dir / "vercel.json")
801
+ else:
802
+ self.console.print("[dim]vercel.json already exists, skipping[/dim]")
803
+
804
+ # Only generate api/server.py if it doesn't exist
805
+ server_path = self.project_dir / "api" / "server.py"
806
+ if not server_path.exists():
807
+ run(
808
+ [PKG_NAME, "generate", "-f", "server", "-y"],
809
+ cwd=self.project_dir,
810
+ check=True,
811
+ capture_output=True,
812
+ )
813
+ # Track the created files
814
+ self.tracker.track(self.project_dir / "api")
815
+ else:
816
+ self.console.print("[dim]api/server.py already exists, skipping[/dim]")
817
+ case _:
818
+ # Future presets will be added here
819
+ pass
820
+
821
+ except CalledProcessError as e:
822
+ error_msg = e.stderr.decode() if e.stderr else str(e)
823
+ raise IOError(f"Failed to generate preset-specific files: {error_msg}") from e
824
+ except FileNotFoundError as e:
825
+ raise IOError(
826
+ f"Command '{PKG_NAME}' not found. Ensure {PKG_DISPLAY_NAME} is properly installed."
827
+ ) from e
828
+
829
+ # Only generate .env.example file if it doesn't exist
830
+ env_example_path = self.project_dir / ".env.example"
831
+ if not env_example_path.exists():
832
+ try:
833
+ run(
834
+ [PKG_NAME, "generate", "-f", "env", "-y"],
835
+ cwd=self.project_dir,
836
+ check=True,
837
+ capture_output=True,
838
+ )
839
+ # Track the created file
840
+ self.tracker.track(env_example_path)
841
+ except CalledProcessError as e:
842
+ error_msg = e.stderr.decode() if e.stderr else str(e)
843
+ raise IOError(f"Failed to generate .env file: {error_msg}") from e
844
+ except FileNotFoundError as e:
845
+ raise IOError(
846
+ f"Command '{PKG_NAME}' not found. Ensure {PKG_DISPLAY_NAME} is properly installed."
847
+ ) from e
848
+ else:
849
+ self.console.print("[dim].env.example already exists, skipping[/dim]")
499
850
 
500
851
  def _create_home_app(self) -> None:
501
852
  """Create the default home application."""
502
- home_creator = _HomeAppCreator(self.project_dir, self.writer, self.templates)
853
+ home_creator = _HomeAppCreator(self.project_dir, self.writer, self.templates, self.console)
503
854
  home_creator.create()
504
855
 
505
- def create(self, preset: str | None = None) -> ExitCode:
856
+ def _show_next_steps(
857
+ self, preset: PresetType, database: DatabaseBackend, use_postgres_env_vars: bool
858
+ ) -> None:
859
+ """Display next steps for the user after successful initialization.
860
+
861
+ Args:
862
+ preset: The preset that was used.
863
+ database: The database backend that was chosen.
864
+ use_postgres_env_vars: Whether using env vars for PostgreSQL.
865
+ """
866
+ from rich.panel import Panel
867
+
868
+ next_steps = [
869
+ "1. Install dependencies: [bold cyan]uv sync[/bold cyan]",
870
+ "2. Copy [bold].env.example[/bold] to [bold].env[/bold] and configure",
871
+ ]
872
+
873
+ step_num = 3
874
+
875
+ # Database-specific instructions
876
+ if database == DatabaseBackend.POSTGRESQL:
877
+ if use_postgres_env_vars:
878
+ next_steps.append(
879
+ f"{step_num}. Configure PostgreSQL connection in [bold].env[/bold]"
880
+ )
881
+ else:
882
+ next_steps.append(
883
+ f"{step_num}. Configure PostgreSQL connection using [bold]pg_service.conf[/bold] and [bold].pgpass[/bold] files"
884
+ )
885
+ step_num += 1
886
+
887
+ # Preset-specific instructions
888
+ if preset == PresetType.VERCEL:
889
+ next_steps.append(f"{step_num}. Configure Vercel blob token in [bold].env[/bold]")
890
+ step_num += 1
891
+
892
+ next_steps.append(
893
+ f"{step_num}. Run development server: [bold cyan]uv run djx runserver[/bold cyan]"
894
+ )
895
+
896
+ panel = Panel(
897
+ "\n".join(next_steps),
898
+ title=f"[bold green]✓ {PKG_DISPLAY_NAME} project initialized successfully![/bold green]",
899
+ border_style="green",
900
+ padding=(1, 2),
901
+ )
902
+
903
+ self.console.print("\n")
904
+ self.console.print(panel)
905
+
906
+ def create(
907
+ self, preset: str | None = None, database: str | None = None, force: bool = False
908
+ ) -> ExitCode:
506
909
  """Execute the full project initialization workflow.
507
910
 
508
911
  Args:
509
912
  preset: Optional preset to use without prompting.
913
+ database: Optional database backend to use without prompting.
914
+ force: Skip directory validation.
510
915
 
511
916
  Returns:
512
917
  ExitCode indicating success or failure.
513
918
  """
514
- from christianwhocodes.io import Text, print
515
-
516
919
  try:
517
- # Validate directory is empty
518
- self._validate_directory()
920
+ # Validate directory is empty or acceptable
921
+ self._validate_directory(force=force)
519
922
 
520
923
  # Get and validate preset choice
521
924
  chosen_preset = self._get_preset_choice(preset)
522
925
 
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,
926
+ # Get and validate database choice
927
+ chosen_database, use_postgres_env_vars = self._get_database_choice(
928
+ chosen_preset, database
535
929
  )
930
+
931
+ with Progress(
932
+ SpinnerColumn(),
933
+ TextColumn("[progress.description]{task.description}"),
934
+ console=self.console,
935
+ transient=True,
936
+ ) as progress:
937
+ # Create core configuration files
938
+ task = progress.add_task("Creating project files...", total=None)
939
+ self._create_core_files(chosen_preset, chosen_database, use_postgres_env_vars)
940
+ progress.update(task, completed=True)
941
+
942
+ # Configure preset-specific files
943
+ task = progress.add_task("Configuring preset files...", total=None)
944
+ self._configure_preset_files_and_env_example(chosen_preset)
945
+ progress.update(task, completed=True)
946
+
947
+ # Create default app
948
+ task = progress.add_task("Creating home app...", total=None)
949
+ self._create_home_app()
950
+ progress.update(task, completed=True)
951
+
952
+ self._show_next_steps(chosen_preset, chosen_database, use_postgres_env_vars)
536
953
  return ExitCode.SUCCESS
537
954
 
538
955
  except KeyboardInterrupt:
539
- print("\nProject initialization cancelled.", Text.WARNING)
956
+ self.tracker.cleanup_all()
957
+ self.console.print(
958
+ "\n[yellow]Project initialization cancelled. Cleaned up partial files.[/yellow]"
959
+ )
540
960
  return ExitCode.ERROR
541
961
 
542
962
  except ProjectInitializationError as e:
543
- print(str(e), Text.WARNING)
963
+ # User declined to proceed - no cleanup needed since nothing was created
964
+ self.console.print(f"[yellow]{e}[/yellow]")
544
965
  return ExitCode.ERROR
545
966
 
546
967
  except ValueError as e:
547
- print(f"Configuration error: {e}", Text.ERROR)
968
+ self.tracker.cleanup_all()
969
+ self.console.print(
970
+ f"[red]Configuration error:[/red] {e}\n[yellow]Cleaned up partial files.[/yellow]"
971
+ )
548
972
  return ExitCode.ERROR
549
973
 
550
974
  except (IOError, PermissionError) as e:
551
- print(f"File system error: {e}", Text.ERROR)
975
+ self.tracker.cleanup_all()
976
+ self.console.print(
977
+ f"[red]File system error:[/red] {e}\n[yellow]Cleaned up partial files.[/yellow]"
978
+ )
552
979
  return ExitCode.ERROR
553
980
 
554
981
  except Exception as e:
555
- print(f"Unexpected error during initialization: {e}", Text.ERROR)
982
+ self.tracker.cleanup_all()
983
+ self.console.print(
984
+ f"[red]Unexpected error during initialization:[/red] {e}\n"
985
+ f"[yellow]Cleaned up partial files.[/yellow]"
986
+ )
556
987
  return ExitCode.ERROR
557
988
 
558
989
 
@@ -561,13 +992,29 @@ class _ProjectInitializer:
561
992
  # ============================================================================
562
993
 
563
994
 
564
- def initialize(preset: str | None = None) -> ExitCode:
565
- """Main entry point for project initialization.
995
+ def initialize(
996
+ preset: str | None = None, database: str | None = None, force: bool = False
997
+ ) -> ExitCode:
998
+ f"""Main entry point for project initialization.
999
+
1000
+ Creates a new project with the specified preset and database configuration.
566
1001
 
567
1002
  Args:
568
1003
  preset: Optional preset to use without prompting.
1004
+ Available presets: 'default', 'vercel'.
1005
+ database: Optional database backend to use without prompting.
1006
+ Available databases: '{DatabaseBackend.SQLITE3.value}', '{DatabaseBackend.POSTGRESQL.value}'.
1007
+ Note: Vercel preset requires PostgreSQL.
1008
+ force: Skip directory validation and proceed even if directory is not empty.
569
1009
 
570
1010
  Returns:
571
- ExitCode indicating success or failure.
1011
+ ExitCode.SUCCESS if initialization completed successfully,
1012
+ ExitCode.ERROR otherwise.
1013
+
1014
+ Example:
1015
+ >>> initialize(preset="vercel") # Will auto-select {DatabaseBackend.POSTGRESQL.value} due to Vercel preset requirement
1016
+ >>> initialize(database="{DatabaseBackend.POSTGRESQL.value}") # Default preset with {DatabaseBackend.POSTGRESQL.value}
1017
+ >>> initialize(preset="default", database="{DatabaseBackend.SQLITE3.value}") # Default preset with {DatabaseBackend.SQLITE3.value}
1018
+ >>> initialize(force=True)
572
1019
  """
573
- return _ProjectInitializer(PROJECT_DIR).create(preset=preset)
1020
+ return _ProjectInitializer(PROJECT_DIR).create(preset=preset, database=database, force=force)