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/system_config.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""System configuration for SUM Platform CLI.
|
|
2
|
+
|
|
3
|
+
Reads agency-specific settings from /etc/sum/config.yml.
|
|
4
|
+
All values MUST be provided in the config file - no defaults.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
from sum.exceptions import SumCliError
|
|
17
|
+
|
|
18
|
+
# Default config file location
|
|
19
|
+
DEFAULT_CONFIG_PATH = Path("/etc/sum/config.yml")
|
|
20
|
+
|
|
21
|
+
# Environment variable override for config path (useful for testing/dev)
|
|
22
|
+
CONFIG_PATH_ENV_VAR = "SUM_CONFIG_PATH"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConfigurationError(SumCliError):
|
|
26
|
+
"""Configuration file is missing or invalid."""
|
|
27
|
+
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class AgencyConfig:
|
|
33
|
+
"""Agency identification settings."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
# Git provider: "github" (default) or "gitea"
|
|
37
|
+
git_provider: str = "github"
|
|
38
|
+
# GitHub-specific (required when git_provider=github)
|
|
39
|
+
github_org: str | None = None
|
|
40
|
+
# Gitea-specific (required when git_provider=gitea)
|
|
41
|
+
gitea_url: str | None = None
|
|
42
|
+
gitea_org: str | None = None
|
|
43
|
+
gitea_token_env: str = "GITEA_TOKEN" # env var name containing API token
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
"""Validate provider-specific fields."""
|
|
47
|
+
if self.git_provider not in ("github", "gitea"):
|
|
48
|
+
raise ConfigurationError(
|
|
49
|
+
f"Invalid git_provider '{self.git_provider}'. Must be 'github' or 'gitea'."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if self.git_provider == "github":
|
|
53
|
+
if not self.github_org:
|
|
54
|
+
raise ConfigurationError(
|
|
55
|
+
"github_org is required when git_provider is 'github'"
|
|
56
|
+
)
|
|
57
|
+
elif self.git_provider == "gitea":
|
|
58
|
+
if not self.gitea_url:
|
|
59
|
+
raise ConfigurationError(
|
|
60
|
+
"gitea_url is required when git_provider is 'gitea'"
|
|
61
|
+
)
|
|
62
|
+
if not self.gitea_org:
|
|
63
|
+
raise ConfigurationError(
|
|
64
|
+
"gitea_org is required when git_provider is 'gitea'"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def org(self) -> str:
|
|
69
|
+
"""Get the organization name for the configured provider."""
|
|
70
|
+
if self.git_provider == "gitea":
|
|
71
|
+
return self.gitea_org or ""
|
|
72
|
+
return self.github_org or ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class StagingConfig:
|
|
77
|
+
"""Staging server configuration."""
|
|
78
|
+
|
|
79
|
+
server: str
|
|
80
|
+
domain_pattern: str # e.g., "{slug}.example.com"
|
|
81
|
+
base_dir: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ProductionConfig:
|
|
86
|
+
"""Production server configuration."""
|
|
87
|
+
|
|
88
|
+
server: str
|
|
89
|
+
ssh_host: str
|
|
90
|
+
base_dir: str
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class TemplatesConfig:
|
|
95
|
+
"""Infrastructure template paths."""
|
|
96
|
+
|
|
97
|
+
dir: str
|
|
98
|
+
systemd: str
|
|
99
|
+
caddy: str
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def systemd_path(self) -> Path:
|
|
103
|
+
"""Full path to systemd template."""
|
|
104
|
+
return Path(self.dir) / self.systemd
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def caddy_path(self) -> Path:
|
|
108
|
+
"""Full path to Caddy template."""
|
|
109
|
+
return Path(self.dir) / self.caddy
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class DefaultsConfig:
|
|
114
|
+
"""Default values for CLI operations."""
|
|
115
|
+
|
|
116
|
+
theme: str
|
|
117
|
+
deploy_user: str
|
|
118
|
+
seed_profile: str
|
|
119
|
+
postgres_port: int = 5432 # Default port, can be overridden in config
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class SystemConfig:
|
|
124
|
+
"""Complete system configuration.
|
|
125
|
+
|
|
126
|
+
Loaded from /etc/sum/config.yml. All values must be provided.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
agency: AgencyConfig
|
|
130
|
+
staging: StagingConfig
|
|
131
|
+
production: ProductionConfig
|
|
132
|
+
templates: TemplatesConfig
|
|
133
|
+
defaults: DefaultsConfig
|
|
134
|
+
|
|
135
|
+
_config_path: Path | None = None
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def load(cls, config_path: Path | str | None = None) -> SystemConfig:
|
|
139
|
+
"""Load configuration from file.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
config_path: Optional explicit path to config file.
|
|
143
|
+
If not provided, checks SUM_CONFIG_PATH env var,
|
|
144
|
+
then falls back to /etc/sum/config.yml.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
SystemConfig instance with loaded values.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
ConfigurationError: If config file is missing or invalid.
|
|
151
|
+
"""
|
|
152
|
+
# Determine config path
|
|
153
|
+
if config_path is None:
|
|
154
|
+
env_path = os.environ.get(CONFIG_PATH_ENV_VAR)
|
|
155
|
+
if env_path:
|
|
156
|
+
config_path = Path(env_path)
|
|
157
|
+
else:
|
|
158
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
159
|
+
else:
|
|
160
|
+
config_path = Path(config_path)
|
|
161
|
+
|
|
162
|
+
# Config file is required
|
|
163
|
+
if not config_path.exists():
|
|
164
|
+
raise ConfigurationError(
|
|
165
|
+
f"Configuration file not found: {config_path}\n\n"
|
|
166
|
+
f"The SUM CLI requires a configuration file.\n"
|
|
167
|
+
f"Create {config_path} with your agency settings.\n\n"
|
|
168
|
+
f"See docs/dev/cli/USER_GUIDE.md for the required format."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return cls._load_from_file(config_path)
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def _load_from_file(cls, config_path: Path) -> SystemConfig:
|
|
175
|
+
"""Load configuration from YAML file."""
|
|
176
|
+
try:
|
|
177
|
+
with open(config_path) as f:
|
|
178
|
+
data = yaml.safe_load(f)
|
|
179
|
+
except yaml.YAMLError as exc:
|
|
180
|
+
raise ConfigurationError(
|
|
181
|
+
f"Invalid YAML in configuration file {config_path}: {exc}"
|
|
182
|
+
) from exc
|
|
183
|
+
|
|
184
|
+
if not data:
|
|
185
|
+
raise ConfigurationError(f"Configuration file is empty: {config_path}")
|
|
186
|
+
|
|
187
|
+
# Validate required sections
|
|
188
|
+
required_sections = ["agency", "staging", "production", "templates", "defaults"]
|
|
189
|
+
missing_sections = [s for s in required_sections if s not in data]
|
|
190
|
+
if missing_sections:
|
|
191
|
+
raise ConfigurationError(
|
|
192
|
+
f"Missing required sections in {config_path}: {', '.join(missing_sections)}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
return cls(
|
|
197
|
+
agency=cls._load_section(data["agency"], AgencyConfig, "agency"),
|
|
198
|
+
staging=cls._load_section(data["staging"], StagingConfig, "staging"),
|
|
199
|
+
production=cls._load_section(
|
|
200
|
+
data["production"], ProductionConfig, "production"
|
|
201
|
+
),
|
|
202
|
+
templates=cls._load_section(
|
|
203
|
+
data["templates"], TemplatesConfig, "templates"
|
|
204
|
+
),
|
|
205
|
+
defaults=cls._load_section(
|
|
206
|
+
data["defaults"], DefaultsConfig, "defaults"
|
|
207
|
+
),
|
|
208
|
+
_config_path=config_path,
|
|
209
|
+
)
|
|
210
|
+
except ConfigurationError:
|
|
211
|
+
raise
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
raise ConfigurationError(
|
|
214
|
+
f"Error loading configuration from {config_path}: {exc}"
|
|
215
|
+
) from exc
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _load_section(
|
|
219
|
+
data: dict[str, Any], config_class: type, section_name: str
|
|
220
|
+
) -> Any:
|
|
221
|
+
"""Load a config section, validating all required fields are present."""
|
|
222
|
+
if not isinstance(data, dict):
|
|
223
|
+
raise ConfigurationError(
|
|
224
|
+
f"Section '{section_name}' must be a mapping, got {type(data).__name__}"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# For dataclasses without default_factory, check which fields have no default
|
|
228
|
+
all_fields = []
|
|
229
|
+
for field_name, field_info in config_class.__dataclass_fields__.items():
|
|
230
|
+
if field_name.startswith("_"):
|
|
231
|
+
continue
|
|
232
|
+
# Field is required if it has no default and no default_factory
|
|
233
|
+
if (
|
|
234
|
+
field_info.default is dataclasses.MISSING
|
|
235
|
+
and field_info.default_factory is dataclasses.MISSING
|
|
236
|
+
):
|
|
237
|
+
all_fields.append(field_name)
|
|
238
|
+
|
|
239
|
+
missing_fields = [f for f in all_fields if f not in data]
|
|
240
|
+
if missing_fields:
|
|
241
|
+
raise ConfigurationError(
|
|
242
|
+
f"Missing required fields in '{section_name}': {', '.join(missing_fields)}"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Build kwargs from data
|
|
246
|
+
kwargs = {}
|
|
247
|
+
for field_name in config_class.__dataclass_fields__:
|
|
248
|
+
if field_name.startswith("_"):
|
|
249
|
+
continue
|
|
250
|
+
if field_name in data:
|
|
251
|
+
kwargs[field_name] = data[field_name]
|
|
252
|
+
|
|
253
|
+
return config_class(**kwargs)
|
|
254
|
+
|
|
255
|
+
def get_site_dir(self, site_slug: str, target: str = "staging") -> Path:
|
|
256
|
+
"""Get the base directory for a site.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
site_slug: The site slug (e.g., 'acme')
|
|
260
|
+
target: 'staging' or 'prod'
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
Path to site directory
|
|
264
|
+
"""
|
|
265
|
+
if target == "prod":
|
|
266
|
+
base = self.production.base_dir
|
|
267
|
+
else:
|
|
268
|
+
base = self.staging.base_dir
|
|
269
|
+
return Path(base) / site_slug
|
|
270
|
+
|
|
271
|
+
def get_site_domain(self, site_slug: str, target: str = "staging") -> str:
|
|
272
|
+
"""Get the domain for a site.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
site_slug: The site slug (e.g., 'acme')
|
|
276
|
+
target: 'staging' or 'prod' (prod requires explicit domain)
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Domain string
|
|
280
|
+
|
|
281
|
+
Raises:
|
|
282
|
+
ValueError: If target is 'prod' (production requires explicit domain)
|
|
283
|
+
"""
|
|
284
|
+
if target == "prod":
|
|
285
|
+
raise ValueError("Production domain must be explicitly specified")
|
|
286
|
+
return self.staging.domain_pattern.format(slug=site_slug)
|
|
287
|
+
|
|
288
|
+
def get_db_name(self, site_slug: str) -> str:
|
|
289
|
+
"""Get the Postgres database name for a site."""
|
|
290
|
+
return f"sum_{site_slug}"
|
|
291
|
+
|
|
292
|
+
def get_db_user(self, site_slug: str) -> str:
|
|
293
|
+
"""Get the Postgres database user for a site."""
|
|
294
|
+
return f"sum_{site_slug}_user"
|
|
295
|
+
|
|
296
|
+
def get_systemd_service_name(self, site_slug: str) -> str:
|
|
297
|
+
"""Get the systemd service name for a site."""
|
|
298
|
+
return f"sum-{site_slug}-gunicorn"
|
|
299
|
+
|
|
300
|
+
def get_caddy_config_name(self, site_slug: str) -> str:
|
|
301
|
+
"""Get the Caddy config file name for a site."""
|
|
302
|
+
return f"sum-{site_slug}.caddy"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# Module-level singleton for convenience
|
|
306
|
+
_system_config: SystemConfig | None = None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def get_system_config(reload: bool = False) -> SystemConfig:
|
|
310
|
+
"""Get the system configuration singleton.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
reload: Force reload from file even if already loaded.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
SystemConfig instance.
|
|
317
|
+
|
|
318
|
+
Raises:
|
|
319
|
+
ConfigurationError: If config file is missing or invalid.
|
|
320
|
+
"""
|
|
321
|
+
global _system_config
|
|
322
|
+
if _system_config is None or reload:
|
|
323
|
+
_system_config = SystemConfig.load()
|
|
324
|
+
return _system_config
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def reset_system_config() -> None:
|
|
328
|
+
"""Reset the config singleton (primarily for testing)."""
|
|
329
|
+
global _system_config
|
|
330
|
+
_system_config = None
|
sum/themes_registry.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Theme registry and discovery for CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from sum.exceptions import ThemeNotFoundError, ThemeValidationError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class ThemeManifest:
|
|
16
|
+
"""Type-safe theme metadata loaded from theme.json."""
|
|
17
|
+
|
|
18
|
+
slug: str
|
|
19
|
+
name: str
|
|
20
|
+
description: str
|
|
21
|
+
version: str
|
|
22
|
+
|
|
23
|
+
def validate(self) -> None:
|
|
24
|
+
if not self.slug:
|
|
25
|
+
raise ValueError("slug cannot be empty")
|
|
26
|
+
if not self.name:
|
|
27
|
+
raise ValueError("name cannot be empty")
|
|
28
|
+
if not self.version:
|
|
29
|
+
raise ValueError("version cannot be empty")
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_dict(cls, data: dict[str, Any]) -> ThemeManifest:
|
|
33
|
+
return cls(
|
|
34
|
+
slug=str(data.get("slug", "")).strip(),
|
|
35
|
+
name=str(data.get("name", "")).strip(),
|
|
36
|
+
description=str(data.get("description", "")).strip(),
|
|
37
|
+
version=str(data.get("version", "")).strip(),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _read_manifest(theme_dir: Path) -> ThemeManifest:
|
|
42
|
+
manifest_path = theme_dir / "theme.json"
|
|
43
|
+
if not manifest_path.is_file():
|
|
44
|
+
raise ThemeValidationError(f"Missing theme manifest: {manifest_path}")
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
48
|
+
except json.JSONDecodeError as e:
|
|
49
|
+
raise ThemeValidationError(
|
|
50
|
+
f"Invalid JSON in theme manifest: {manifest_path} ({e})"
|
|
51
|
+
) from e
|
|
52
|
+
|
|
53
|
+
if not isinstance(data, dict):
|
|
54
|
+
raise ThemeValidationError(f"Theme manifest must be an object: {manifest_path}")
|
|
55
|
+
|
|
56
|
+
manifest = ThemeManifest.from_dict(data)
|
|
57
|
+
manifest.validate()
|
|
58
|
+
|
|
59
|
+
# Hard validation: directory name must match manifest slug
|
|
60
|
+
if manifest.slug != theme_dir.name:
|
|
61
|
+
raise ThemeValidationError(
|
|
62
|
+
f"Theme slug mismatch: dir='{theme_dir.name}' manifest='{manifest.slug}'"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return manifest
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _resolve_theme_dir_from_env(slug: str) -> Path | None:
|
|
69
|
+
"""
|
|
70
|
+
Resolve a theme dir from SUM_THEME_PATH.
|
|
71
|
+
|
|
72
|
+
Spec v1 supports setting SUM_THEME_PATH to a single theme directory like:
|
|
73
|
+
SUM_THEME_PATH=/path/to/themes/theme_a
|
|
74
|
+
|
|
75
|
+
For developer ergonomics we also support pointing at a themes root like:
|
|
76
|
+
SUM_THEME_PATH=/path/to/themes
|
|
77
|
+
"""
|
|
78
|
+
env = os.getenv("SUM_THEME_PATH")
|
|
79
|
+
if not env:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
p = Path(env).expanduser().resolve()
|
|
83
|
+
if not p.exists():
|
|
84
|
+
raise ThemeNotFoundError(f"SUM_THEME_PATH does not exist: {p}")
|
|
85
|
+
|
|
86
|
+
# If SUM_THEME_PATH points at a theme root (contains theme.json), use it directly.
|
|
87
|
+
if (p / "theme.json").is_file():
|
|
88
|
+
return p
|
|
89
|
+
|
|
90
|
+
# Otherwise treat it as a themes root containing subdirectories by slug.
|
|
91
|
+
candidate = p / slug
|
|
92
|
+
if candidate.is_dir():
|
|
93
|
+
return candidate
|
|
94
|
+
|
|
95
|
+
raise ThemeNotFoundError(f"Theme '{slug}' not found under SUM_THEME_PATH: {p}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def resolve_theme_dir(slug: str) -> Path:
|
|
99
|
+
"""
|
|
100
|
+
Resolve a theme directory using Theme Architecture Spec v1 order:
|
|
101
|
+
|
|
102
|
+
1) SUM_THEME_PATH (dev override)
|
|
103
|
+
2) repo-local canonical: ./themes/<slug> (relative to current working dir)
|
|
104
|
+
3) bundled themes inside CLI package (optional, later)
|
|
105
|
+
"""
|
|
106
|
+
slug = slug.strip()
|
|
107
|
+
if not slug:
|
|
108
|
+
raise ThemeNotFoundError("Theme slug cannot be empty")
|
|
109
|
+
|
|
110
|
+
env_dir = _resolve_theme_dir_from_env(slug)
|
|
111
|
+
if env_dir is not None:
|
|
112
|
+
return env_dir
|
|
113
|
+
|
|
114
|
+
repo_local = (Path.cwd() / "themes" / slug).resolve()
|
|
115
|
+
if repo_local.is_dir():
|
|
116
|
+
return repo_local
|
|
117
|
+
|
|
118
|
+
# Bundled themes inside CLI package: optional later (not implemented yet).
|
|
119
|
+
raise ThemeNotFoundError(
|
|
120
|
+
f"Theme '{slug}' not found. Looked in SUM_THEME_PATH (if set) and "
|
|
121
|
+
f"{repo_local.parent}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_theme(slug: str) -> ThemeManifest:
|
|
126
|
+
"""Return a validated ThemeManifest for the theme slug."""
|
|
127
|
+
theme_dir = resolve_theme_dir(slug)
|
|
128
|
+
try:
|
|
129
|
+
return _read_manifest(theme_dir)
|
|
130
|
+
except ThemeValidationError as e:
|
|
131
|
+
# Keep a stable exception type for callers (CLI/tests).
|
|
132
|
+
raise ThemeValidationError(str(e)) from e
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def list_themes() -> list[ThemeManifest]:
|
|
136
|
+
"""
|
|
137
|
+
List themes from the best available registry in this environment.
|
|
138
|
+
|
|
139
|
+
- If SUM_THEME_PATH points to a single theme dir, return that one theme.
|
|
140
|
+
- If SUM_THEME_PATH points to a themes root, scan that root.
|
|
141
|
+
- Else scan ./themes (repo-local canonical).
|
|
142
|
+
"""
|
|
143
|
+
env = os.getenv("SUM_THEME_PATH")
|
|
144
|
+
if env:
|
|
145
|
+
p = Path(env).expanduser().resolve()
|
|
146
|
+
if (p / "theme.json").is_file():
|
|
147
|
+
return [_read_manifest(p)]
|
|
148
|
+
if p.is_dir():
|
|
149
|
+
return discover_themes(p)
|
|
150
|
+
|
|
151
|
+
repo_local_root = (Path.cwd() / "themes").resolve()
|
|
152
|
+
return discover_themes(repo_local_root)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def discover_themes(themes_root: Path) -> list[ThemeManifest]:
|
|
156
|
+
"""Discover themes by scanning `<themes_root>/*/theme.json`."""
|
|
157
|
+
if not themes_root.exists():
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
manifests: list[ThemeManifest] = []
|
|
161
|
+
for theme_dir in sorted(p for p in themes_root.iterdir() if p.is_dir()):
|
|
162
|
+
if theme_dir.name.startswith("__"):
|
|
163
|
+
continue
|
|
164
|
+
try:
|
|
165
|
+
manifests.append(_read_manifest(theme_dir))
|
|
166
|
+
except ThemeValidationError:
|
|
167
|
+
# Discovery is tolerant by design: invalid themes are ignored.
|
|
168
|
+
continue
|
|
169
|
+
return sorted(manifests, key=lambda t: t.slug)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
__all__ = [
|
|
173
|
+
"ThemeManifest",
|
|
174
|
+
"ThemeNotFoundError",
|
|
175
|
+
"ThemeValidationError",
|
|
176
|
+
"discover_themes",
|
|
177
|
+
"get_theme",
|
|
178
|
+
"list_themes",
|
|
179
|
+
"resolve_theme_dir",
|
|
180
|
+
]
|
sum/utils/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sum.utils.django import DjangoCommandExecutor
|
|
4
|
+
from sum.utils.environment import (
|
|
5
|
+
ExecutionMode,
|
|
6
|
+
detect_mode,
|
|
7
|
+
find_monorepo_root,
|
|
8
|
+
get_clients_dir,
|
|
9
|
+
)
|
|
10
|
+
from sum.utils.output import OutputFormatter
|
|
11
|
+
from sum.utils.prompts import PromptManager
|
|
12
|
+
from sum.utils.validation import ProjectValidator, ValidationResult, ValidationStatus
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DjangoCommandExecutor",
|
|
16
|
+
"ExecutionMode",
|
|
17
|
+
"detect_mode",
|
|
18
|
+
"find_monorepo_root",
|
|
19
|
+
"get_clients_dir",
|
|
20
|
+
"OutputFormatter",
|
|
21
|
+
"PromptManager",
|
|
22
|
+
"ProjectValidator",
|
|
23
|
+
"ValidationResult",
|
|
24
|
+
"ValidationStatus",
|
|
25
|
+
]
|
sum/utils/django.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from sum.exceptions import VenvError
|
|
9
|
+
from sum.utils.environment import ExecutionMode, find_monorepo_root
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DjangoCommandExecutor:
|
|
13
|
+
"""Executes Django management commands."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
project_path: Path,
|
|
18
|
+
mode: ExecutionMode,
|
|
19
|
+
python_path: Path | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Initialize Django command executor.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
project_path: Path to the Django project (where manage.py is).
|
|
25
|
+
mode: Execution mode (MONOREPO or STANDALONE).
|
|
26
|
+
python_path: Optional explicit path to Python executable.
|
|
27
|
+
If not provided, looks for .venv/bin/python in project_path.
|
|
28
|
+
"""
|
|
29
|
+
self.project_path = project_path
|
|
30
|
+
self.mode = mode
|
|
31
|
+
self._python_path = python_path
|
|
32
|
+
|
|
33
|
+
def run_command(
|
|
34
|
+
self,
|
|
35
|
+
command: list[str],
|
|
36
|
+
env: Mapping[str, str] | None = None,
|
|
37
|
+
check: bool = True,
|
|
38
|
+
) -> subprocess.CompletedProcess[str]:
|
|
39
|
+
"""Run a Django management command.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
command: Command and arguments as a list (e.g., ["migrate", "--noinput"]).
|
|
43
|
+
env: Optional environment variable overrides.
|
|
44
|
+
check: Whether to raise on non-zero exit code.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Completed process result.
|
|
48
|
+
"""
|
|
49
|
+
python = self._get_python_executable()
|
|
50
|
+
full_command = [str(python), "manage.py", *command]
|
|
51
|
+
|
|
52
|
+
command_env = os.environ.copy()
|
|
53
|
+
if env:
|
|
54
|
+
command_env.update(env)
|
|
55
|
+
|
|
56
|
+
if self.mode is ExecutionMode.MONOREPO:
|
|
57
|
+
core_path = self._get_core_path()
|
|
58
|
+
existing = command_env.get("PYTHONPATH", "")
|
|
59
|
+
if existing:
|
|
60
|
+
command_env["PYTHONPATH"] = f"{existing}{os.pathsep}{core_path}"
|
|
61
|
+
else:
|
|
62
|
+
command_env["PYTHONPATH"] = str(core_path)
|
|
63
|
+
|
|
64
|
+
return subprocess.run(
|
|
65
|
+
full_command,
|
|
66
|
+
cwd=self.project_path,
|
|
67
|
+
env=command_env,
|
|
68
|
+
capture_output=True,
|
|
69
|
+
text=True,
|
|
70
|
+
check=check,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def _get_python_executable(self) -> Path:
|
|
74
|
+
"""Return the Python executable to use."""
|
|
75
|
+
# Use explicit path if provided
|
|
76
|
+
if self._python_path is not None:
|
|
77
|
+
if not self._python_path.exists():
|
|
78
|
+
raise VenvError(
|
|
79
|
+
f"Specified Python executable not found: {self._python_path}"
|
|
80
|
+
)
|
|
81
|
+
return self._python_path
|
|
82
|
+
|
|
83
|
+
# Fall back to .venv/bin/python in project
|
|
84
|
+
venv_python = self.project_path / ".venv" / "bin" / "python"
|
|
85
|
+
if not venv_python.exists():
|
|
86
|
+
raise VenvError(
|
|
87
|
+
f"Virtualenv not found at {self.project_path / '.venv'}. "
|
|
88
|
+
"Run 'sum init --full' or create manually with 'python -m venv .venv'"
|
|
89
|
+
)
|
|
90
|
+
return venv_python
|
|
91
|
+
|
|
92
|
+
def _get_core_path(self) -> Path:
|
|
93
|
+
"""Get the monorepo core path for PYTHONPATH injection."""
|
|
94
|
+
repo_root = find_monorepo_root(self.project_path)
|
|
95
|
+
if repo_root is not None:
|
|
96
|
+
return repo_root / "core"
|
|
97
|
+
raise ValueError("Cannot determine core path - not in monorepo")
|