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
sum/setup/scaffold.py ADDED
@@ -0,0 +1,500 @@
1
+ # pyright: reportUnknownArgumentType=false, reportUnknownMemberType=false, reportUnusedCallResult=false, reportImplicitStringConcatenation=false
2
+
3
+ """Project scaffold and theme setup utilities."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import importlib.resources
8
+ import importlib.resources.abc
9
+ import json
10
+ import os
11
+ import shutil
12
+ import tempfile
13
+ from dataclasses import dataclass
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+ from typing import cast
17
+
18
+ from sum.exceptions import SetupError, ThemeNotFoundError, ThemeValidationError
19
+ from sum.setup.remote_themes import parse_theme_spec, resolve_remote_theme
20
+ from sum.system_config import ConfigurationError, get_system_config
21
+ from sum.utils.environment import find_monorepo_root
22
+ from sum.utils.project import (
23
+ ProjectNaming,
24
+ get_packaged_boilerplate,
25
+ is_boilerplate_dir,
26
+ safe_rmtree,
27
+ safe_text_replace_in_file,
28
+ validate_project_name,
29
+ )
30
+
31
+ BoilerplateSource = Path | importlib.resources.abc.Traversable
32
+
33
+ DEFAULT_THEME_SLUG = "theme_a"
34
+ LEGACY_CORE_CSS_REF = "/static/sum_core/css/main.css"
35
+ MIN_COMPILED_CSS_BYTES = 5 * 1024
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class ThemeManifest:
40
+ slug: str
41
+ name: str
42
+ description: str
43
+ version: str
44
+
45
+ def validate(self) -> None:
46
+ if not self.slug:
47
+ raise ValueError("slug cannot be empty")
48
+ if not self.name:
49
+ raise ValueError("name cannot be empty")
50
+ if not self.version:
51
+ raise ValueError("version cannot be empty")
52
+
53
+
54
+ def _read_manifest(theme_dir: Path) -> ThemeManifest:
55
+ manifest_path = theme_dir / "theme.json"
56
+ if not manifest_path.is_file():
57
+ raise ThemeValidationError(f"Missing theme manifest: {manifest_path}")
58
+
59
+ try:
60
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
61
+ except json.JSONDecodeError as exc:
62
+ raise ThemeValidationError(
63
+ f"Invalid JSON in theme manifest: {manifest_path} ({exc})"
64
+ ) from exc
65
+
66
+ if not isinstance(data, dict):
67
+ raise ThemeValidationError(f"Theme manifest must be an object: {manifest_path}")
68
+
69
+ manifest_data = cast(dict[str, object], data)
70
+ manifest = ThemeManifest(
71
+ slug=str(manifest_data.get("slug", "")).strip(),
72
+ name=str(manifest_data.get("name", "")).strip(),
73
+ description=str(manifest_data.get("description", "")).strip(),
74
+ version=str(manifest_data.get("version", "")).strip(),
75
+ )
76
+ try:
77
+ manifest.validate()
78
+ except ValueError as exc:
79
+ raise ThemeValidationError(str(exc)) from exc
80
+
81
+ if manifest.slug != theme_dir.name:
82
+ raise ThemeValidationError(
83
+ f"Theme slug mismatch: dir='{theme_dir.name}' manifest='{manifest.slug}'"
84
+ )
85
+ return manifest
86
+
87
+
88
+ def _resolve_theme_dir(theme_slug: str, repo_root: Path | None) -> Path:
89
+ """Resolve theme directory from slug, optionally with version.
90
+
91
+ Resolution order:
92
+ 1. SUM_THEME_PATH environment variable (if set)
93
+ 2. Monorepo themes directory (repo_root/themes/)
94
+ 3. Current working directory (./themes/)
95
+ 4. Remote fetch from sum-themes repository
96
+
97
+ Args:
98
+ theme_slug: Theme slug, optionally with version (e.g., 'theme_a' or 'theme_a@1.0.0').
99
+ repo_root: Optional path to monorepo root.
100
+
101
+ Returns:
102
+ Path to the resolved theme directory.
103
+
104
+ Raises:
105
+ ThemeNotFoundError: If theme cannot be found locally or remotely.
106
+ """
107
+ # Parse theme spec to extract slug and optional version
108
+ spec = parse_theme_spec(theme_slug)
109
+ slug = spec.slug
110
+
111
+ # If version is specified, skip local lookups and go straight to remote
112
+ # (local themes don't support versioning)
113
+ if spec.version is not None:
114
+ return resolve_remote_theme(theme_slug)
115
+
116
+ # Step 1: Check SUM_THEME_PATH environment variable
117
+ env = os.getenv("SUM_THEME_PATH")
118
+ if env:
119
+ path = Path(env).expanduser().resolve()
120
+ if not path.exists():
121
+ raise ThemeNotFoundError(f"SUM_THEME_PATH does not exist: {path}")
122
+ if (path / "theme.json").is_file():
123
+ return path
124
+ candidate = path / slug
125
+ if candidate.is_dir():
126
+ return candidate
127
+ raise ThemeNotFoundError(
128
+ f"Theme '{slug}' not found under SUM_THEME_PATH: {path}"
129
+ )
130
+
131
+ # Step 2: Check monorepo themes directory
132
+ if repo_root is not None:
133
+ repo_theme = repo_root / "themes" / slug
134
+ if repo_theme.is_dir():
135
+ return repo_theme
136
+
137
+ # Step 3: Check current working directory
138
+ cwd_theme = (Path.cwd() / "themes" / slug).resolve()
139
+ if cwd_theme.is_dir():
140
+ return cwd_theme
141
+
142
+ # Step 4: Fetch from remote sum-themes repository
143
+ remote_error: str | None = None
144
+ try:
145
+ return resolve_remote_theme(slug)
146
+ except ThemeNotFoundError as exc:
147
+ # Preserve the remote error details for the final message
148
+ remote_error = str(exc)
149
+
150
+ locations = [
151
+ "SUM_THEME_PATH (if set)",
152
+ f"{repo_root}/themes/ (monorepo)" if repo_root else None,
153
+ "./themes/ (current directory)",
154
+ "sum-themes remote repository",
155
+ ]
156
+ locations_str = "\n - ".join(loc for loc in locations if loc)
157
+ message = f"Theme '{slug}' not found. Looked in:\n - {locations_str}"
158
+ if remote_error:
159
+ message += f"\n\nRemote fetch error: {remote_error}"
160
+ raise ThemeNotFoundError(message)
161
+
162
+
163
+ def _get_theme(theme_slug: str, repo_root: Path | None) -> tuple[ThemeManifest, Path]:
164
+ theme_dir = _resolve_theme_dir(theme_slug, repo_root)
165
+ return _read_manifest(theme_dir), theme_dir
166
+
167
+
168
+ def _resolve_boilerplate_source(repo_root: Path | None) -> BoilerplateSource:
169
+ env_override = os.getenv("SUM_BOILERPLATE_PATH")
170
+ if env_override:
171
+ path = Path(env_override).expanduser().resolve()
172
+ if not is_boilerplate_dir(path):
173
+ message = (
174
+ "SUM_BOILERPLATE_PATH is set but is not a valid boilerplate dir: "
175
+ f"{path}"
176
+ )
177
+ raise SetupError(message)
178
+ return path
179
+
180
+ if repo_root is not None:
181
+ repo_boilerplate = repo_root / "boilerplate"
182
+ if is_boilerplate_dir(repo_boilerplate):
183
+ return repo_boilerplate
184
+
185
+ cwd_boilerplate = (Path.cwd() / "boilerplate").resolve()
186
+ if is_boilerplate_dir(cwd_boilerplate):
187
+ return cwd_boilerplate
188
+
189
+ packaged = get_packaged_boilerplate()
190
+ return cast(BoilerplateSource, packaged)
191
+
192
+
193
+ def _replace_placeholders(project_root: Path, naming: ProjectNaming) -> None:
194
+ for dirpath, _, filenames in os.walk(project_root):
195
+ for filename in filenames:
196
+ path = Path(dirpath) / filename
197
+ _ = safe_text_replace_in_file(
198
+ path,
199
+ "project_name",
200
+ naming.python_package,
201
+ )
202
+
203
+
204
+ def _rename_project_package_dir(project_root: Path, naming: ProjectNaming) -> None:
205
+ src = project_root / "project_name"
206
+ dst = project_root / naming.python_package
207
+ if not src.exists():
208
+ raise SetupError("Boilerplate is malformed: missing 'project_name/' package.")
209
+ if dst.exists():
210
+ raise SetupError(
211
+ f"Refusing to overwrite existing project package directory: {dst}"
212
+ )
213
+ src.rename(dst)
214
+
215
+
216
+ def _create_env_from_example(project_root: Path) -> None:
217
+ env_example = project_root / ".env.example"
218
+ env_file = project_root / ".env"
219
+ if env_file.exists():
220
+ return
221
+ if env_example.exists():
222
+ shutil.copy2(env_example, env_file)
223
+
224
+
225
+ def _theme_contract_errors(theme_root: Path, theme_slug: str) -> list[str]:
226
+ errors: list[str] = []
227
+
228
+ manifest_path = theme_root / "theme.json"
229
+ if not manifest_path.is_file():
230
+ errors.append(f"Missing theme manifest: {manifest_path}")
231
+
232
+ base_template = theme_root / "templates" / "theme" / "base.html"
233
+ if not base_template.is_file():
234
+ errors.append(f"Missing theme base template: {base_template}")
235
+
236
+ compiled_css = theme_root / "static" / theme_slug / "css" / "main.css"
237
+ if not compiled_css.is_file():
238
+ errors.append(f"Missing compiled CSS: {compiled_css}")
239
+ else:
240
+ try:
241
+ size = compiled_css.stat().st_size
242
+ except OSError as exc:
243
+ errors.append(f"Could not stat compiled CSS: {compiled_css} ({exc})")
244
+ else:
245
+ if size <= MIN_COMPILED_CSS_BYTES:
246
+ errors.append(
247
+ f"Compiled CSS is unexpectedly small ({size} bytes): {compiled_css}"
248
+ )
249
+
250
+ try:
251
+ css_text = compiled_css.read_text(encoding="utf-8", errors="ignore")
252
+ except OSError as exc:
253
+ errors.append(f"Could not read compiled CSS: {compiled_css} ({exc})")
254
+ else:
255
+ if LEGACY_CORE_CSS_REF in css_text:
256
+ errors.append(
257
+ "Compiled CSS references legacy core stylesheet "
258
+ f"({LEGACY_CORE_CSS_REF}): {compiled_css}"
259
+ )
260
+
261
+ return errors
262
+
263
+
264
+ def _copy_theme_to_active(
265
+ project_root: Path, theme_source_dir: Path, theme_slug: str
266
+ ) -> None:
267
+ theme_target_dir = project_root / "theme" / "active"
268
+ theme_parent_dir = theme_target_dir.parent
269
+ theme_parent_dir.mkdir(parents=True, exist_ok=True)
270
+
271
+ if theme_target_dir.exists():
272
+ raise SetupError(f"Theme target directory already exists: {theme_target_dir}")
273
+
274
+ ignore = shutil.ignore_patterns("node_modules")
275
+ with tempfile.TemporaryDirectory(prefix=f"sum-theme-{theme_slug}-") as tmp_root:
276
+ tmp_dir = Path(tmp_root) / theme_slug
277
+ shutil.copytree(theme_source_dir, tmp_dir, dirs_exist_ok=False, ignore=ignore)
278
+
279
+ errors = _theme_contract_errors(tmp_dir, theme_slug)
280
+ if errors:
281
+ raise SetupError(
282
+ "Theme copy validation failed:\n - " + "\n - ".join(errors)
283
+ )
284
+
285
+ shutil.move(str(tmp_dir), str(theme_target_dir))
286
+
287
+
288
+ def _write_theme_config(
289
+ project_root: Path, theme_slug: str, theme_version: str
290
+ ) -> None:
291
+ sum_dir = project_root / ".sum"
292
+ sum_dir.mkdir(parents=True, exist_ok=True)
293
+
294
+ theme_config = {
295
+ "theme": theme_slug,
296
+ "original_version": theme_version,
297
+ "locked_at": datetime.now(UTC).isoformat(),
298
+ }
299
+
300
+ theme_file = sum_dir / "theme.json"
301
+ theme_file.write_text(
302
+ json.dumps(theme_config, indent=2) + "\n",
303
+ encoding="utf-8",
304
+ )
305
+
306
+
307
+ def _resolve_seeders_dir(repo_root: Path | None) -> Path | None:
308
+ """Resolve the seeders package directory.
309
+
310
+ Resolution order:
311
+ 1. Monorepo seeders directory (repo_root/seeders/)
312
+ 2. Current working directory (./seeders/)
313
+
314
+ Returns None if seeders cannot be found (will skip seeder copy).
315
+ """
316
+ if repo_root is not None:
317
+ repo_seeders = repo_root / "seeders"
318
+ if repo_seeders.is_dir() and (repo_seeders / "__init__.py").exists():
319
+ return repo_seeders
320
+
321
+ cwd_seeders = (Path.cwd() / "seeders").resolve()
322
+ if cwd_seeders.is_dir() and (cwd_seeders / "__init__.py").exists():
323
+ return cwd_seeders
324
+
325
+ return None
326
+
327
+
328
+ def _resolve_content_profile_dir(profile: str, repo_root: Path | None) -> Path | None:
329
+ """Resolve the content profile directory.
330
+
331
+ Resolution order:
332
+ 1. Monorepo content directory (repo_root/content/<profile>/)
333
+ 2. Current working directory (./content/<profile>/)
334
+
335
+ Returns None if profile cannot be found (will skip content copy).
336
+ """
337
+ if repo_root is not None:
338
+ repo_content = repo_root / "content" / profile
339
+ if repo_content.is_dir():
340
+ return repo_content
341
+
342
+ cwd_content = (Path.cwd() / "content" / profile).resolve()
343
+ if cwd_content.is_dir():
344
+ return cwd_content
345
+
346
+ return None
347
+
348
+
349
+ def _copy_seeders_and_content(
350
+ project_path: Path,
351
+ seeders_dir: Path | None,
352
+ content_profile_dir: Path | None,
353
+ profile: str,
354
+ ) -> None:
355
+ """Copy seeders package and content profile to scaffolded project."""
356
+ ignore = shutil.ignore_patterns("__pycache__", "*.pyc")
357
+
358
+ if seeders_dir is not None:
359
+ target_seeders = project_path / "seeders"
360
+ shutil.copytree(seeders_dir, target_seeders, ignore=ignore)
361
+
362
+ if content_profile_dir is not None:
363
+ # Create content/<profile>/ structure
364
+ target_content = project_path / "content" / profile
365
+ target_content.parent.mkdir(parents=True, exist_ok=True)
366
+ shutil.copytree(content_profile_dir, target_content, ignore=ignore)
367
+
368
+
369
+ def _configure_ci_workflows(project_path: Path) -> None:
370
+ """Keep only the CI workflow directory for the configured git provider.
371
+
372
+ Boilerplate contains both .github/workflows/ and .gitea/workflows/.
373
+ This removes the one that doesn't match the configured git_provider.
374
+ """
375
+ github_dir = project_path / ".github"
376
+ gitea_dir = project_path / ".gitea"
377
+
378
+ # Determine provider from config, defaulting to github if config unavailable
379
+ try:
380
+ config = get_system_config()
381
+ provider = config.agency.git_provider
382
+ except ConfigurationError:
383
+ # Config not available (e.g., running in test without config)
384
+ # Default to GitHub workflows
385
+ provider = "github"
386
+
387
+ if provider == "gitea":
388
+ # Remove GitHub workflows, keep Gitea
389
+ if github_dir.exists():
390
+ shutil.rmtree(github_dir)
391
+ else:
392
+ # Remove Gitea workflows, keep GitHub (default)
393
+ if gitea_dir.exists():
394
+ shutil.rmtree(gitea_dir)
395
+
396
+
397
+ def scaffold_project(
398
+ project_name: str,
399
+ clients_dir: Path,
400
+ theme_slug: str = DEFAULT_THEME_SLUG,
401
+ seed_profile: str = "starter",
402
+ ) -> Path:
403
+ try:
404
+ naming = validate_project_name(project_name)
405
+ except ValueError as exc:
406
+ raise SetupError(str(exc)) from exc
407
+
408
+ project_path: Path = clients_dir / naming.slug
409
+ if project_path.exists():
410
+ raise SetupError(f"Target directory already exists: {project_path}")
411
+
412
+ repo_root = find_monorepo_root(clients_dir)
413
+
414
+ try:
415
+ theme_manifest, theme_source_dir = _get_theme(theme_slug, repo_root)
416
+ except ThemeNotFoundError as exc:
417
+ raise SetupError(f"Theme '{theme_slug}' does not exist.") from exc
418
+ except ThemeValidationError as exc:
419
+ raise SetupError(f"Theme '{theme_slug}' is invalid: {exc}") from exc
420
+
421
+ # Resolve seeders and content profile for the seed command
422
+ seeders_dir = _resolve_seeders_dir(repo_root)
423
+ content_profile_dir = _resolve_content_profile_dir(seed_profile, repo_root)
424
+
425
+ boilerplate_source = _resolve_boilerplate_source(repo_root)
426
+
427
+ try:
428
+ clients_dir.mkdir(parents=True, exist_ok=True)
429
+ boilerplate_ignore = shutil.ignore_patterns("__pycache__", "*.pyc")
430
+ if isinstance(boilerplate_source, Path):
431
+ if not is_boilerplate_dir(boilerplate_source):
432
+ raise SetupError(
433
+ f"Boilerplate missing or malformed at: {boilerplate_source}"
434
+ )
435
+ shutil.copytree(
436
+ boilerplate_source,
437
+ project_path,
438
+ dirs_exist_ok=False,
439
+ ignore=boilerplate_ignore,
440
+ )
441
+ else:
442
+ with importlib.resources.as_file(boilerplate_source) as bp_path:
443
+ bp_path = Path(bp_path)
444
+ if not is_boilerplate_dir(bp_path):
445
+ raise SetupError("Packaged boilerplate missing or malformed.")
446
+ shutil.copytree(
447
+ bp_path,
448
+ project_path,
449
+ dirs_exist_ok=False,
450
+ ignore=boilerplate_ignore,
451
+ )
452
+ except FileExistsError as exc:
453
+ raise SetupError(f"Target directory already exists: {project_path}") from exc
454
+ except SetupError:
455
+ raise
456
+ except Exception as exc:
457
+ raise SetupError(f"Failed to copy boilerplate: {exc}") from exc
458
+
459
+ try:
460
+ _rename_project_package_dir(project_path, naming)
461
+ _replace_placeholders(project_path, naming)
462
+ _create_env_from_example(project_path)
463
+ _configure_ci_workflows(project_path)
464
+ _copy_theme_to_active(project_path, theme_source_dir, theme_slug)
465
+ _write_theme_config(project_path, theme_slug, theme_manifest.version)
466
+ _copy_seeders_and_content(
467
+ project_path, seeders_dir, content_profile_dir, seed_profile
468
+ )
469
+ except Exception as exc:
470
+ try:
471
+ safe_rmtree(project_path, tmp_root=None, repo_root=repo_root)
472
+ except Exception:
473
+ # Cleanup failures should not mask the original init error.
474
+ pass
475
+ raise SetupError(f"Project created but failed to finalize init: {exc}") from exc
476
+
477
+ return project_path
478
+
479
+
480
+ def validate_project_structure(project_path: Path) -> None:
481
+ if not project_path.is_dir():
482
+ raise SetupError(f"Project directory does not exist: {project_path}")
483
+
484
+ required_files = [
485
+ project_path / "manage.py",
486
+ project_path / "pytest.ini",
487
+ project_path / ".env",
488
+ project_path / ".env.example",
489
+ ]
490
+ for path in required_files:
491
+ if not path.exists():
492
+ raise SetupError(f"Missing required file: {path}")
493
+
494
+ theme_dir = project_path / "theme" / "active"
495
+ if not theme_dir.is_dir():
496
+ raise SetupError(f"Missing active theme directory: {theme_dir}")
497
+
498
+ theme_config = project_path / ".sum" / "theme.json"
499
+ if not theme_config.is_file():
500
+ raise SetupError(f"Missing theme config: {theme_config}")
sum/setup/seed.py ADDED
@@ -0,0 +1,110 @@
1
+ """Content seeding for CLI setup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from sum.exceptions import SeedError
9
+ from sum.utils.django import DjangoCommandExecutor
10
+
11
+
12
+ @dataclass
13
+ class SeedResult:
14
+ """Result of a seed operation."""
15
+
16
+ success: bool
17
+ page_id: int | None = None
18
+
19
+
20
+ class ContentSeeder:
21
+ """Seeds initial Wagtail content."""
22
+
23
+ def __init__(self, django_executor: DjangoCommandExecutor) -> None:
24
+ self.django = django_executor
25
+
26
+ def seed_homepage(self, preset: str | None = None) -> SeedResult:
27
+ """Create initial homepage.
28
+
29
+ Args:
30
+ preset: Optional theme preset to use for seeding.
31
+
32
+ Raises:
33
+ SeedError: If seeding fails.
34
+
35
+ Returns:
36
+ SeedResult with success=True and optional page_id.
37
+ """
38
+ cmd = ["seed_homepage"]
39
+ if preset:
40
+ cmd.extend(["--preset", preset])
41
+
42
+ result = self.django.run_command(cmd, check=False)
43
+
44
+ if result.returncode != 0:
45
+ # Check if it's just "already exists" warning
46
+ if "already exists" in result.stdout.lower():
47
+ return SeedResult(success=True)
48
+ raise SeedError(f"Seeding failed: {result.stderr}")
49
+
50
+ return SeedResult(success=True, page_id=self._extract_page_id(result.stdout))
51
+
52
+ def seed_profile(self, profile: str) -> SeedResult:
53
+ """Run the profile-based site seeder.
54
+
55
+ Args:
56
+ profile: The content profile name to seed (e.g. "sage-stone").
57
+
58
+ Raises:
59
+ SeedError: If seeding fails.
60
+
61
+ Returns:
62
+ SeedResult with success=True.
63
+ """
64
+ result = self.django.run_command(["seed", profile], check=False)
65
+
66
+ if result.returncode != 0:
67
+ details = result.stderr or result.stdout
68
+ raise SeedError(f"Seeding failed: {details}")
69
+
70
+ return SeedResult(success=True)
71
+
72
+ def check_homepage_exists(self) -> bool:
73
+ """Check if homepage is already created.
74
+
75
+ Returns:
76
+ True if homepage exists, False otherwise.
77
+
78
+ Raises:
79
+ SeedError: If the Django shell command fails.
80
+ """
81
+ result = self.django.run_command(
82
+ [
83
+ "shell",
84
+ "-c",
85
+ (
86
+ "from home.models import HomePage; "
87
+ "print(HomePage.objects.filter(slug='home').exists())"
88
+ ),
89
+ ],
90
+ check=False,
91
+ )
92
+
93
+ if result.returncode != 0:
94
+ raise SeedError(
95
+ f"Failed to check homepage existence: {result.stderr or result.stdout}"
96
+ )
97
+
98
+ return result.stdout.strip().lower() == "true"
99
+
100
+ def _extract_page_id(self, output: str) -> int | None:
101
+ """Extract page ID from command output.
102
+
103
+ Args:
104
+ output: The command output to parse.
105
+
106
+ Returns:
107
+ The extracted page ID or None if not found.
108
+ """
109
+ match = re.search(r"ID: (\d+)", output)
110
+ return int(match.group(1)) if match else None