plinth-cli 0.2.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 (49) hide show
  1. plinth/__init__.py +1 -0
  2. plinth/__main__.py +5 -0
  3. plinth/add.py +97 -0
  4. plinth/blueprints/ci/github/dot-github/workflows/ci.yaml.j2 +38 -0
  5. plinth/blueprints/ci/gitlab/dot-gitlab-ci.yml.j2 +36 -0
  6. plinth/blueprints/gitops/applicationset.yaml.j2 +46 -0
  7. plinth/blueprints/gitops/values.yaml.j2 +2 -0
  8. plinth/blueprints/repo/app/README.md.j2 +63 -0
  9. plinth/blueprints/repo/app/dagger.json.j2 +8 -0
  10. plinth/blueprints/repo/app/dot-dagger/dot-gitignore +3 -0
  11. plinth/blueprints/repo/app/dot-dagger/pyproject.toml +12 -0
  12. plinth/blueprints/repo/app/dot-dagger/src/ci/__init__.py +3 -0
  13. plinth/blueprints/repo/app/dot-dagger/src/ci/main.py.j2 +133 -0
  14. plinth/blueprints/repo/app/dot-gitignore +4 -0
  15. plinth/blueprints/repo/gitops/README.md.j2 +53 -0
  16. plinth/blueprints/repo/gitops/apps/project.yaml.j2 +15 -0
  17. plinth/blueprints/repo/gitops/bootstrap/root.yaml.j2 +20 -0
  18. plinth/blueprints/repo/gitops/charts/workload/Chart.yaml +5 -0
  19. plinth/blueprints/repo/gitops/charts/workload/templates/_helpers.tpl +13 -0
  20. plinth/blueprints/repo/gitops/charts/workload/templates/deployment.yaml +61 -0
  21. plinth/blueprints/repo/gitops/charts/workload/templates/service.yaml +20 -0
  22. plinth/blueprints/repo/gitops/charts/workload/values.yaml +9 -0
  23. plinth/blueprints/repo/gitops/scripts/bootstrap-local.sh.j2 +23 -0
  24. plinth/blueprints/workload/common/Dockerfile.j2 +14 -0
  25. plinth/blueprints/workload/common/app/__init__.py +0 -0
  26. plinth/blueprints/workload/common/dot-dockerignore +4 -0
  27. plinth/blueprints/workload/common/score.yaml.j2 +33 -0
  28. plinth/blueprints/workload/common/tests/__init__.py +0 -0
  29. plinth/blueprints/workload/modular-monolith/app/main.py.j2 +63 -0
  30. plinth/blueprints/workload/modular-monolith/app/modules/__init__.py +5 -0
  31. plinth/blueprints/workload/modular-monolith/app/modules/example.py +12 -0
  32. plinth/blueprints/workload/modular-monolith/tests/test_main.py.j2 +42 -0
  33. plinth/blueprints/workload/service/app/main.py.j2 +54 -0
  34. plinth/blueprints/workload/service/tests/test_main.py.j2 +41 -0
  35. plinth/blueprints/workload/worker/app/main.py.j2 +44 -0
  36. plinth/blueprints/workload/worker/tests/test_main.py.j2 +26 -0
  37. plinth/cli.py +185 -0
  38. plinth/config.py +51 -0
  39. plinth/loader.py +49 -0
  40. plinth/plan.py +70 -0
  41. plinth/render.py +119 -0
  42. plinth/schema.py +158 -0
  43. plinth/wizard.py +129 -0
  44. plinth/write.py +72 -0
  45. plinth_cli-0.2.0.dist-info/METADATA +174 -0
  46. plinth_cli-0.2.0.dist-info/RECORD +49 -0
  47. plinth_cli-0.2.0.dist-info/WHEEL +4 -0
  48. plinth_cli-0.2.0.dist-info/entry_points.txt +2 -0
  49. plinth_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
