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/setup/git_ops.py ADDED
@@ -0,0 +1,463 @@
1
+ """Git operations for SUM Platform sites.
2
+
3
+ Handles git init, initial commit, and remote repository creation.
4
+ Supports GitHub (via gh CLI) and Gitea (via REST API).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ from abc import ABC, abstractmethod
13
+ from pathlib import Path
14
+
15
+ from sum.exceptions import SetupError
16
+ from sum.system_config import AgencyConfig, get_system_config
17
+ from sum.utils.output import OutputFormatter
18
+
19
+ # =============================================================================
20
+ # Git Provider Abstraction
21
+ # =============================================================================
22
+
23
+
24
+ class GitProvider(ABC):
25
+ """Abstract base class for git hosting providers."""
26
+
27
+ @abstractmethod
28
+ def is_available(self) -> bool:
29
+ """Check if the provider is configured and accessible."""
30
+ ...
31
+
32
+ @abstractmethod
33
+ def create_repo(self, app_dir: Path, repo_name: str) -> str:
34
+ """Create a remote repository and push the code.
35
+
36
+ Args:
37
+ app_dir: Path to the local git repository.
38
+ repo_name: Name for the remote repository.
39
+
40
+ Returns:
41
+ URL of the created repository.
42
+
43
+ Raises:
44
+ SetupError: If repo creation or push fails.
45
+ """
46
+ ...
47
+
48
+ @abstractmethod
49
+ def get_repo_url(self, org: str, repo_name: str) -> str:
50
+ """Get the HTTPS URL for a repository."""
51
+ ...
52
+
53
+ @abstractmethod
54
+ def get_clone_url(self, org: str, repo_name: str) -> str:
55
+ """Get the SSH clone URL for a repository."""
56
+ ...
57
+
58
+ @property
59
+ @abstractmethod
60
+ def name(self) -> str:
61
+ """Human-readable provider name."""
62
+ ...
63
+
64
+
65
+ class GitHubProvider(GitProvider):
66
+ """GitHub provider using gh CLI."""
67
+
68
+ def __init__(self, config: AgencyConfig) -> None:
69
+ self.org = config.github_org or ""
70
+
71
+ @property
72
+ def name(self) -> str:
73
+ return "GitHub"
74
+
75
+ def is_available(self) -> bool:
76
+ """Check if gh CLI is available and authenticated."""
77
+ if not shutil.which("gh"):
78
+ return False
79
+
80
+ try:
81
+ result = subprocess.run(
82
+ ["gh", "auth", "status"],
83
+ capture_output=True,
84
+ text=True,
85
+ )
86
+ return result.returncode == 0
87
+ except subprocess.SubprocessError:
88
+ return False
89
+
90
+ def get_repo_url(self, org: str, repo_name: str) -> str:
91
+ return f"https://github.com/{org}/{repo_name}"
92
+
93
+ def get_clone_url(self, org: str, repo_name: str) -> str:
94
+ return f"git@github.com:{org}/{repo_name}.git"
95
+
96
+ def create_repo(self, app_dir: Path, repo_name: str) -> str:
97
+ """Create a private GitHub repository and push the code."""
98
+ if not self.is_available():
99
+ raise SetupError(
100
+ "GitHub CLI (gh) is not available or not authenticated. "
101
+ "Run 'gh auth login' to authenticate, or use --no-git to skip."
102
+ )
103
+
104
+ try:
105
+ subprocess.run(
106
+ [
107
+ "gh",
108
+ "repo",
109
+ "create",
110
+ f"{self.org}/{repo_name}",
111
+ "--private",
112
+ "--source",
113
+ str(app_dir),
114
+ "--push",
115
+ ],
116
+ cwd=app_dir,
117
+ check=True,
118
+ capture_output=True,
119
+ text=True,
120
+ )
121
+
122
+ return self.get_repo_url(self.org, repo_name)
123
+
124
+ except subprocess.CalledProcessError as exc:
125
+ stderr = exc.stderr or ""
126
+ if "already exists" in stderr.lower():
127
+ OutputFormatter.warning(
128
+ f"Repository {self.org}/{repo_name} already exists, "
129
+ "attempting to push..."
130
+ )
131
+ return self._push_to_existing_repo(app_dir, repo_name)
132
+ raise SetupError(f"Failed to create GitHub repository: {stderr}") from exc
133
+
134
+ def _push_to_existing_repo(self, app_dir: Path, repo_name: str) -> str:
135
+ """Push to an existing GitHub repository."""
136
+ repo_url = self.get_repo_url(self.org, repo_name)
137
+
138
+ try:
139
+ result = subprocess.run(
140
+ ["git", "remote", "get-url", "origin"],
141
+ cwd=app_dir,
142
+ capture_output=True,
143
+ text=True,
144
+ )
145
+
146
+ if result.returncode != 0:
147
+ subprocess.run(
148
+ [
149
+ "git",
150
+ "remote",
151
+ "add",
152
+ "origin",
153
+ self.get_clone_url(self.org, repo_name),
154
+ ],
155
+ cwd=app_dir,
156
+ check=True,
157
+ capture_output=True,
158
+ )
159
+
160
+ subprocess.run(
161
+ ["git", "push", "-u", "origin", "main"],
162
+ cwd=app_dir,
163
+ check=True,
164
+ capture_output=True,
165
+ )
166
+
167
+ return repo_url
168
+
169
+ except subprocess.CalledProcessError as exc:
170
+ raise SetupError(
171
+ f"Failed to push to existing repository: {exc.stderr}"
172
+ ) from exc
173
+
174
+
175
+ class GiteaProvider(GitProvider):
176
+ """Gitea provider using REST API."""
177
+
178
+ def __init__(self, config: AgencyConfig) -> None:
179
+ self.org = config.gitea_org or ""
180
+ self.base_url = (config.gitea_url or "").rstrip("/")
181
+ self.token_env = config.gitea_token_env
182
+
183
+ @property
184
+ def name(self) -> str:
185
+ return "Gitea"
186
+
187
+ def _get_token(self) -> str | None:
188
+ """Get the API token from environment."""
189
+ return os.environ.get(self.token_env)
190
+
191
+ def is_available(self) -> bool:
192
+ """Check if Gitea is configured with a valid token."""
193
+ if not self.base_url or not self.org:
194
+ return False
195
+ return self._get_token() is not None
196
+
197
+ def get_repo_url(self, org: str, repo_name: str) -> str:
198
+ return f"{self.base_url}/{org}/{repo_name}"
199
+
200
+ def get_clone_url(self, org: str, repo_name: str) -> str:
201
+ # Extract host from base_url for SSH
202
+ # e.g., https://gitea.example.com -> gitea.example.com
203
+ from urllib.parse import urlparse
204
+
205
+ parsed = urlparse(self.base_url)
206
+ host = parsed.netloc
207
+ return f"git@{host}:{org}/{repo_name}.git"
208
+
209
+ def create_repo(self, app_dir: Path, repo_name: str) -> str:
210
+ """Create a private Gitea repository and push the code."""
211
+ token = self._get_token()
212
+ if not token:
213
+ raise SetupError(
214
+ f"Gitea API token not found. Set the {self.token_env} environment "
215
+ "variable, or use --no-git to skip repository creation."
216
+ )
217
+
218
+ try:
219
+ import httpx
220
+ except ImportError:
221
+ raise SetupError(
222
+ "httpx is required for Gitea support. "
223
+ "Install with: pip install sum-cli[gitea]"
224
+ ) from None
225
+
226
+ # Create repository via API
227
+ api_url = f"{self.base_url}/api/v1/orgs/{self.org}/repos"
228
+ headers = {
229
+ "Authorization": f"token {token}",
230
+ "Content-Type": "application/json",
231
+ }
232
+ payload = {
233
+ "name": repo_name,
234
+ "private": True,
235
+ "auto_init": False,
236
+ }
237
+
238
+ try:
239
+ response = httpx.post(api_url, json=payload, headers=headers, timeout=30.0)
240
+
241
+ if response.status_code == 409:
242
+ # Repository already exists
243
+ OutputFormatter.warning(
244
+ f"Repository {self.org}/{repo_name} already exists, "
245
+ "attempting to push..."
246
+ )
247
+ return self._push_to_existing_repo(app_dir, repo_name)
248
+
249
+ if response.status_code not in (200, 201):
250
+ error_msg = response.text
251
+ try:
252
+ error_data = response.json()
253
+ except ValueError:
254
+ # Response body is not JSON; fall back to raw text message.
255
+ pass
256
+ else:
257
+ error_msg = error_data.get("message", response.text)
258
+ raise SetupError(
259
+ f"Failed to create Gitea repository: {error_msg} "
260
+ f"(HTTP {response.status_code})"
261
+ )
262
+
263
+ # Add remote and push
264
+ return self._setup_remote_and_push(app_dir, repo_name)
265
+
266
+ except httpx.RequestError as exc:
267
+ raise SetupError(
268
+ f"Failed to connect to Gitea at {self.base_url}: {exc}"
269
+ ) from exc
270
+
271
+ def _setup_remote_and_push(self, app_dir: Path, repo_name: str) -> str:
272
+ """Set up git remote and push to Gitea."""
273
+ clone_url = self.get_clone_url(self.org, repo_name)
274
+
275
+ try:
276
+ # Add remote
277
+ subprocess.run(
278
+ ["git", "remote", "add", "origin", clone_url],
279
+ cwd=app_dir,
280
+ check=True,
281
+ capture_output=True,
282
+ )
283
+
284
+ # Push
285
+ subprocess.run(
286
+ ["git", "push", "-u", "origin", "main"],
287
+ cwd=app_dir,
288
+ check=True,
289
+ capture_output=True,
290
+ )
291
+
292
+ return self.get_repo_url(self.org, repo_name)
293
+
294
+ except subprocess.CalledProcessError as exc:
295
+ raise SetupError(f"Failed to push to Gitea: {exc.stderr}") from exc
296
+
297
+ def _push_to_existing_repo(self, app_dir: Path, repo_name: str) -> str:
298
+ """Push to an existing Gitea repository."""
299
+ try:
300
+ result = subprocess.run(
301
+ ["git", "remote", "get-url", "origin"],
302
+ cwd=app_dir,
303
+ capture_output=True,
304
+ text=True,
305
+ )
306
+
307
+ if result.returncode != 0:
308
+ subprocess.run(
309
+ [
310
+ "git",
311
+ "remote",
312
+ "add",
313
+ "origin",
314
+ self.get_clone_url(self.org, repo_name),
315
+ ],
316
+ cwd=app_dir,
317
+ check=True,
318
+ capture_output=True,
319
+ )
320
+
321
+ subprocess.run(
322
+ ["git", "push", "-u", "origin", "main"],
323
+ cwd=app_dir,
324
+ check=True,
325
+ capture_output=True,
326
+ )
327
+
328
+ return self.get_repo_url(self.org, repo_name)
329
+
330
+ except subprocess.CalledProcessError as exc:
331
+ raise SetupError(
332
+ f"Failed to push to existing repository: {exc.stderr}"
333
+ ) from exc
334
+
335
+
336
+ # =============================================================================
337
+ # Provider Factory
338
+ # =============================================================================
339
+
340
+
341
+ def get_git_provider(config: AgencyConfig | None = None) -> GitProvider:
342
+ """Get the configured git provider instance.
343
+
344
+ Args:
345
+ config: Optional AgencyConfig. If not provided, loads from system config.
346
+
347
+ Returns:
348
+ GitProvider instance for the configured provider.
349
+ """
350
+ if config is None:
351
+ config = get_system_config().agency
352
+
353
+ if config.git_provider == "gitea":
354
+ return GiteaProvider(config)
355
+ return GitHubProvider(config)
356
+
357
+
358
+ # =============================================================================
359
+ # Public API
360
+ # =============================================================================
361
+
362
+
363
+ def init_git_repo(app_dir: Path) -> None:
364
+ """Initialize a git repository in the app directory."""
365
+ try:
366
+ subprocess.run(
367
+ ["git", "init"],
368
+ cwd=app_dir,
369
+ check=True,
370
+ capture_output=True,
371
+ )
372
+ except subprocess.CalledProcessError as exc:
373
+ raise SetupError(f"Failed to initialize git repository: {exc.stderr}") from exc
374
+
375
+
376
+ def create_initial_commit(
377
+ app_dir: Path, message: str = "Initial scaffold from sum-platform init"
378
+ ) -> None:
379
+ """Create the initial git commit with all files.
380
+
381
+ Uses the system's git configuration for author identity.
382
+ """
383
+ try:
384
+ subprocess.run(
385
+ ["git", "add", "-A"],
386
+ cwd=app_dir,
387
+ check=True,
388
+ capture_output=True,
389
+ )
390
+
391
+ subprocess.run(
392
+ ["git", "commit", "-m", message],
393
+ cwd=app_dir,
394
+ check=True,
395
+ capture_output=True,
396
+ )
397
+ except subprocess.CalledProcessError as exc:
398
+ raise SetupError(f"Failed to create initial commit: {exc.stderr}") from exc
399
+
400
+
401
+ def setup_git_for_site(
402
+ app_dir: Path,
403
+ site_slug: str,
404
+ skip_remote: bool = False,
405
+ ) -> str | None:
406
+ """Set up git repository for a new site.
407
+
408
+ Args:
409
+ app_dir: Path to the app directory.
410
+ site_slug: The site slug (used as repo name).
411
+ skip_remote: If True, only do local git init, skip remote repo creation.
412
+
413
+ Returns:
414
+ Remote repo URL if created, None if skipped.
415
+ """
416
+ # Initialize git repo
417
+ init_git_repo(app_dir)
418
+
419
+ # Create initial commit
420
+ create_initial_commit(app_dir)
421
+
422
+ if skip_remote:
423
+ OutputFormatter.info("Skipping remote repository creation (--no-git)")
424
+ return None
425
+
426
+ # Get the configured provider
427
+ provider = get_git_provider()
428
+
429
+ # Check if provider is available
430
+ if not provider.is_available():
431
+ OutputFormatter.warning(
432
+ f"{provider.name} is not available or not configured. "
433
+ "Skipping repository creation."
434
+ )
435
+ return None
436
+
437
+ # Create remote repo
438
+ repo_url = provider.create_repo(app_dir, site_slug)
439
+ return repo_url
440
+
441
+
442
+ # =============================================================================
443
+ # Backward Compatibility
444
+ # =============================================================================
445
+
446
+
447
+ # Keep old function names for backward compatibility
448
+ def check_gh_cli() -> bool:
449
+ """Check if gh CLI is available and authenticated.
450
+
451
+ Deprecated: Use get_git_provider().is_available() instead.
452
+ """
453
+ provider = GitHubProvider(get_system_config().agency)
454
+ return provider.is_available()
455
+
456
+
457
+ def create_github_repo(app_dir: Path, repo_name: str) -> str:
458
+ """Create a private GitHub repository and push the code.
459
+
460
+ Deprecated: Use get_git_provider().create_repo() instead.
461
+ """
462
+ provider = GitHubProvider(get_system_config().agency)
463
+ return provider.create_repo(app_dir, repo_name)