sum-cli 3.0.0__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 (72) hide show
  1. sum/__init__.py +1 -0
  2. sum/boilerplate/.env.example +124 -0
  3. sum/boilerplate/.gitea/workflows/ci.yml +33 -0
  4. sum/boilerplate/.gitea/workflows/deploy-production.yml +98 -0
  5. sum/boilerplate/.gitea/workflows/deploy-staging.yml +113 -0
  6. sum/boilerplate/.github/workflows/ci.yml +36 -0
  7. sum/boilerplate/.github/workflows/deploy-production.yml +102 -0
  8. sum/boilerplate/.github/workflows/deploy-staging.yml +115 -0
  9. sum/boilerplate/.gitignore +45 -0
  10. sum/boilerplate/README.md +259 -0
  11. sum/boilerplate/manage.py +34 -0
  12. sum/boilerplate/project_name/__init__.py +5 -0
  13. sum/boilerplate/project_name/home/__init__.py +5 -0
  14. sum/boilerplate/project_name/home/apps.py +20 -0
  15. sum/boilerplate/project_name/home/management/__init__.py +0 -0
  16. sum/boilerplate/project_name/home/management/commands/__init__.py +0 -0
  17. sum/boilerplate/project_name/home/management/commands/populate_demo_content.py +644 -0
  18. sum/boilerplate/project_name/home/management/commands/seed.py +129 -0
  19. sum/boilerplate/project_name/home/management/commands/seed_showroom.py +1661 -0
  20. sum/boilerplate/project_name/home/migrations/__init__.py +3 -0
  21. sum/boilerplate/project_name/home/models.py +13 -0
  22. sum/boilerplate/project_name/settings/__init__.py +5 -0
  23. sum/boilerplate/project_name/settings/base.py +348 -0
  24. sum/boilerplate/project_name/settings/local.py +78 -0
  25. sum/boilerplate/project_name/settings/production.py +106 -0
  26. sum/boilerplate/project_name/urls.py +33 -0
  27. sum/boilerplate/project_name/wsgi.py +16 -0
  28. sum/boilerplate/pytest.ini +5 -0
  29. sum/boilerplate/requirements.txt +25 -0
  30. sum/boilerplate/static/client/.gitkeep +3 -0
  31. sum/boilerplate/templates/overrides/.gitkeep +3 -0
  32. sum/boilerplate/tests/__init__.py +3 -0
  33. sum/boilerplate/tests/test_health.py +51 -0
  34. sum/cli.py +42 -0
  35. sum/commands/__init__.py +10 -0
  36. sum/commands/backup.py +308 -0
  37. sum/commands/check.py +128 -0
  38. sum/commands/init.py +265 -0
  39. sum/commands/promote.py +758 -0
  40. sum/commands/run.py +96 -0
  41. sum/commands/themes.py +56 -0
  42. sum/commands/update.py +301 -0
  43. sum/config.py +61 -0
  44. sum/docs/USER_GUIDE.md +663 -0
  45. sum/exceptions.py +45 -0
  46. sum/setup/__init__.py +17 -0
  47. sum/setup/auth.py +184 -0
  48. sum/setup/database.py +58 -0
  49. sum/setup/deps.py +73 -0
  50. sum/setup/git_ops.py +463 -0
  51. sum/setup/infrastructure.py +576 -0
  52. sum/setup/orchestrator.py +354 -0
  53. sum/setup/remote_themes.py +371 -0
  54. sum/setup/scaffold.py +500 -0
  55. sum/setup/seed.py +110 -0
  56. sum/setup/site_orchestrator.py +441 -0
  57. sum/setup/venv.py +89 -0
  58. sum/system_config.py +330 -0
  59. sum/themes_registry.py +180 -0
  60. sum/utils/__init__.py +25 -0
  61. sum/utils/django.py +97 -0
  62. sum/utils/environment.py +76 -0
  63. sum/utils/output.py +78 -0
  64. sum/utils/project.py +110 -0
  65. sum/utils/prompts.py +36 -0
  66. sum/utils/validation.py +313 -0
  67. sum_cli-3.0.0.dist-info/METADATA +127 -0
  68. sum_cli-3.0.0.dist-info/RECORD +72 -0
  69. sum_cli-3.0.0.dist-info/WHEEL +5 -0
  70. sum_cli-3.0.0.dist-info/entry_points.txt +2 -0
  71. sum_cli-3.0.0.dist-info/licenses/LICENSE +29 -0
  72. sum_cli-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,441 @@