plinth/plan.py ADDED
@@ -0,0 +1,70 @@
1
+ """Turn a validated config into a deterministic generation plan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ from plinth.schema import AppKind, Layout, ProjectConfig
9
+
10
+ RepoRole = Literal["app", "gitops"]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class WorkloadPlan:
15
+ name: str
16
+ kind: AppKind
17
+ port: int | None # None for workers
18
+ source_dir: str # "" at repo root, or "apps/<name>/" in a monorepo
19
+
20
+ @property
21
+ def score_path(self) -> str:
22
+ return f"{self.source_dir}score.yaml"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class RepoPlan:
27
+ folder: str
28
+ role: RepoRole
29
+ url: str
30
+ workloads: tuple[WorkloadPlan, ...]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Plan:
35
+ config: ProjectConfig
36
+ repos: tuple[RepoPlan, ...]
37
+
38
+ @property
39
+ def gitops(self) -> RepoPlan:
40
+ return next(r for r in self.repos if r.role == "gitops")
41
+
42
+ @property
43
+ def app_repos(self) -> tuple[RepoPlan, ...]:
44
+ return tuple(r for r in self.repos if r.role == "app")
45
+
46
+ def workloads(self) -> list[tuple[RepoPlan, WorkloadPlan]]:
47
+ return [(r, w) for r in self.app_repos for w in r.workloads]
48
+
49
+
50
+ def build_plan(config: ProjectConfig) -> Plan:
51
+ project = config.project
52
+ base = project.git_base_url
53
+
54
+ def repo(folder: str, role: RepoRole, workloads: tuple[WorkloadPlan, ...]) -> RepoPlan:
55
+ return RepoPlan(folder, role, f"{base}/{folder}.git", workloads)
56
+
57
+ repos: list[RepoPlan] = []
58
+ if project.layout is Layout.MONOREPO:
59
+ workloads = tuple(
60
+ WorkloadPlan(a.name, a.kind, a.http_port, f"apps/{a.name}/")
61
+ for a in config.applications
62
+ )
63
+ repos.append(repo(f"{project.name}-app", "app", workloads))
64
+ else:
65
+ for a in config.applications:
66
+ workload = WorkloadPlan(a.name, a.kind, a.http_port, "")
67
+ repos.append(repo(f"{project.name}-{a.name}", "app", (workload,)))
68
+
69
+ repos.append(repo(f"{project.name}-gitops", "gitops", ()))
70
+ return Plan(config=config, repos=tuple(repos))
plinth/render.py ADDED
@@ -0,0 +1,119 @@
1
+ """Render blueprints into an in-memory file map."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError
6
+ from importlib.metadata import version as dist_version
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+ from jinja2 import Environment, FileSystemLoader, StrictUndefined
11
+
12
+ from plinth import config as defaults
13
+ from plinth.plan import Plan, RepoPlan
14
+
15
+ BLUEPRINTS_DIR = Path(__file__).resolve().parent / "blueprints"
16
+ TEMPLATE_SUFFIX = ".j2"
17
+ DOT_PREFIX = "dot-" # blueprint "dot-gitignore" renders as ".gitignore"
18
+ EXECUTABLE_SUFFIXES = (".sh",)
19
+
20
+ try:
21
+ GENERATOR_VERSION = dist_version("plinth-cli")
22
+ except PackageNotFoundError: # running from a bare checkout without install
23
+ GENERATOR_VERSION = "0.0.0+uninstalled"
24
+
25
+ FileMap = dict[str, bytes]
26
+
27
+
28
+ def make_env(blueprints_root: Path) -> Environment:
29
+ return Environment(
30
+ loader=FileSystemLoader(str(blueprints_root)),
31
+ undefined=StrictUndefined,
32
+ autoescape=False,
33
+ keep_trailing_newline=True,
34
+ trim_blocks=True,
35
+ lstrip_blocks=True,
36
+ )
37
+
38
+
39
+ def _output_path(rel: str) -> str:
40
+ parts = ["." + p[len(DOT_PREFIX) :] if p.startswith(DOT_PREFIX) else p for p in rel.split("/")]
41
+ out = "/".join(parts)
42
+ return out.removesuffix(TEMPLATE_SUFFIX)
43
+
44
+
45
+ def render_tree(
46
+ env: Environment, blueprints_root: Path, blueprint: str, context: dict, prefix: str
47
+ ) -> FileMap:
48
+ base = blueprints_root / blueprint
49
+ if not base.is_dir():
50
+ return {}
51
+ out: FileMap = {}
52
+ for src in sorted(p for p in base.rglob("*") if p.is_file() and "__pycache__" not in p.parts):
53
+ rel = src.relative_to(base).as_posix()
54
+ if rel.endswith(TEMPLATE_SUFFIX):
55
+ content = env.get_template(f"{blueprint}/{rel}").render(**context).encode("utf-8")
56
+ else:
57
+ content = src.read_bytes()
58
+ out[prefix + _output_path(rel)] = content
59
+ return out
60
+
61
+
62
+ def _render_one(env: Environment, template: str, context: dict) -> bytes:
63
+ return env.get_template(template).render(**context).encode("utf-8")
64
+
65
+
66
+ def _metadata(repo: RepoPlan, plan: Plan) -> bytes:
67
+ data = {
68
+ "generator": {"name": "plinth", "version": GENERATOR_VERSION},
69
+ "project": plan.config.project.name,
70
+ "repo": {"folder": repo.folder, "role": repo.role},
71
+ }
72
+ return yaml.safe_dump(data, sort_keys=True).encode("utf-8")
73
+
74
+
75
+ def render_plan(plan: Plan, blueprints_root: Path = BLUEPRINTS_DIR) -> FileMap:
76
+ env = make_env(blueprints_root)
77
+ project = plan.config.project
78
+ base_ctx = {
79
+ "config": plan.config,
80
+ "project": project,
81
+ "plan": plan,
82
+ "defaults": defaults,
83
+ "git_token_user": defaults.GIT_TOKEN_USER[project.git_provider.value],
84
+ }
85
+ files: FileMap = {}
86
+
87
+ for repo in plan.repos:
88
+ ctx = {**base_ctx, "repo": repo}
89
+ root = f"{repo.folder}/"
90
+ files.update(render_tree(env, blueprints_root, f"repo/{repo.role}", ctx, root))
91
+ files[root + defaults.REPO_METADATA_FILENAME] = _metadata(repo, plan)
92
+
93
+ if repo.role == "app":
94
+ files.update(
95
+ render_tree(env, blueprints_root, f"ci/{project.git_provider.value}", ctx, root)
96
+ )
97
+ for wl in repo.workloads:
98
+ wctx = {**ctx, "workload": wl}
99
+ for blueprint in ("workload/common", f"workload/{wl.kind.value}"):
100
+ files.update(
101
+ render_tree(env, blueprints_root, blueprint, wctx, root + wl.source_dir)
102
+ )
103
+ else:
104
+ for environment in plan.config.environments:
105
+ ectx = {**ctx, "environment": environment}
106
+ files[f"{root}apps/{environment}.yaml"] = _render_one(
107
+ env, "gitops/applicationset.yaml.j2", ectx
108
+ )
109
+ for app_repo, wl in plan.workloads():
110
+ wctx = {**ectx, "workload": wl, "app_repo": app_repo}
111
+ files[f"{root}environments/{environment}/{wl.name}/values.yaml"] = _render_one(
112
+ env, "gitops/values.yaml.j2", wctx
113
+ )
114
+
115
+ return files
116
+
117
+
118
+ def is_executable(rel: str) -> bool:
119
+ return rel.endswith(EXECUTABLE_SUFFIXES)
plinth/schema.py ADDED
@@ -0,0 +1,158 @@
1
+ """Project configuration schema (plinth.yaml)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from enum import StrEnum
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
9
+
10
+ from plinth import config as defaults
11
+
12
+ NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
13
+ GIT_BASE_URL_RE = re.compile(r"^https://[^\s/]+(?:/[^\s/]+)+$")
14
+
15
+
16
+ def validate_name(value: str, what: str) -> str:
17
+ if not NAME_RE.fullmatch(value):
18
+ raise ValueError(
19
+ f"{what} '{value}' must be lowercase kebab-case "
20
+ "(start with a letter; only a-z, 0-9, and single hyphens)"
21
+ )
22
+ if not defaults.NAME_MIN_LEN <= len(value) <= defaults.NAME_MAX_LEN:
23
+ raise ValueError(
24
+ f"{what} '{value}' must be {defaults.NAME_MIN_LEN}-{defaults.NAME_MAX_LEN} characters"
25
+ )
26
+ return value
27
+
28
+
29
+ class Architecture(StrEnum):
30
+ MODULAR_MONOLITH = "modular-monolith"
31
+ MICROSERVICES = "microservices"
32
+ HYBRID = "hybrid"
33
+
34
+
35
+ class Layout(StrEnum):
36
+ MONOREPO = "monorepo"
37
+ SPLIT_REPO = "split-repo"
38
+
39
+
40
+ class GitProvider(StrEnum):
41
+ GITHUB = "github"
42
+ GITLAB = "gitlab"
43
+ GENERIC = "generic"
44
+
45
+
46
+ class AppKind(StrEnum):
47
+ MODULAR_MONOLITH = "modular-monolith"
48
+ SERVICE = "service"
49
+ WORKER = "worker"
50
+
51
+
52
+ class StrictModel(BaseModel):
53
+ model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
54
+
55
+
56
+ class ProjectSpec(StrictModel):
57
+ name: str
58
+ architecture: Architecture
59
+ layout: Layout
60
+ git_provider: GitProvider = Field(alias="gitProvider")
61
+ git_base_url: str = Field(alias="gitBaseUrl")
62
+ registry: str
63
+
64
+ @field_validator("name")
65
+ @classmethod
66
+ def _name(cls, v: str) -> str:
67
+ return validate_name(v, "project.name")
68
+
69
+ @field_validator("registry")
70
+ @classmethod
71
+ def _registry(cls, v: str) -> str:
72
+ if "://" in v or v.endswith("/") or not v:
73
+ raise ValueError("registry must be host[/path] without scheme or trailing slash")
74
+ return v
75
+
76
+ @field_validator("git_base_url")
77
+ @classmethod
78
+ def _git_base_url(cls, v: str) -> str:
79
+ if not GIT_BASE_URL_RE.fullmatch(v):
80
+ raise ValueError(
81
+ "gitBaseUrl must look like https://host/org (no trailing slash), "
82
+ "e.g. https://github.com/acme"
83
+ )
84
+ return v
85
+
86
+
87
+ class ApplicationSpec(StrictModel):
88
+ name: str
89
+ kind: AppKind
90
+ port: int | None = Field(default=None, ge=1, le=65535)
91
+
92
+ @field_validator("name")
93
+ @classmethod
94
+ def _name(cls, v: str) -> str:
95
+ validate_name(v, "application name")
96
+ if v in defaults.RESERVED_APP_NAMES:
97
+ raise ValueError(f"application name '{v}' is reserved")
98
+ return v
99
+
100
+ @model_validator(mode="after")
101
+ def _worker_has_no_port(self) -> ApplicationSpec:
102
+ if self.kind is AppKind.WORKER and self.port is not None:
103
+ raise ValueError(f"application '{self.name}': workers have no HTTP port")
104
+ return self
105
+
106
+ @property
107
+ def http_port(self) -> int | None:
108
+ if self.kind is AppKind.WORKER:
109
+ return None
110
+ return self.port if self.port is not None else defaults.DEFAULT_PORT
111
+
112
+
113
+ class ProjectConfig(StrictModel):
114
+ project: ProjectSpec
115
+ applications: list[ApplicationSpec] = Field(min_length=1)
116
+ environments: list[str] = Field(default_factory=lambda: list(defaults.DEFAULT_ENVIRONMENTS))
117
+
118
+ @field_validator("environments")
119
+ @classmethod
120
+ def _envs(cls, v: list[str]) -> list[str]:
121
+ if not v:
122
+ raise ValueError("at least one environment is required")
123
+ for e in v:
124
+ validate_name(e, "environment")
125
+ if len(set(v)) != len(v):
126
+ raise ValueError("environment names must be unique")
127
+ if defaults.RELEASE_ENVIRONMENT not in v:
128
+ raise ValueError(
129
+ f"an environment named '{defaults.RELEASE_ENVIRONMENT}' is required (CI releases to it)"
130
+ )
131
+ return v
132
+
133
+ @model_validator(mode="after")
134
+ def _architecture_rules(self) -> ProjectConfig:
135
+ names = [a.name for a in self.applications]
136
+ dupes = sorted({n for n in names if names.count(n) > 1})
137
+ if dupes:
138
+ raise ValueError(f"duplicate application names: {dupes}")
139
+
140
+ monoliths = [a for a in self.applications if a.kind is AppKind.MODULAR_MONOLITH]
141
+ others = [a for a in self.applications if a.kind is not AppKind.MODULAR_MONOLITH]
142
+ arch = self.project.architecture
143
+
144
+ if arch is Architecture.MODULAR_MONOLITH and (len(monoliths) != 1 or others):
145
+ raise ValueError(
146
+ "architecture 'modular-monolith' requires exactly one application "
147
+ "of kind 'modular-monolith' and no others"
148
+ )
149
+ if arch is Architecture.MICROSERVICES and monoliths:
150
+ raise ValueError(
151
+ "architecture 'microservices' must not contain kind 'modular-monolith'"
152
+ )
153
+ if arch is Architecture.HYBRID and (len(monoliths) != 1 or not others):
154
+ raise ValueError(
155
+ "architecture 'hybrid' requires exactly one 'modular-monolith' "
156
+ "plus at least one 'service' or 'worker'"
157
+ )
158
+ return self
plinth/wizard.py ADDED
@@ -0,0 +1,129 @@
1
+ """Interactive questions for `plinth project`. Produces a config dict only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import questionary
6
+ from questionary import Choice
7
+
8
+ from plinth import config as defaults
9
+ from plinth.schema import NAME_RE, AppKind, Architecture, GitProvider, Layout
10
+
11
+
12
+ def _name_ok(value: str) -> bool | str:
13
+ if NAME_RE.fullmatch(value) and defaults.NAME_MIN_LEN <= len(value) <= defaults.NAME_MAX_LEN:
14
+ return True
15
+ return (
16
+ f"lowercase kebab-case, {defaults.NAME_MIN_LEN}-{defaults.NAME_MAX_LEN} chars, e.g. atlas"
17
+ )
18
+
19
+
20
+ def _port_ok(value: str) -> bool | str:
21
+ return True if value.isdigit() and 0 < int(value) < 65536 else "port must be 1-65535"
22
+
23
+
24
+ def _ask_port(question: str) -> int:
25
+ answer = questionary.text(question, default=str(defaults.DEFAULT_PORT), validate=_port_ok)
26
+ return int(answer.unsafe_ask())
27
+
28
+
29
+ def _ask_applications(architecture: str) -> list[dict]:
30
+ apps: list[dict] = []
31
+ if architecture in (Architecture.MODULAR_MONOLITH, Architecture.HYBRID):
32
+ name = questionary.text(
33
+ "Monolith application name:", default="api", validate=_name_ok
34
+ ).unsafe_ask()
35
+ apps.append(
36
+ {
37
+ "name": name,
38
+ "kind": AppKind.MODULAR_MONOLITH.value,
39
+ "port": _ask_port("Monolith HTTP port:"),
40
+ }
41
+ )
42
+ if architecture == Architecture.MODULAR_MONOLITH:
43
+ return apps
44
+
45
+ while True:
46
+ name = questionary.text(
47
+ "Service or worker name (leave empty to finish):",
48
+ validate=lambda v: True if v == "" else _name_ok(v),
49
+ ).unsafe_ask()
50
+ if name == "":
51
+ if any(a["kind"] != AppKind.MODULAR_MONOLITH for a in apps):
52
+ return apps
53
+ questionary.print("At least one service or worker is required.", style="bold")
54
+ continue
55
+ kind = questionary.select(
56
+ f"Kind of '{name}':",
57
+ choices=[
58
+ Choice("service (HTTP, gets a port and health check)", AppKind.SERVICE.value),
59
+ Choice("worker (no HTTP port)", AppKind.WORKER.value),
60
+ ],
61
+ ).unsafe_ask()
62
+ app = {"name": name, "kind": kind}
63
+ if kind == AppKind.SERVICE:
64
+ app["port"] = _ask_port("Port:")
65
+ apps.append(app)
66
+
67
+
68
+ def run_wizard() -> dict:
69
+ name = questionary.text("Project name:", validate=_name_ok).unsafe_ask()
70
+
71
+ architecture = questionary.select(
72
+ "Application architecture:",
73
+ choices=[
74
+ Choice(
75
+ "modular-monolith (one process, one image, internal modules)",
76
+ Architecture.MODULAR_MONOLITH.value,
77
+ ),
78
+ Choice(
79
+ "microservices (several independently built apps)",
80
+ Architecture.MICROSERVICES.value,
81
+ ),
82
+ Choice(
83
+ "hybrid (one monolith plus independent services/workers)",
84
+ Architecture.HYBRID.value,
85
+ ),
86
+ ],
87
+ ).unsafe_ask()
88
+
89
+ layout = questionary.select(
90
+ "Repository layout:",
91
+ choices=[
92
+ Choice("monorepo (all apps in <project>-app)", Layout.MONOREPO.value),
93
+ Choice("split-repo (one repo per app)", Layout.SPLIT_REPO.value),
94
+ ],
95
+ ).unsafe_ask()
96
+
97
+ provider = questionary.select(
98
+ "Git provider (decides which CI file is generated):",
99
+ choices=[Choice(p.value, p.value) for p in GitProvider],
100
+ ).unsafe_ask()
101
+
102
+ git_base_url = questionary.text(
103
+ "Git base URL the repos will live under (https://host/org):",
104
+ default=defaults.DEFAULT_GIT_BASE_URL[provider],
105
+ ).unsafe_ask()
106
+
107
+ registry = questionary.text(
108
+ "Container registry (host/path, no scheme):", default=defaults.DEFAULT_REGISTRY
109
+ ).unsafe_ask()
110
+
111
+ applications = _ask_applications(architecture)
112
+
113
+ envs = questionary.text(
114
+ f"Environments (comma separated, must include {defaults.RELEASE_ENVIRONMENT}):",
115
+ default=",".join(defaults.DEFAULT_ENVIRONMENTS),
116
+ ).unsafe_ask()
117
+
118
+ return {
119
+ "project": {
120
+ "name": name,
121
+ "architecture": architecture,
122
+ "layout": layout,
123
+ "gitProvider": provider,
124
+ "gitBaseUrl": git_base_url,
125
+ "registry": registry,
126
+ },
127
+ "applications": applications,
128
+ "environments": [e.strip() for e in envs.split(",") if e.strip()],
129
+ }
plinth/write.py ADDED
@@ -0,0 +1,72 @@
1
+ """Write rendered files to disk and initialise git repositories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ from plinth.render import FileMap, is_executable
12
+
13
+ INITIAL_COMMIT_MESSAGE = "chore: initial commit"
14
+
15
+
16
+ class OutputExistsError(Exception):
17
+ pass
18
+
19
+
20
+ def write_tree(root: Path, files: FileMap) -> None:
21
+ for rel in sorted(files):
22
+ dest = root / rel
23
+ dest.parent.mkdir(parents=True, exist_ok=True)
24
+ dest.write_bytes(files[rel])
25
+ if is_executable(rel):
26
+ dest.chmod(0o755)
27
+
28
+
29
+ def write_project(
30
+ target: Path, files: FileMap, repo_urls: dict[str, str], init_git: bool
31
+ ) -> list[str]:
32
+ """Write a whole project atomically: everything lands in a temp folder that is
33
+ renamed into place only once it is complete. Returns warnings."""
34
+ if target.exists():
35
+ raise OutputExistsError(f"refusing to write into existing path: {target}")
36
+ target.parent.mkdir(parents=True, exist_ok=True)
37
+ tmp = Path(tempfile.mkdtemp(prefix=f".{target.name}.", suffix=".plinth-tmp", dir=target.parent))
38
+ try:
39
+ write_tree(tmp, files)
40
+ warnings = init_repos(tmp, repo_urls) if init_git else []
41
+ if target.exists():
42
+ raise OutputExistsError(f"refusing to write into existing path: {target}")
43
+ os.rename(tmp, target)
44
+ except BaseException:
45
+ shutil.rmtree(tmp, ignore_errors=True)
46
+ raise
47
+ return warnings
48
+
49
+
50
+ def init_repos(root: Path, repo_urls: dict[str, str]) -> list[str]:
51
+ if shutil.which("git") is None:
52
+ return ["git not found; repositories were not initialised"]
53
+ warnings = []
54
+ for folder, url in repo_urls.items():
55
+ warning = init_repo(root / folder, url)
56
+ if warning:
57
+ warnings.append(warning)
58
+ return warnings
59
+
60
+
61
+ def init_repo(path: Path, url: str) -> str | None:
62
+ def git(*args: str) -> subprocess.CompletedProcess:
63
+ return subprocess.run(["git", *args], cwd=path, capture_output=True, text=True, check=False)
64
+
65
+ for args in (("init", "-q", "-b", "main"), ("remote", "add", "origin", url), ("add", "-A")):
66
+ result = git(*args)
67
+ if result.returncode != 0:
68
+ return f"{path.name}: git {args[0]} failed: {result.stderr.strip()}"
69
+ result = git("commit", "-q", "-m", INITIAL_COMMIT_MESSAGE)
70
+ if result.returncode != 0:
71
+ return f"{path.name}: initialised but not committed: {result.stderr.strip()}"
72
+ return None