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/utils/environment.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ExecutionMode(str, Enum):
|
|
8
|
+
MONOREPO = "monorepo"
|
|
9
|
+
STANDALONE = "standalone"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _normalize_start_path(start_path: Path | None) -> Path:
|
|
13
|
+
"""Return a directory path from a file path or default to the current cwd."""
|
|
14
|
+
path = start_path or Path.cwd()
|
|
15
|
+
if path.is_file():
|
|
16
|
+
return path.parent
|
|
17
|
+
return path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find_monorepo_root(start_path: Path | None = None) -> Path | None:
|
|
21
|
+
"""Walk upward to find monorepo root (contains core/ and boilerplate/)."""
|
|
22
|
+
search_path = _normalize_start_path(start_path)
|
|
23
|
+
|
|
24
|
+
for parent in [search_path, *search_path.parents]:
|
|
25
|
+
if (parent / "core").is_dir() and (parent / "boilerplate").is_dir():
|
|
26
|
+
return parent
|
|
27
|
+
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def detect_mode(path: Path | None = None) -> ExecutionMode:
|
|
32
|
+
"""Detect execution mode based on directory structure."""
|
|
33
|
+
if find_monorepo_root(path) is not None:
|
|
34
|
+
return ExecutionMode.MONOREPO
|
|
35
|
+
return ExecutionMode.STANDALONE
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_clients_dir(start_path: Path | None = None, *, create: bool = False) -> Path:
|
|
39
|
+
"""Resolve the clients directory for monorepo or standalone mode."""
|
|
40
|
+
repo_root = find_monorepo_root(start_path)
|
|
41
|
+
if repo_root is not None:
|
|
42
|
+
monorepo_clients = repo_root / "clients"
|
|
43
|
+
if monorepo_clients.is_dir():
|
|
44
|
+
return monorepo_clients
|
|
45
|
+
|
|
46
|
+
search_path = _normalize_start_path(start_path)
|
|
47
|
+
standalone_clients = search_path / "clients"
|
|
48
|
+
if standalone_clients.is_dir():
|
|
49
|
+
return standalone_clients
|
|
50
|
+
|
|
51
|
+
if create:
|
|
52
|
+
standalone_clients.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
return standalone_clients
|
|
54
|
+
|
|
55
|
+
raise FileNotFoundError(
|
|
56
|
+
"Cannot locate clients directory. Ensure you're running inside the monorepo "
|
|
57
|
+
"or from a standalone project root that contains a 'clients' folder."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_project_path(project: str | None, start_path: Path | None = None) -> Path:
|
|
62
|
+
"""Resolve a project path from name or current directory."""
|
|
63
|
+
if project:
|
|
64
|
+
clients_dir = get_clients_dir(start_path)
|
|
65
|
+
project_path = clients_dir / project
|
|
66
|
+
if project_path.is_dir():
|
|
67
|
+
return project_path
|
|
68
|
+
raise FileNotFoundError(f"Project not found: {project}")
|
|
69
|
+
|
|
70
|
+
cwd = _normalize_start_path(start_path)
|
|
71
|
+
if (cwd / "manage.py").exists():
|
|
72
|
+
return cwd
|
|
73
|
+
|
|
74
|
+
raise FileNotFoundError(
|
|
75
|
+
"Not in a project directory. Either cd into a project or specify project name."
|
|
76
|
+
)
|
sum/utils/output.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OutputFormatter:
|
|
7
|
+
"""Formatting helpers for CLI output."""
|
|
8
|
+
|
|
9
|
+
@staticmethod
|
|
10
|
+
def progress(
|
|
11
|
+
step: int, total: int, message: str, status: str = "running", emit: bool = True
|
|
12
|
+
) -> str:
|
|
13
|
+
"""Format, print, and return a progress line for a multi-step operation."""
|
|
14
|
+
line = f"⏳ [{step}/{total}] {status}: {message}"
|
|
15
|
+
if emit:
|
|
16
|
+
print(line)
|
|
17
|
+
return line
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def success(message: str, emit: bool = True) -> str:
|
|
21
|
+
"""Format, print, and return a success message string."""
|
|
22
|
+
line = f"✅ {message}"
|
|
23
|
+
if emit:
|
|
24
|
+
print(line)
|
|
25
|
+
return line
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def error(message: str, emit: bool = True) -> str:
|
|
29
|
+
"""Format, print, and return an error message string."""
|
|
30
|
+
line = f"❌ {message}"
|
|
31
|
+
if emit:
|
|
32
|
+
print(line)
|
|
33
|
+
return line
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def info(message: str, emit: bool = True) -> str:
|
|
37
|
+
"""Format, print, and return an informational message string."""
|
|
38
|
+
line = f"ℹ {message}"
|
|
39
|
+
if emit:
|
|
40
|
+
print(line)
|
|
41
|
+
return line
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def warning(message: str, emit: bool = True) -> str:
|
|
45
|
+
"""Format, print, and return a warning message string."""
|
|
46
|
+
line = f"⚠️ {message}"
|
|
47
|
+
if emit:
|
|
48
|
+
print(line)
|
|
49
|
+
return line
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def header(message: str, emit: bool = True) -> str:
|
|
53
|
+
"""Format, print, and return a header message string."""
|
|
54
|
+
line = f"🚀 {message}"
|
|
55
|
+
if emit:
|
|
56
|
+
print(line)
|
|
57
|
+
return line
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def summary(project_name: str, data: Mapping[str, str], emit: bool = True) -> str:
|
|
61
|
+
"""Format, print, and return a formatted summary block for a project.
|
|
62
|
+
|
|
63
|
+
Password values are intentionally omitted from the output for safety.
|
|
64
|
+
"""
|
|
65
|
+
lines = [
|
|
66
|
+
"📋 Summary",
|
|
67
|
+
"--------------------",
|
|
68
|
+
f"🏷️ Project: {project_name}",
|
|
69
|
+
]
|
|
70
|
+
for key, value in data.items():
|
|
71
|
+
if key.lower() == "password":
|
|
72
|
+
continue
|
|
73
|
+
lines.append(f"✅ {key}: {value}")
|
|
74
|
+
lines.append("--------------------")
|
|
75
|
+
output = "\n".join(lines)
|
|
76
|
+
if emit:
|
|
77
|
+
print(output)
|
|
78
|
+
return output
|
sum/utils/project.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Project naming utilities and boilerplate resource access.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for validating project names, generating
|
|
4
|
+
Python package names from slugs, and accessing bundled boilerplate templates.
|
|
5
|
+
|
|
6
|
+
Migrated from sum_cli.util as part of CLI v2 consolidation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib.resources
|
|
12
|
+
import importlib.resources.abc
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Final
|
|
18
|
+
|
|
19
|
+
PROJECT_SLUG_RE: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ProjectNaming:
|
|
24
|
+
"""
|
|
25
|
+
Naming derived from the user-provided project slug.
|
|
26
|
+
|
|
27
|
+
- slug: directory name under clients/
|
|
28
|
+
- python_package: importable Python identifier (hyphens converted to underscores)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
slug: str
|
|
32
|
+
python_package: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_project_name(project_name: str) -> ProjectNaming:
|
|
36
|
+
"""
|
|
37
|
+
Validate and normalize the project name.
|
|
38
|
+
|
|
39
|
+
Allowed: lowercase letters, digits, hyphens; must start with a letter.
|
|
40
|
+
"""
|
|
41
|
+
name = project_name.strip()
|
|
42
|
+
if not PROJECT_SLUG_RE.fullmatch(name):
|
|
43
|
+
raise ValueError(
|
|
44
|
+
"Invalid project name. Use lowercase letters, digits, and hyphens "
|
|
45
|
+
"(must start with a letter). Example: acme-kitchens"
|
|
46
|
+
)
|
|
47
|
+
python_pkg = name.replace("-", "_")
|
|
48
|
+
return ProjectNaming(slug=name, python_package=python_pkg)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_packaged_boilerplate() -> importlib.resources.abc.Traversable:
|
|
52
|
+
"""
|
|
53
|
+
Return the boilerplate directory bundled with the CLI package as a Traversable.
|
|
54
|
+
|
|
55
|
+
Use `importlib.resources.as_file(...)` to materialize this to a real filesystem
|
|
56
|
+
path when you need to pass it to APIs like shutil.copytree.
|
|
57
|
+
"""
|
|
58
|
+
root = importlib.resources.files("sum")
|
|
59
|
+
return root.joinpath("boilerplate")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_boilerplate_dir(path: Path) -> bool:
|
|
63
|
+
"""
|
|
64
|
+
Minimal structural validation for a boilerplate directory.
|
|
65
|
+
"""
|
|
66
|
+
return (
|
|
67
|
+
path.is_dir()
|
|
68
|
+
and (path / "manage.py").is_file()
|
|
69
|
+
and (path / "pytest.ini").is_file()
|
|
70
|
+
and (path / "project_name").is_dir()
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def safe_text_replace_in_file(path: Path, old: str, new: str) -> bool:
|
|
75
|
+
"""
|
|
76
|
+
Replace text in a file if it looks like UTF-8 text.
|
|
77
|
+
|
|
78
|
+
Returns True if the file was modified.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
content = path.read_text(encoding="utf-8")
|
|
82
|
+
except UnicodeDecodeError:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
if old not in content:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
path.write_text(content.replace(old, new), encoding="utf-8")
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def find_repo_root(start: Path) -> Path | None:
|
|
93
|
+
"""Walk upward to find repo root (contains .git directory)."""
|
|
94
|
+
for directory in [start, *start.parents]:
|
|
95
|
+
if (directory / ".git").exists():
|
|
96
|
+
return directory
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def safe_rmtree(path: Path, *, tmp_root: Path | None, repo_root: Path | None) -> None:
|
|
101
|
+
resolved = path.resolve()
|
|
102
|
+
if ".git" in resolved.parts:
|
|
103
|
+
raise RuntimeError(f"Refusing to delete path containing .git: {resolved}")
|
|
104
|
+
if repo_root and resolved == repo_root.resolve():
|
|
105
|
+
raise RuntimeError(f"Refusing to delete repo root: {resolved}")
|
|
106
|
+
if tmp_root and not resolved.is_relative_to(tmp_root.resolve()):
|
|
107
|
+
raise RuntimeError(
|
|
108
|
+
"Refusing to delete outside tmp root: " f"{resolved} (tmp_root={tmp_root})"
|
|
109
|
+
)
|
|
110
|
+
shutil.rmtree(resolved)
|
sum/utils/prompts.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class PromptManager:
|
|
8
|
+
"""Handle interactive prompts with support for non-interactive modes."""
|
|
9
|
+
|
|
10
|
+
no_prompt: bool = False
|
|
11
|
+
ci: bool = False
|
|
12
|
+
|
|
13
|
+
def _should_prompt(self) -> bool:
|
|
14
|
+
return not (self.no_prompt or self.ci)
|
|
15
|
+
|
|
16
|
+
def confirm(self, message: str, default: bool = True) -> bool:
|
|
17
|
+
"""Ask a yes/no question and return the user's choice or the default."""
|
|
18
|
+
if not self._should_prompt():
|
|
19
|
+
return default
|
|
20
|
+
|
|
21
|
+
suffix = " [Y/n]" if default else " [y/N]"
|
|
22
|
+
response = input(f"{message}{suffix}: ").strip().lower()
|
|
23
|
+
if not response:
|
|
24
|
+
return default
|
|
25
|
+
return response in {"y", "yes"}
|
|
26
|
+
|
|
27
|
+
def text(self, message: str, default: str | None = None) -> str:
|
|
28
|
+
"""Prompt for text input, returning the response or default if provided."""
|
|
29
|
+
if not self._should_prompt():
|
|
30
|
+
return default or ""
|
|
31
|
+
|
|
32
|
+
suffix = f" [{default}]" if default else ""
|
|
33
|
+
response = input(f"{message}{suffix}: ").strip()
|
|
34
|
+
if response:
|
|
35
|
+
return response
|
|
36
|
+
return default or ""
|
sum/utils/validation.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import cast
|
|
13
|
+
|
|
14
|
+
from sum.setup.venv import VenvManager
|
|
15
|
+
from sum.utils.django import DjangoCommandExecutor
|
|
16
|
+
from sum.utils.environment import ExecutionMode, find_monorepo_root
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ValidationStatus(str, Enum):
|
|
20
|
+
OK = "ok"
|
|
21
|
+
FAIL = "fail"
|
|
22
|
+
SKIP = "skip"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ValidationResult:
|
|
27
|
+
status: ValidationStatus
|
|
28
|
+
message: str
|
|
29
|
+
remediation: str | None = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def passed(self) -> bool:
|
|
33
|
+
return self.status is ValidationStatus.OK
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def failed(self) -> bool:
|
|
37
|
+
return self.status is ValidationStatus.FAIL
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def skipped(self) -> bool:
|
|
41
|
+
return self.status is ValidationStatus.SKIP
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def ok(cls, message: str) -> ValidationResult:
|
|
45
|
+
return cls(ValidationStatus.OK, message)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def fail(cls, message: str, remediation: str | None = None) -> ValidationResult:
|
|
49
|
+
return cls(ValidationStatus.FAIL, message, remediation)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def skip(cls, message: str) -> ValidationResult:
|
|
53
|
+
return cls(ValidationStatus.SKIP, message)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
MIN_COMPILED_CSS_BYTES = 5 * 1024
|
|
57
|
+
ENV_KEY_RE = re.compile(r"^([A-Z][A-Z0-9_]*)=(.*)$")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_env_assignments(path: Path) -> dict[str, str]:
|
|
61
|
+
data: dict[str, str] = {}
|
|
62
|
+
if not path.exists():
|
|
63
|
+
return data
|
|
64
|
+
|
|
65
|
+
for raw in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
66
|
+
line = raw.strip()
|
|
67
|
+
if not line or line.startswith("#"):
|
|
68
|
+
continue
|
|
69
|
+
match = ENV_KEY_RE.match(line)
|
|
70
|
+
if not match:
|
|
71
|
+
continue
|
|
72
|
+
key, value = match.group(1), match.group(2)
|
|
73
|
+
data[key] = value
|
|
74
|
+
return data
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read_json_file(path: Path) -> tuple[dict[str, object] | None, str]:
|
|
78
|
+
if not path.exists():
|
|
79
|
+
return None, f"Missing file: {path}"
|
|
80
|
+
try:
|
|
81
|
+
return json.loads(path.read_text(encoding="utf-8")), ""
|
|
82
|
+
except Exception as exc: # pragma: no cover - best effort parsing
|
|
83
|
+
return None, f"Invalid JSON in {path}: {exc}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ProjectValidator:
|
|
87
|
+
"""Validates project setup state."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, project_path: Path, mode: ExecutionMode) -> None:
|
|
90
|
+
self.project_path = project_path
|
|
91
|
+
self.mode = mode
|
|
92
|
+
self.venv_manager = VenvManager()
|
|
93
|
+
|
|
94
|
+
def check_venv_exists(self) -> ValidationResult:
|
|
95
|
+
"""Check if virtualenv exists."""
|
|
96
|
+
venv_path = self.project_path / ".venv"
|
|
97
|
+
if venv_path.is_dir():
|
|
98
|
+
return ValidationResult.ok(".venv exists")
|
|
99
|
+
return ValidationResult.fail(
|
|
100
|
+
".venv not found",
|
|
101
|
+
"Run 'sum init --full' or 'python -m venv .venv'",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def check_packages_installed(self) -> ValidationResult:
|
|
105
|
+
"""Check if key packages are installed in venv."""
|
|
106
|
+
if not self.venv_manager.exists(self.project_path):
|
|
107
|
+
return ValidationResult.skip("Virtualenv missing; skipping package checks")
|
|
108
|
+
|
|
109
|
+
python = self.venv_manager.get_python_executable(self.project_path)
|
|
110
|
+
for package in ("django", "wagtail"):
|
|
111
|
+
result = subprocess.run(
|
|
112
|
+
[str(python), "-c", f"import {package}"],
|
|
113
|
+
capture_output=True,
|
|
114
|
+
text=True,
|
|
115
|
+
)
|
|
116
|
+
if result.returncode != 0:
|
|
117
|
+
return ValidationResult.fail(
|
|
118
|
+
f"Package '{package}' not installed",
|
|
119
|
+
"Run 'pip install -r requirements.txt'",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return ValidationResult.ok("Required packages installed")
|
|
123
|
+
|
|
124
|
+
def check_env_local(self) -> ValidationResult:
|
|
125
|
+
"""Check if .env.local exists."""
|
|
126
|
+
env_local = self.project_path / ".env.local"
|
|
127
|
+
if env_local.is_file():
|
|
128
|
+
return ValidationResult.ok(".env.local found")
|
|
129
|
+
return ValidationResult.skip("No .env.local found (superuser not created)")
|
|
130
|
+
|
|
131
|
+
def check_migrations_applied(self) -> ValidationResult:
|
|
132
|
+
"""Check if migrations are up to date."""
|
|
133
|
+
if not self.venv_manager.exists(self.project_path):
|
|
134
|
+
return ValidationResult.skip("Virtualenv missing; skipping migration check")
|
|
135
|
+
try:
|
|
136
|
+
executor = DjangoCommandExecutor(self.project_path, self.mode)
|
|
137
|
+
result = executor.run_command(["migrate", "--check"], check=False)
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
return ValidationResult.fail(f"Migration check failed: {exc}")
|
|
140
|
+
|
|
141
|
+
if result.returncode == 0:
|
|
142
|
+
return ValidationResult.ok("Migrations up to date")
|
|
143
|
+
return ValidationResult.fail(
|
|
144
|
+
"Pending migrations",
|
|
145
|
+
"Run 'python manage.py migrate'",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def check_homepage_exists(self) -> ValidationResult:
|
|
149
|
+
"""Check if homepage is set as site root."""
|
|
150
|
+
if not self.venv_manager.exists(self.project_path):
|
|
151
|
+
return ValidationResult.skip("Virtualenv missing; skipping homepage check")
|
|
152
|
+
try:
|
|
153
|
+
executor = DjangoCommandExecutor(self.project_path, self.mode)
|
|
154
|
+
script = "\n".join(
|
|
155
|
+
[
|
|
156
|
+
"from wagtail.models import Site",
|
|
157
|
+
"site = Site.objects.get(is_default_site=True)",
|
|
158
|
+
"print(site.root_page.slug)",
|
|
159
|
+
]
|
|
160
|
+
)
|
|
161
|
+
result = executor.run_command(["shell", "-c", script], check=False)
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
return ValidationResult.fail(f"Homepage check failed: {exc}")
|
|
164
|
+
|
|
165
|
+
if result.returncode == 0 and result.stdout.strip() == "home":
|
|
166
|
+
return ValidationResult.ok("Homepage set as site root")
|
|
167
|
+
return ValidationResult.fail(
|
|
168
|
+
"Homepage not configured",
|
|
169
|
+
"Run 'python manage.py seed_homepage'",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def check_required_env_vars(self) -> ValidationResult:
|
|
173
|
+
"""Check required env vars from .env.example are set."""
|
|
174
|
+
env_example_path = self.project_path / ".env.example"
|
|
175
|
+
required_env: dict[str, str] = _parse_env_assignments(env_example_path)
|
|
176
|
+
if not env_example_path.exists():
|
|
177
|
+
return ValidationResult.fail("Missing .env.example")
|
|
178
|
+
|
|
179
|
+
env: dict[str, str] = _parse_env_assignments(self.project_path / ".env")
|
|
180
|
+
missing: list[str] = []
|
|
181
|
+
for key in sorted(required_env.keys()):
|
|
182
|
+
if key in env:
|
|
183
|
+
continue
|
|
184
|
+
if key in os.environ and os.environ[key].strip():
|
|
185
|
+
continue
|
|
186
|
+
missing.append(str(key))
|
|
187
|
+
|
|
188
|
+
if required_env and missing:
|
|
189
|
+
missing_list: list[str] = list(missing)
|
|
190
|
+
return ValidationResult.fail(
|
|
191
|
+
"Missing required env vars",
|
|
192
|
+
"Missing: " + ", ".join(missing_list),
|
|
193
|
+
)
|
|
194
|
+
return ValidationResult.ok("Required env vars present")
|
|
195
|
+
|
|
196
|
+
def _resolve_theme_slugs(self) -> tuple[str | None, str | None, str | None]:
|
|
197
|
+
provenance_path = self.project_path / ".sum" / "theme.json"
|
|
198
|
+
provenance, provenance_err = _read_json_file(provenance_path)
|
|
199
|
+
expected_slug: str | None = None
|
|
200
|
+
if provenance is not None:
|
|
201
|
+
theme_value = provenance.get("theme")
|
|
202
|
+
if isinstance(theme_value, str) and theme_value.strip():
|
|
203
|
+
expected_slug = theme_value.strip()
|
|
204
|
+
|
|
205
|
+
theme_root = self.project_path / "theme" / "active"
|
|
206
|
+
if not theme_root.is_dir():
|
|
207
|
+
return (
|
|
208
|
+
expected_slug,
|
|
209
|
+
None,
|
|
210
|
+
f"Missing {theme_root} (expected active theme install)",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
manifest_path = theme_root / "theme.json"
|
|
214
|
+
manifest, manifest_err = _read_json_file(manifest_path)
|
|
215
|
+
manifest_slug: str | None = None
|
|
216
|
+
if manifest is None:
|
|
217
|
+
return expected_slug, None, manifest_err
|
|
218
|
+
|
|
219
|
+
slug_value = manifest.get("slug")
|
|
220
|
+
if isinstance(slug_value, str) and slug_value.strip():
|
|
221
|
+
manifest_slug = slug_value.strip()
|
|
222
|
+
else:
|
|
223
|
+
return (
|
|
224
|
+
expected_slug,
|
|
225
|
+
None,
|
|
226
|
+
"Missing `slug` field in theme/active/theme.json",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if provenance is None:
|
|
230
|
+
return expected_slug, manifest_slug, provenance_err
|
|
231
|
+
|
|
232
|
+
return expected_slug, manifest_slug, None
|
|
233
|
+
|
|
234
|
+
def check_theme_slug_match(self) -> ValidationResult:
|
|
235
|
+
expected_slug, manifest_slug, error = self._resolve_theme_slugs()
|
|
236
|
+
if error:
|
|
237
|
+
return ValidationResult.fail(error)
|
|
238
|
+
|
|
239
|
+
if expected_slug and manifest_slug and expected_slug != manifest_slug:
|
|
240
|
+
return ValidationResult.fail(
|
|
241
|
+
f".sum says '{expected_slug}' but theme/active says '{manifest_slug}'"
|
|
242
|
+
)
|
|
243
|
+
if manifest_slug:
|
|
244
|
+
return ValidationResult.ok(manifest_slug)
|
|
245
|
+
return ValidationResult.fail(
|
|
246
|
+
"Could not determine theme slug (check .sum/theme.json and theme/active/theme.json)"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def check_theme_compiled_css(self) -> ValidationResult:
|
|
250
|
+
expected_slug, manifest_slug, error = self._resolve_theme_slugs()
|
|
251
|
+
if error and manifest_slug is None:
|
|
252
|
+
return ValidationResult.fail(error)
|
|
253
|
+
|
|
254
|
+
css_slug = expected_slug or manifest_slug
|
|
255
|
+
if not css_slug:
|
|
256
|
+
return ValidationResult.fail(
|
|
257
|
+
"Could not determine theme slug for static path (check .sum/theme.json and theme/active/theme.json)"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
compiled_css = (
|
|
261
|
+
self.project_path
|
|
262
|
+
/ "theme"
|
|
263
|
+
/ "active"
|
|
264
|
+
/ "static"
|
|
265
|
+
/ css_slug
|
|
266
|
+
/ "css"
|
|
267
|
+
/ "main.css"
|
|
268
|
+
)
|
|
269
|
+
if not compiled_css.is_file():
|
|
270
|
+
return ValidationResult.fail(f"Missing {compiled_css}")
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
size = compiled_css.stat().st_size
|
|
274
|
+
except OSError as exc:
|
|
275
|
+
return ValidationResult.fail(f"Could not stat {compiled_css}: {exc}")
|
|
276
|
+
|
|
277
|
+
if size <= MIN_COMPILED_CSS_BYTES:
|
|
278
|
+
return ValidationResult.fail(
|
|
279
|
+
f"File too small ({size} bytes): {compiled_css}"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
return ValidationResult.ok(str(compiled_css))
|
|
283
|
+
|
|
284
|
+
def check_sum_core_import(self) -> ValidationResult:
|
|
285
|
+
monorepo_root = find_monorepo_root(self.project_path)
|
|
286
|
+
core_path_added = False
|
|
287
|
+
core_dir: Path | None = None
|
|
288
|
+
if self.mode is ExecutionMode.MONOREPO and monorepo_root is not None:
|
|
289
|
+
core_dir = monorepo_root / "core"
|
|
290
|
+
core_str = str(core_dir)
|
|
291
|
+
if core_str not in sys.path:
|
|
292
|
+
sys.path.insert(0, core_str)
|
|
293
|
+
core_path_added = True
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
module = importlib.import_module("sum_core")
|
|
297
|
+
_ = module.__name__
|
|
298
|
+
if self.mode is ExecutionMode.MONOREPO and monorepo_root is not None:
|
|
299
|
+
return ValidationResult.ok("monorepo mode")
|
|
300
|
+
return ValidationResult.ok("sum_core importable")
|
|
301
|
+
except ImportError:
|
|
302
|
+
if self.mode is ExecutionMode.MONOREPO and monorepo_root is not None:
|
|
303
|
+
return ValidationResult.fail(
|
|
304
|
+
"Failed in monorepo mode - check core/ directory structure"
|
|
305
|
+
)
|
|
306
|
+
return ValidationResult.fail(
|
|
307
|
+
"Install requirements first: pip install -r requirements.txt"
|
|
308
|
+
)
|
|
309
|
+
except Exception as exc:
|
|
310
|
+
return ValidationResult.fail(f"Failed to import sum_core: {exc}")
|
|
311
|
+
finally:
|
|
312
|
+
if core_path_added and core_dir is not None:
|
|
313
|
+
sys.path.remove(str(cast(Path, core_dir)))
|