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,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 ""
@@ -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)))