sum-cli 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. sum/__init__.py +1 -0
  2. sum/boilerplate/.env.example +124 -0
  3. sum/boilerplate/.gitea/workflows/ci.yml +33 -0
  4. sum/boilerplate/.gitea/workflows/deploy-production.yml +98 -0
  5. sum/boilerplate/.gitea/workflows/deploy-staging.yml +113 -0
  6. sum/boilerplate/.github/workflows/ci.yml +36 -0
  7. sum/boilerplate/.github/workflows/deploy-production.yml +102 -0
  8. sum/boilerplate/.github/workflows/deploy-staging.yml +115 -0
  9. sum/boilerplate/.gitignore +45 -0
  10. sum/boilerplate/README.md +259 -0
  11. sum/boilerplate/manage.py +34 -0
  12. sum/boilerplate/project_name/__init__.py +5 -0
  13. sum/boilerplate/project_name/home/__init__.py +5 -0
  14. sum/boilerplate/project_name/home/apps.py +20 -0
  15. sum/boilerplate/project_name/home/management/__init__.py +0 -0
  16. sum/boilerplate/project_name/home/management/commands/__init__.py +0 -0
  17. sum/boilerplate/project_name/home/management/commands/populate_demo_content.py +644 -0
  18. sum/boilerplate/project_name/home/management/commands/seed.py +129 -0
  19. sum/boilerplate/project_name/home/management/commands/seed_showroom.py +1661 -0
  20. sum/boilerplate/project_name/home/migrations/__init__.py +3 -0
  21. sum/boilerplate/project_name/home/models.py +13 -0
  22. sum/boilerplate/project_name/settings/__init__.py +5 -0
  23. sum/boilerplate/project_name/settings/base.py +348 -0
  24. sum/boilerplate/project_name/settings/local.py +78 -0
  25. sum/boilerplate/project_name/settings/production.py +106 -0
  26. sum/boilerplate/project_name/urls.py +33 -0
  27. sum/boilerplate/project_name/wsgi.py +16 -0
  28. sum/boilerplate/pytest.ini +5 -0
  29. sum/boilerplate/requirements.txt +25 -0
  30. sum/boilerplate/static/client/.gitkeep +3 -0
  31. sum/boilerplate/templates/overrides/.gitkeep +3 -0
  32. sum/boilerplate/tests/__init__.py +3 -0
  33. sum/boilerplate/tests/test_health.py +51 -0
  34. sum/cli.py +42 -0
  35. sum/commands/__init__.py +10 -0
  36. sum/commands/backup.py +308 -0
  37. sum/commands/check.py +128 -0
  38. sum/commands/init.py +265 -0
  39. sum/commands/promote.py +758 -0
  40. sum/commands/run.py +96 -0
  41. sum/commands/themes.py +56 -0
  42. sum/commands/update.py +301 -0
  43. sum/config.py +61 -0
  44. sum/docs/USER_GUIDE.md +663 -0
  45. sum/exceptions.py +45 -0
  46. sum/setup/__init__.py +17 -0
  47. sum/setup/auth.py +184 -0
  48. sum/setup/database.py +58 -0
  49. sum/setup/deps.py +73 -0
  50. sum/setup/git_ops.py +463 -0
  51. sum/setup/infrastructure.py +576 -0
  52. sum/setup/orchestrator.py +354 -0
  53. sum/setup/remote_themes.py +371 -0
  54. sum/setup/scaffold.py +500 -0
  55. sum/setup/seed.py +110 -0
  56. sum/setup/site_orchestrator.py +441 -0
  57. sum/setup/venv.py +89 -0
  58. sum/system_config.py +330 -0
  59. sum/themes_registry.py +180 -0
  60. sum/utils/__init__.py +25 -0
  61. sum/utils/django.py +97 -0
  62. sum/utils/environment.py +76 -0
  63. sum/utils/output.py +78 -0
  64. sum/utils/project.py +110 -0
  65. sum/utils/prompts.py +36 -0
  66. sum/utils/validation.py +313 -0
  67. sum_cli-3.0.0.dist-info/METADATA +127 -0
  68. sum_cli-3.0.0.dist-info/RECORD +72 -0
  69. sum_cli-3.0.0.dist-info/WHEEL +5 -0
  70. sum_cli-3.0.0.dist-info/entry_points.txt +2 -0
  71. sum_cli-3.0.0.dist-info/licenses/LICENSE +29 -0
  72. sum_cli-3.0.0.dist-info/top_level.txt +1 -0
sum/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")