1
+ """Site orchestration for production deployment.
2
+
3
+ Handles the full workflow of creating a working site on staging:
4
+ - Directory structure at /srv/sum/<name>/
5
+ - Postgres database with credentials
6
+ - Code scaffolding
7
+ - External venv
8
+ - Django setup (migrate, seed, superuser, collectstatic)
9
+ - Systemd service
10
+ - Caddy configuration
11
+ - Git repository
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import shutil
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ from sum.exceptions import SeedError, SetupError
21
+ from sum.setup.auth import SuperuserManager
22
+ from sum.setup.database import DatabaseManager
23
+ from sum.setup.deps import DependencyManager
24
+ from sum.setup.git_ops import setup_git_for_site
25
+ from sum.setup.infrastructure import (
26
+ SiteCredentials,
27
+ check_infrastructure,
28
+ cleanup_site,
29
+ configure_caddy,
30
+ create_postgres_database,
31
+ create_site_directories,
32
+ fix_site_ownership,
33
+ generate_site_credentials,
34
+ install_systemd_service,
35
+ start_site_service,
36
+ write_credentials_file,
37
+ write_env_file,
38
+ )
39
+ from sum.setup.scaffold import scaffold_project
40
+ from sum.setup.venv import VenvManager
41
+ from sum.system_config import SystemConfig, get_system_config
42
+ from sum.utils.django import DjangoCommandExecutor
43
+ from sum.utils.environment import ExecutionMode
44
+ from sum.utils.output import OutputFormatter
45
+
46
+
47
+ @dataclass
48
+ class SiteSetupResult:
49
+ """Result of site setup operation."""
50
+
51
+ success: bool
52
+ site_slug: str
53
+ site_dir: Path
54
+ app_dir: Path
55
+ domain: str
56
+ admin_url: str
57
+ credentials_path: Path
58
+ repo_url: str | None
59
+ superuser_username: str
60
+ superuser_password: str
61
+
62
+
63
+ @dataclass
64
+ class SiteSetupConfig:
65
+ """Configuration for site setup."""
66
+
67
+ site_slug: str
68
+ theme_slug: str = "theme_a"
69
+ seed_profile: str | None = "starter"
70
+ content_path: str | None = None
71
+ superuser_username: str = "admin"
72
+ skip_git: bool = False
73
+ skip_systemd: bool = False
74
+ skip_caddy: bool = False
75
+
76
+
77
+ class SiteOrchestrator:
78
+ """Orchestrates the full site setup workflow for production deployment.
79
+
80
+ Creates a working site on staging with all infrastructure configured.
81
+ """
82
+
83
+ def __init__(self, config: SystemConfig | None = None):
84
+ self.sys_config = config or get_system_config()
85
+ self._credentials: SiteCredentials | None = None
86
+ self._site_dir: Path | None = None
87
+ self._app_dir: Path | None = None
88
+
89
+ def setup_site(self, setup_config: SiteSetupConfig) -> SiteSetupResult:
90
+ """Run the complete site setup workflow.
91
+
92
+ Args:
93
+ setup_config: Configuration for the site setup.
94
+
95
+ Returns:
96
+ SiteSetupResult with all site information.
97
+
98
+ Raises:
99
+ SetupError: If any step fails.
100
+ """
101
+ site_slug = setup_config.site_slug
102
+ total_steps = self._count_steps(setup_config)
103
+ current_step = 0
104
+
105
+ try:
106
+ # Step 1: Check infrastructure prerequisites
107
+ current_step += 1
108
+ self._progress(current_step, total_steps, "Checking prerequisites")
109
+ infra = check_infrastructure()
110
+ infra.require_all()
111
+ self._done(current_step, total_steps, "Prerequisites verified")
112
+
113
+ # Step 2: Generate credentials
114
+ current_step += 1
115
+ self._progress(current_step, total_steps, "Generating credentials")
116
+ self._credentials = generate_site_credentials(
117
+ site_slug,
118
+ superuser_username=setup_config.superuser_username,
119
+ )
120
+ self._done(current_step, total_steps, "Credentials generated")
121
+
122
+ # Step 3: Create directory structure
123
+ current_step += 1
124
+ self._progress(current_step, total_steps, "Creating directories")
125
+ self._site_dir = create_site_directories(site_slug, self.sys_config)
126
+ self._app_dir = self._site_dir / "app"
127
+ self._done(current_step, total_steps, "Directories created")
128
+
129
+ # Step 4: Create Postgres database
130
+ current_step += 1
131
+ self._progress(current_step, total_steps, "Creating database")
132
+ create_postgres_database(self._credentials, self.sys_config)
133
+ self._done(current_step, total_steps, "Database created")
134
+
135
+ # Step 5: Write .env file
136
+ current_step += 1
137
+ self._progress(current_step, total_steps, "Writing configuration")
138
+ write_env_file(
139
+ self._site_dir, site_slug, self._credentials, self.sys_config
140
+ )
141
+ self._done(current_step, total_steps, "Configuration written")
142
+
143
+ # Step 6: Scaffold project code
144
+ current_step += 1
145
+ self._progress(current_step, total_steps, "Scaffolding project")
146
+ self._scaffold_to_app_dir(
147
+ site_slug,
148
+ setup_config.theme_slug,
149
+ setup_config.seed_profile,
150
+ )
151
+ # Copy .env to app directory (Django reads from app/.env, not site root)
152
+ self._copy_env_to_app()
153
+ self._done(current_step, total_steps, "Project scaffolded")
154
+
155
+ # Step 7: Create external venv
156
+ current_step += 1
157
+ self._progress(current_step, total_steps, "Creating virtualenv")
158
+ venv_path = self._site_dir / "venv"
159
+ venv_manager = VenvManager(venv_path=venv_path)
160
+ venv_manager.create(self._app_dir)
161
+ self._done(current_step, total_steps, "Virtualenv created")
162
+
163
+ # Step 8: Install dependencies
164
+ current_step += 1
165
+ self._progress(current_step, total_steps, "Installing dependencies")
166
+ deps_manager = DependencyManager(venv_manager=venv_manager)
167
+ deps_manager.install(self._app_dir)
168
+ self._done(current_step, total_steps, "Dependencies installed")
169
+
170
+ # Step 9: Run migrations
171
+ current_step += 1
172
+ self._progress(current_step, total_steps, "Running migrations")
173
+ django_executor = self._create_django_executor(venv_manager)
174
+ db_manager = DatabaseManager(django_executor)
175
+ db_manager.migrate()
176
+ self._done(current_step, total_steps, "Migrations complete")
177
+
178
+ # Step 10: Seed content
179
+ if setup_config.seed_profile:
180
+ current_step += 1
181
+ self._progress(current_step, total_steps, "Seeding content")
182
+ # Use the seed command with YAML content profiles
183
+ # Default profile is 'starter' - a production-ready base
184
+ self._seed_content(
185
+ django_executor,
186
+ setup_config.seed_profile,
187
+ setup_config.content_path,
188
+ )
189
+ self._done(current_step, total_steps, "Content seeded")
190
+
191
+ # Step 11: Create superuser
192
+ current_step += 1
193
+ self._progress(current_step, total_steps, "Creating superuser")
194
+ auth_manager = SuperuserManager(django_executor, self._app_dir)
195
+ auth_manager.create(
196
+ username=self._credentials.superuser_username,
197
+ email=self._credentials.superuser_email,
198
+ password=self._credentials.superuser_password,
199
+ )
200
+ self._done(current_step, total_steps, "Superuser created")
201
+
202
+ # Step 12: Collect static files
203
+ current_step += 1
204
+ self._progress(current_step, total_steps, "Collecting static files")
205
+ self._collect_static(django_executor)
206
+ self._done(current_step, total_steps, "Static files collected")
207
+
208
+ # Step 13: Git init and push (must happen before ownership change)
209
+ repo_url = None
210
+ current_step += 1
211
+ self._progress(current_step, total_steps, "Setting up git repository")
212
+ repo_url = setup_git_for_site(
213
+ self._app_dir,
214
+ site_slug,
215
+ skip_remote=setup_config.skip_git,
216
+ )
217
+ if repo_url:
218
+ self._done(current_step, total_steps, f"Repository created: {repo_url}")
219
+ else:
220
+ self._done(current_step, total_steps, "Git initialized (local only)")
221
+
222
+ # Step 14: Fix ownership for deploy user
223
+ current_step += 1
224
+ self._progress(current_step, total_steps, "Setting file ownership")
225
+ fix_site_ownership(site_slug, self.sys_config)
226
+ self._done(current_step, total_steps, "Ownership set")
227
+
228
+ # Step 15: Install systemd service (optional)
229
+ if not setup_config.skip_systemd:
230
+ current_step += 1
231
+ self._progress(current_step, total_steps, "Installing systemd service")
232
+ install_systemd_service(site_slug, self.sys_config)
233
+ self._done(current_step, total_steps, "Systemd service installed")
234
+
235
+ # Step 16: Configure Caddy (optional)
236
+ if not setup_config.skip_caddy:
237
+ current_step += 1
238
+ self._progress(current_step, total_steps, "Configuring Caddy")
239
+ configure_caddy(site_slug, self.sys_config)
240
+ self._done(current_step, total_steps, "Caddy configured")
241
+
242
+ # Step 17: Start service (if systemd was configured)
243
+ if not setup_config.skip_systemd:
244
+ current_step += 1
245
+ self._progress(current_step, total_steps, "Starting service")
246
+ start_site_service(site_slug, self.sys_config)
247
+ self._done(current_step, total_steps, "Service started")
248
+
249
+ # Step 18: Write credentials file
250
+ current_step += 1
251
+ self._progress(current_step, total_steps, "Saving credentials")
252
+ creds_path = write_credentials_file(
253
+ self._site_dir, site_slug, self._credentials, self.sys_config
254
+ )
255
+ self._done(current_step, total_steps, "Credentials saved")
256
+
257
+ # Build result
258
+ domain = self.sys_config.get_site_domain(site_slug)
259
+ return SiteSetupResult(
260
+ success=True,
261
+ site_slug=site_slug,
262
+ site_dir=self._site_dir,
263
+ app_dir=self._app_dir,
264
+ domain=domain,
265
+ admin_url=f"https://{domain}/admin/",
266
+ credentials_path=creds_path,
267
+ repo_url=repo_url,
268
+ superuser_username=self._credentials.superuser_username,
269
+ superuser_password=self._credentials.superuser_password,
270
+ )
271
+
272
+ except Exception as exc:
273
+ # Cleanup on failure
274
+ OutputFormatter.error(f"Setup failed: {exc}")
275
+ OutputFormatter.warning("Cleaning up partial installation...")
276
+ cleanup_site(site_slug, self.sys_config)
277
+ raise
278
+
279
+ def _count_steps(self, config: SiteSetupConfig) -> int:
280
+ """Count total steps based on configuration.
281
+
282
+ Builds a list of step names to ensure count matches actual execution order.
283
+ """
284
+ steps: list[str] = [
285
+ "check_prerequisites",
286
+ "generate_credentials",
287
+ "create_directories",
288
+ "create_database",
289
+ "write_env_file",
290
+ "scaffold_project",
291
+ "create_venv",
292
+ "install_dependencies",
293
+ "run_migrations",
294
+ ]
295
+
296
+ # Seeding happens before superuser creation
297
+ if config.seed_profile:
298
+ steps.append("seed_content")
299
+
300
+ steps.extend(
301
+ [
302
+ "create_superuser",
303
+ "collect_static",
304
+ # Git must happen before ownership change
305
+ "setup_git",
306
+ "fix_ownership",
307
+ ]
308
+ )
309
+
310
+ if not config.skip_systemd:
311
+ steps.append("install_systemd_service")
312
+
313
+ if not config.skip_caddy:
314
+ steps.append("configure_caddy")
315
+
316
+ # Start service after caddy is configured
317
+ if not config.skip_systemd:
318
+ steps.append("start_systemd_service")
319
+
320
+ steps.append("write_credentials_file")
321
+
322
+ return len(steps)
323
+
324
+ def _progress(self, step: int, total: int, message: str) -> None:
325
+ """Show progress indicator."""
326
+ OutputFormatter.progress(step, total, message, "⏳")
327
+
328
+ def _done(self, step: int, total: int, message: str) -> None:
329
+ """Show completion indicator."""
330
+ OutputFormatter.progress(step, total, message, "✅")
331
+
332
+ def _scaffold_to_app_dir(
333
+ self, site_slug: str, theme_slug: str, seed_profile: str | None = None
334
+ ) -> None:
335
+ """Scaffold project code to the app directory.
336
+
337
+ The scaffold function expects to create the directory, so we need to
338
+ remove the empty app dir first, let scaffold create it, then the
339
+ result will be in the right place.
340
+ """
341
+ if self._app_dir is None or self._site_dir is None:
342
+ raise SetupError("Site directories not initialized")
343
+
344
+ # Remove empty app dir (scaffold will create it)
345
+ if self._app_dir.exists():
346
+ try:
347
+ self._app_dir.rmdir()
348
+ except OSError as exc:
349
+ raise SetupError(
350
+ f"Cannot remove app directory (not empty?): {exc}"
351
+ ) from exc
352
+
353
+ # Scaffold creates <parent>/<slug>, so we use site_dir as parent
354
+ # and site_slug as the name, but we want it in "app" subdir
355
+ # So we scaffold to a temp name then rename
356
+ scaffold_project(
357
+ project_name=site_slug,
358
+ clients_dir=self._site_dir,
359
+ theme_slug=theme_slug,
360
+ seed_profile=seed_profile or "starter",
361
+ )
362
+
363
+ # Rename from site_slug to app
364
+ temp_path = self._site_dir / site_slug
365
+ if not temp_path.exists():
366
+ raise SetupError(f"Scaffold did not create expected directory: {temp_path}")
367
+
368
+ try:
369
+ temp_path.rename(self._app_dir)
370
+ except OSError as exc:
371
+ raise SetupError(
372
+ f"Failed to rename scaffolded directory to app: {exc}"
373
+ ) from exc
374
+
375
+ def _create_django_executor(
376
+ self, venv_manager: VenvManager
377
+ ) -> DjangoCommandExecutor:
378
+ """Create a Django command executor for the site."""
379
+ if self._app_dir is None:
380
+ raise SetupError("App directory not initialized")
381
+
382
+ # We need a custom executor that uses our external venv
383
+ return DjangoCommandExecutor(
384
+ self._app_dir,
385
+ ExecutionMode.STANDALONE,
386
+ python_path=venv_manager.get_python_executable(self._app_dir),
387
+ )
388
+
389
+ def _copy_env_to_app(self) -> None:
390
+ """Copy .env from site root to app directory.
391
+
392
+ Django reads .env from the project root (app/), not the site root.
393
+ The scaffolded project has a default .env that needs to be overwritten
394
+ with the production configuration.
395
+ """
396
+ if self._site_dir is None or self._app_dir is None:
397
+ raise SetupError("Site directories not initialized")
398
+
399
+ source_env = self._site_dir / ".env"
400
+ target_env = self._app_dir / ".env"
401
+
402
+ if not source_env.exists():
403
+ raise SetupError(f".env file not found at {source_env}")
404
+
405
+ try:
406
+ shutil.copy2(source_env, target_env)
407
+ except OSError as exc:
408
+ raise SetupError(f"Failed to copy .env to app directory: {exc}") from exc
409
+
410
+ def _collect_static(self, django_executor: DjangoCommandExecutor) -> None:
411
+ """Run collectstatic Django command."""
412
+ django_executor.run_command(["collectstatic", "--noinput"])
413
+
414
+ def _seed_content(
415
+ self,
416
+ django_executor: DjangoCommandExecutor,
417
+ profile: str,
418
+ content_path: str | None = None,
419
+ ) -> None:
420
+ """Seed the site with content using YAML content profiles.
421
+
422
+ Uses the seed management command with the seeders package and
423
+ content profiles.
424
+
425
+ Args:
426
+ django_executor: Django command executor for the site.
427
+ profile: Seed profile name (e.g., 'starter', 'sage-stone').
428
+ content_path: Optional path to custom content directory.
429
+ """
430
+ cmd = ["seed", profile, "--clear"]
431
+ if content_path:
432
+ cmd.extend(["--content-path", content_path])
433
+
434
+ result = django_executor.run_command(
435
+ cmd,
436
+ check=False,
437
+ )
438
+
439
+ if result.returncode != 0:
440
+ details = result.stderr or result.stdout
441
+ raise SeedError(f"Seeding failed: {details}")
sum/setup/venv.py ADDED
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from sum.exceptions import VenvError
9
+ from sum.utils.output import OutputFormatter
10
+
11
+
12
+ class VenvManager:
13
+ """Manage Python virtual environments for CLI projects.
14
+
15
+ Supports both internal venv (.venv inside project) and external venv
16
+ (separate directory outside project, e.g., /srv/sum/<name>/venv/).
17
+ """
18
+
19
+ def __init__(self, venv_path: Path | None = None):
20
+ """Initialize VenvManager with optional explicit venv path.
21
+
22
+ Args:
23
+ venv_path: If provided, use this path for the venv.
24
+ If None, uses project_path/.venv (legacy behavior).
25
+ """
26
+ self._explicit_venv_path = venv_path
27
+
28
+ def _get_venv_path(self, project_path: Path) -> Path:
29
+ """Get the venv path for a project."""
30
+ if self._explicit_venv_path is not None:
31
+ return self._explicit_venv_path
32
+ return project_path / ".venv"
33
+
34
+ def create(self, project_path: Path) -> Path:
35
+ """Create a virtualenv if it does not exist.
36
+
37
+ Args:
38
+ project_path: Path to the project. Used to determine venv location
39
+ if no explicit venv_path was provided.
40
+
41
+ Returns:
42
+ Path to the virtualenv directory.
43
+ """
44
+ venv_path = self._get_venv_path(project_path)
45
+
46
+ if venv_path.is_dir():
47
+ OutputFormatter.info(f"Virtualenv already exists at {venv_path}")
48
+ return venv_path
49
+
50
+ OutputFormatter.progress(1, 1, f"Creating virtualenv at {venv_path}")
51
+ try:
52
+ subprocess.run(
53
+ [sys.executable, "-m", "venv", str(venv_path)],
54
+ check=True,
55
+ capture_output=True,
56
+ text=True,
57
+ )
58
+ except subprocess.CalledProcessError as exc:
59
+ OutputFormatter.error("Failed to create virtualenv")
60
+ details = exc.stderr or exc.stdout or "Unknown error"
61
+ raise VenvError(
62
+ f"Failed to create virtualenv at {venv_path}: {details}"
63
+ ) from exc
64
+
65
+ OutputFormatter.success(f"Virtualenv created at {venv_path}")
66
+ return venv_path
67
+
68
+ def get_python_executable(self, project_path: Path) -> Path:
69
+ """Return the Python executable inside the virtualenv."""
70
+ venv_path = self._get_venv_path(project_path)
71
+ python_path = venv_path / "bin" / "python"
72
+ return python_path
73
+
74
+ def get_pip_executable(self, project_path: Path) -> Path:
75
+ """Return the pip executable inside the virtualenv."""
76
+ venv_path = self._get_venv_path(project_path)
77
+ pip_path = venv_path / "bin" / "pip"
78
+ return pip_path
79
+
80
+ def is_activated(self) -> bool:
81
+ """Return True when running inside a virtualenv."""
82
+ active = bool(os.environ.get("VIRTUAL_ENV")) or sys.prefix != sys.base_prefix
83
+ return active
84
+
85
+ def exists(self, project_path: Path) -> bool:
86
+ """Return True when the virtualenv exists."""
87
+ venv_path = self._get_venv_path(project_path)
88
+ exists = venv_path.is_dir()
89
+ return exists