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.
- sum/__init__.py +1 -0
- sum/boilerplate/.env.example +124 -0
- sum/boilerplate/.gitea/workflows/ci.yml +33 -0
- sum/boilerplate/.gitea/workflows/deploy-production.yml +98 -0
- sum/boilerplate/.gitea/workflows/deploy-staging.yml +113 -0
- sum/boilerplate/.github/workflows/ci.yml +36 -0
- sum/boilerplate/.github/workflows/deploy-production.yml +102 -0
- sum/boilerplate/.github/workflows/deploy-staging.yml +115 -0
- sum/boilerplate/.gitignore +45 -0
- sum/boilerplate/README.md +259 -0
- sum/boilerplate/manage.py +34 -0
- sum/boilerplate/project_name/__init__.py +5 -0
- sum/boilerplate/project_name/home/__init__.py +5 -0
- sum/boilerplate/project_name/home/apps.py +20 -0
- sum/boilerplate/project_name/home/management/__init__.py +0 -0
- sum/boilerplate/project_name/home/management/commands/__init__.py +0 -0
- sum/boilerplate/project_name/home/management/commands/populate_demo_content.py +644 -0
- sum/boilerplate/project_name/home/management/commands/seed.py +129 -0
- sum/boilerplate/project_name/home/management/commands/seed_showroom.py +1661 -0
- sum/boilerplate/project_name/home/migrations/__init__.py +3 -0
- sum/boilerplate/project_name/home/models.py +13 -0
- sum/boilerplate/project_name/settings/__init__.py +5 -0
- sum/boilerplate/project_name/settings/base.py +348 -0
- sum/boilerplate/project_name/settings/local.py +78 -0
- sum/boilerplate/project_name/settings/production.py +106 -0
- sum/boilerplate/project_name/urls.py +33 -0
- sum/boilerplate/project_name/wsgi.py +16 -0
- sum/boilerplate/pytest.ini +5 -0
- sum/boilerplate/requirements.txt +25 -0
- sum/boilerplate/static/client/.gitkeep +3 -0
- sum/boilerplate/templates/overrides/.gitkeep +3 -0
- sum/boilerplate/tests/__init__.py +3 -0
- sum/boilerplate/tests/test_health.py +51 -0
- sum/cli.py +42 -0
- sum/commands/__init__.py +10 -0
- sum/commands/backup.py +308 -0
- sum/commands/check.py +128 -0
- sum/commands/init.py +265 -0
- sum/commands/promote.py +758 -0
- sum/commands/run.py +96 -0
- sum/commands/themes.py +56 -0
- sum/commands/update.py +301 -0
- sum/config.py +61 -0
- sum/docs/USER_GUIDE.md +663 -0
- sum/exceptions.py +45 -0
- sum/setup/__init__.py +17 -0
- sum/setup/auth.py +184 -0
- sum/setup/database.py +58 -0
- sum/setup/deps.py +73 -0
- sum/setup/git_ops.py +463 -0
- sum/setup/infrastructure.py +576 -0
- sum/setup/orchestrator.py +354 -0
- sum/setup/remote_themes.py +371 -0
- sum/setup/scaffold.py +500 -0
- sum/setup/seed.py +110 -0
- sum/setup/site_orchestrator.py +441 -0
- sum/setup/venv.py +89 -0
- sum/system_config.py +330 -0
- sum/themes_registry.py +180 -0
- sum/utils/__init__.py +25 -0
- sum/utils/django.py +97 -0
- sum/utils/environment.py +76 -0
- sum/utils/output.py +78 -0
- sum/utils/project.py +110 -0
- sum/utils/prompts.py +36 -0
- sum/utils/validation.py +313 -0
- sum_cli-3.0.0.dist-info/METADATA +127 -0
- sum_cli-3.0.0.dist-info/RECORD +72 -0
- sum_cli-3.0.0.dist-info/WHEEL +5 -0
- sum_cli-3.0.0.dist-info/entry_points.txt +2 -0
- sum_cli-3.0.0.dist-info/licenses/LICENSE +29 -0
- 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
|