quoll-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 (191) hide show
  1. quoll/__init__.py +0 -0
  2. quoll/cd/__init__.py +1 -0
  3. quoll/cd/config.py +75 -0
  4. quoll/ci/__init__.py +0 -0
  5. quoll/ci/config.py +213 -0
  6. quoll/ci/custom.py +32 -0
  7. quoll/ci/detect.py +139 -0
  8. quoll/ci/steps.py +85 -0
  9. quoll/ci/tools.py +104 -0
  10. quoll/commands/__init__.py +0 -0
  11. quoll/commands/_common.py +145 -0
  12. quoll/commands/_workspace.py +83 -0
  13. quoll/commands/add.py +104 -0
  14. quoll/commands/add_secret.py +75 -0
  15. quoll/commands/cd.py +214 -0
  16. quoll/commands/ci.py +521 -0
  17. quoll/commands/docker.py +244 -0
  18. quoll/commands/k8s.py +211 -0
  19. quoll/commands/new.py +337 -0
  20. quoll/commands/remove.py +79 -0
  21. quoll/config/__init__.py +0 -0
  22. quoll/config/quoll_toml.py +196 -0
  23. quoll/config/settings.py +22 -0
  24. quoll/config/store.py +39 -0
  25. quoll/console.py +3 -0
  26. quoll/docker/__init__.py +1 -0
  27. quoll/docker/config.py +64 -0
  28. quoll/docker/detect.py +41 -0
  29. quoll/docker/render.py +162 -0
  30. quoll/files.py +64 -0
  31. quoll/k8s/__init__.py +0 -0
  32. quoll/k8s/chart/templates/_helpers.tpl +38 -0
  33. quoll/k8s/chart/templates/deployment.yaml +93 -0
  34. quoll/k8s/chart/templates/hpa.yaml +24 -0
  35. quoll/k8s/chart/templates/ingress.yaml +35 -0
  36. quoll/k8s/chart/templates/pdb.yaml +16 -0
  37. quoll/k8s/chart/templates/service.yaml +18 -0
  38. quoll/k8s/chart/templates/serviceaccount.yaml +10 -0
  39. quoll/k8s/ci.py +39 -0
  40. quoll/k8s/config.py +97 -0
  41. quoll/k8s/deploy.py +22 -0
  42. quoll/k8s/detect.py +32 -0
  43. quoll/k8s/render.py +94 -0
  44. quoll/languages/__init__.py +121 -0
  45. quoll/languages/base.py +350 -0
  46. quoll/languages/cpp/Dockerfile.j2 +37 -0
  47. quoll/languages/cpp/__init__.py +17 -0
  48. quoll/languages/cpp/ci.py +192 -0
  49. quoll/languages/cpp/detect.py +84 -0
  50. quoll/languages/cpp/docker.py +47 -0
  51. quoll/languages/dotnet/Dockerfile.j2 +21 -0
  52. quoll/languages/dotnet/__init__.py +17 -0
  53. quoll/languages/dotnet/ci.py +88 -0
  54. quoll/languages/dotnet/detect.py +53 -0
  55. quoll/languages/dotnet/docker.py +47 -0
  56. quoll/languages/go/Dockerfile.j2 +25 -0
  57. quoll/languages/go/__init__.py +17 -0
  58. quoll/languages/go/ci.py +126 -0
  59. quoll/languages/go/detect.py +30 -0
  60. quoll/languages/go/docker.py +40 -0
  61. quoll/languages/java/Dockerfile.j2 +28 -0
  62. quoll/languages/java/__init__.py +20 -0
  63. quoll/languages/java/ci.py +163 -0
  64. quoll/languages/java/detect.py +77 -0
  65. quoll/languages/java/docker.py +45 -0
  66. quoll/languages/java/frameworks/__init__.py +1 -0
  67. quoll/languages/java/frameworks/spring_boot/Dockerfile.j2 +18 -0
  68. quoll/languages/java/frameworks/spring_boot/__init__.py +10 -0
  69. quoll/languages/java/frameworks/spring_boot/detect.py +9 -0
  70. quoll/languages/java/frameworks/spring_boot/docker.py +19 -0
  71. quoll/languages/java/frameworks/spring_boot/services.py +24 -0
  72. quoll/languages/node/Dockerfile.j2 +98 -0
  73. quoll/languages/node/__init__.py +29 -0
  74. quoll/languages/node/ci.py +200 -0
  75. quoll/languages/node/detect.py +164 -0
  76. quoll/languages/node/docker.py +141 -0
  77. quoll/languages/node/frameworks/__init__.py +1 -0
  78. quoll/languages/node/frameworks/angular/__init__.py +18 -0
  79. quoll/languages/node/frameworks/angular/ci.py +34 -0
  80. quoll/languages/node/frameworks/angular/detect.py +20 -0
  81. quoll/languages/node/frameworks/angular/docker.py +31 -0
  82. quoll/languages/node/frameworks/angular/starter.py +74 -0
  83. quoll/languages/node/frameworks/astro/__init__.py +10 -0
  84. quoll/languages/node/frameworks/astro/ci.py +17 -0
  85. quoll/languages/node/frameworks/astro/detect.py +14 -0
  86. quoll/languages/node/frameworks/astro/docker.py +17 -0
  87. quoll/languages/node/frameworks/nestjs/__init__.py +7 -0
  88. quoll/languages/node/frameworks/nestjs/detect.py +9 -0
  89. quoll/languages/node/frameworks/nestjs/docker.py +10 -0
  90. quoll/languages/node/frameworks/nextjs/Dockerfile.j2 +14 -0
  91. quoll/languages/node/frameworks/nextjs/__init__.py +8 -0
  92. quoll/languages/node/frameworks/nextjs/ci.py +29 -0
  93. quoll/languages/node/frameworks/nextjs/detect.py +13 -0
  94. quoll/languages/node/frameworks/nextjs/docker.py +41 -0
  95. quoll/languages/node/frameworks/nuxt/__init__.py +10 -0
  96. quoll/languages/node/frameworks/nuxt/ci.py +17 -0
  97. quoll/languages/node/frameworks/nuxt/detect.py +17 -0
  98. quoll/languages/node/frameworks/nuxt/docker.py +24 -0
  99. quoll/languages/node/frameworks/sveltekit/__init__.py +15 -0
  100. quoll/languages/node/frameworks/sveltekit/ci.py +15 -0
  101. quoll/languages/node/frameworks/sveltekit/detect.py +18 -0
  102. quoll/languages/node/frameworks/sveltekit/docker.py +18 -0
  103. quoll/languages/node/starter.py +42 -0
  104. quoll/languages/node/templates/express/README.md +11 -0
  105. quoll/languages/node/templates/express/env.example +2 -0
  106. quoll/languages/node/templates/express/eslint.config.js +15 -0
  107. quoll/languages/node/templates/express/gitignore +4 -0
  108. quoll/languages/node/templates/express/nvmrc +1 -0
  109. quoll/languages/node/templates/express/package.json +16 -0
  110. quoll/languages/node/templates/express/prettierignore +6 -0
  111. quoll/languages/node/templates/express/src/app.ts +27 -0
  112. quoll/languages/node/templates/express/src/server.ts +11 -0
  113. quoll/languages/node/templates/express/test/app.test.ts +29 -0
  114. quoll/languages/node/templates/express/tsconfig.build.json +8 -0
  115. quoll/languages/node/templates/express/tsconfig.json +14 -0
  116. quoll/languages/node/templates/express/vitest.config.ts +12 -0
  117. quoll/languages/php/Dockerfile.j2 +40 -0
  118. quoll/languages/php/__init__.py +19 -0
  119. quoll/languages/php/ci.py +147 -0
  120. quoll/languages/php/detect.py +75 -0
  121. quoll/languages/php/docker.py +59 -0
  122. quoll/languages/php/frameworks/__init__.py +1 -0
  123. quoll/languages/php/frameworks/laravel/__init__.py +17 -0
  124. quoll/languages/php/frameworks/laravel/ci.py +24 -0
  125. quoll/languages/php/frameworks/laravel/detect.py +13 -0
  126. quoll/languages/php/frameworks/laravel/docker.py +29 -0
  127. quoll/languages/php/frameworks/laravel/services.py +25 -0
  128. quoll/languages/php/frameworks/symfony/__init__.py +15 -0
  129. quoll/languages/php/frameworks/symfony/ci.py +22 -0
  130. quoll/languages/php/frameworks/symfony/detect.py +17 -0
  131. quoll/languages/php/frameworks/symfony/docker.py +25 -0
  132. quoll/languages/python/Dockerfile.j2 +61 -0
  133. quoll/languages/python/__init__.py +19 -0
  134. quoll/languages/python/ci.py +157 -0
  135. quoll/languages/python/detect.py +175 -0
  136. quoll/languages/python/docker.py +80 -0
  137. quoll/languages/python/frameworks/__init__.py +1 -0
  138. quoll/languages/python/frameworks/django/__init__.py +16 -0
  139. quoll/languages/python/frameworks/django/ci.py +43 -0
  140. quoll/languages/python/frameworks/django/detect.py +17 -0
  141. quoll/languages/python/frameworks/django/docker.py +20 -0
  142. quoll/languages/python/frameworks/fastapi/__init__.py +7 -0
  143. quoll/languages/python/frameworks/fastapi/detect.py +9 -0
  144. quoll/languages/python/frameworks/fastapi/docker.py +21 -0
  145. quoll/languages/python/frameworks/flask/__init__.py +7 -0
  146. quoll/languages/python/frameworks/flask/detect.py +9 -0
  147. quoll/languages/python/frameworks/flask/docker.py +22 -0
  148. quoll/languages/ruby/Dockerfile.j2 +45 -0
  149. quoll/languages/ruby/__init__.py +19 -0
  150. quoll/languages/ruby/ci.py +110 -0
  151. quoll/languages/ruby/detect.py +72 -0
  152. quoll/languages/ruby/docker.py +63 -0
  153. quoll/languages/ruby/frameworks/__init__.py +1 -0
  154. quoll/languages/ruby/frameworks/rails/Dockerfile.j2 +16 -0
  155. quoll/languages/ruby/frameworks/rails/__init__.py +18 -0
  156. quoll/languages/ruby/frameworks/rails/ci.py +26 -0
  157. quoll/languages/ruby/frameworks/rails/detect.py +15 -0
  158. quoll/languages/ruby/frameworks/rails/docker.py +62 -0
  159. quoll/languages/ruby/frameworks/rails/services.py +10 -0
  160. quoll/languages/rust/Dockerfile.j2 +23 -0
  161. quoll/languages/rust/__init__.py +17 -0
  162. quoll/languages/rust/ci.py +125 -0
  163. quoll/languages/rust/detect.py +46 -0
  164. quoll/languages/rust/docker.py +38 -0
  165. quoll/main.py +50 -0
  166. quoll/platforms/__init__.py +62 -0
  167. quoll/platforms/base.py +103 -0
  168. quoll/platforms/github/__init__.py +4 -0
  169. quoll/platforms/github/actions.py +78 -0
  170. quoll/platforms/github/api.py +124 -0
  171. quoll/platforms/github/cd.py +265 -0
  172. quoll/platforms/github/ci.py +497 -0
  173. quoll/platforms/github/dependabot.yml.j2 +49 -0
  174. quoll/platforms/github/platform.py +52 -0
  175. quoll/platforms/github/workflow.py +74 -0
  176. quoll/platforms/gitlab/__init__.py +4 -0
  177. quoll/platforms/gitlab/api.py +158 -0
  178. quoll/platforms/gitlab/cd.py +226 -0
  179. quoll/platforms/gitlab/ci.py +400 -0
  180. quoll/platforms/gitlab/pipeline.py +136 -0
  181. quoll/platforms/gitlab/platform.py +54 -0
  182. quoll/project.py +60 -0
  183. quoll/services.py +117 -0
  184. quoll/starters.py +117 -0
  185. quoll/workspace.py +232 -0
  186. quoll/yaml_dump.py +137 -0
  187. quoll_cli-0.2.0.dist-info/METADATA +641 -0
  188. quoll_cli-0.2.0.dist-info/RECORD +191 -0
  189. quoll_cli-0.2.0.dist-info/WHEEL +4 -0
  190. quoll_cli-0.2.0.dist-info/entry_points.txt +2 -0
  191. quoll_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
quoll/services.py ADDED
@@ -0,0 +1,117 @@
1
+ """Backing services (databases, caches) an app needs: containers for CI tests and for compose.yaml.
2
+
3
+ Languages detect which ones a project uses (see `detect_services`); this module knows how to run them and
4
+ which environment variables point an app at them. Credentials are throwaway: these containers only serve
5
+ tests and local development.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Callable, Dict, Iterable, List, Mapping, Union
11
+
12
+ DATABASE = "app"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Service:
17
+ name: str
18
+ image: str
19
+ port: int
20
+ env: Dict[str, str]
21
+ # Shell command that succeeds once the service accepts connections.
22
+ health: str
23
+ # Where the container keeps its data (a named volume in compose.yaml).
24
+ data_dir: str
25
+ # Environment variables pointing an app at the service on `host`, using `database` (a name of its own).
26
+ app_env: Callable[[str, str], Dict[str, str]]
27
+
28
+
29
+ def _postgres(host: str, database: str) -> Dict[str, str]:
30
+ url = f"postgresql://{DATABASE}:{DATABASE}@{host}:5432/{database}"
31
+ # The PG* variables are read by libpq-based clients (psycopg, pg, rails...) without any configuration.
32
+ return {
33
+ "DATABASE_URL": url,
34
+ "PGHOST": host,
35
+ "PGPORT": "5432",
36
+ "PGUSER": DATABASE,
37
+ "PGPASSWORD": DATABASE,
38
+ "PGDATABASE": database,
39
+ }
40
+
41
+
42
+ def _mysql(host: str, database: str) -> Dict[str, str]:
43
+ # root, so test runners can create their test databases (Django, Rails...).
44
+ return {"DATABASE_URL": f"mysql://root:root@{host}:3306/{database}"}
45
+
46
+
47
+ SERVICES: Dict[str, Service] = {
48
+ s.name: s
49
+ for s in (
50
+ Service(
51
+ name="postgres",
52
+ image="postgres:17-alpine",
53
+ port=5432,
54
+ env={"POSTGRES_USER": DATABASE, "POSTGRES_PASSWORD": DATABASE, "POSTGRES_DB": DATABASE},
55
+ health=f"pg_isready -U {DATABASE} -d {DATABASE}",
56
+ data_dir="/var/lib/postgresql/data",
57
+ app_env=_postgres,
58
+ ),
59
+ Service(
60
+ name="mysql",
61
+ image="mysql:8.4",
62
+ port=3306,
63
+ env={"MYSQL_ROOT_PASSWORD": "root", "MYSQL_DATABASE": DATABASE},
64
+ health="mysqladmin ping -h 127.0.0.1 -uroot -proot",
65
+ data_dir="/var/lib/mysql",
66
+ app_env=_mysql,
67
+ ),
68
+ Service(
69
+ name="redis",
70
+ image="redis:7-alpine",
71
+ port=6379,
72
+ env={},
73
+ health="redis-cli ping",
74
+ data_dir="/data",
75
+ app_env=lambda host, database: {"REDIS_URL": f"redis://{host}:6379"},
76
+ ),
77
+ Service(
78
+ name="mongodb",
79
+ image="mongo:8",
80
+ port=27017,
81
+ env={},
82
+ health="mongosh --quiet --eval 'db.adminCommand({ping: 1})'",
83
+ data_dir="/data/db",
84
+ app_env=lambda host, database: {"MONGODB_URI": f"mongodb://{host}:27017/{database}"},
85
+ ),
86
+ )
87
+ }
88
+
89
+ # Signature of the language/framework hooks adding their own variables (Laravel's DB_*, Spring's datasource...):
90
+ # (service, host, database) -> variables.
91
+ ServiceEnv = Callable[[str, str, str], Dict[str, str]]
92
+
93
+
94
+ def no_service_env(service: str, host: str, database: str) -> Dict[str, str]:
95
+ return {}
96
+
97
+
98
+ def detect_services(dependencies: Iterable[str], packages: Mapping[str, Iterable[str]]) -> List[str]:
99
+ """Services whose client packages appear in `dependencies`, in catalog order."""
100
+ names = {d.lower() for d in dependencies}
101
+ return [service for service in SERVICES if any(p.lower() in names for p in packages.get(service, ()))]
102
+
103
+
104
+ def environment(
105
+ services: Iterable[str], host: Union[str, Callable[[str], str]], *hooks: ServiceEnv, database: str = DATABASE
106
+ ) -> Dict[str, str]:
107
+ """Variables pointing an app at `services`, reachable on `host` (or `host(service)`), using its `database`.
108
+
109
+ Later hooks override earlier values.
110
+ """
111
+ env: Dict[str, str] = {}
112
+ for name in services:
113
+ address = host(name) if callable(host) else host
114
+ env.update(SERVICES[name].app_env(address, database))
115
+ for hook in hooks:
116
+ env.update(hook(name, address, database))
117
+ return env
quoll/starters.py ADDED
@@ -0,0 +1,117 @@
1
+ """Templates and generators (`ng new`, `npm install`...) behind the starters of `quoll new`.
2
+
3
+ A generator runs with the local tool when it is installed and no runtime version is asked for; otherwise in a
4
+ container of that runtime's image, so that the project and its lockfile match the version it will run on.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ from pathlib import Path
12
+ from typing import List, Optional, Sequence
13
+
14
+ from quoll.languages.base import StarterOptions
15
+
16
+ # Runtimes' container images, by runtime and version.
17
+ IMAGES = {"node": "node:{version}"}
18
+
19
+
20
+ class StarterError(Exception):
21
+ pass
22
+
23
+
24
+ def run(command: List[str], cwd: Path, runtime: str = "", version: str = "") -> None:
25
+ """Run `command` in `cwd`: in the `runtime:version` image when a version is asked for or the tool is missing."""
26
+ image = IMAGES[runtime].format(version=version or "latest") if runtime else ""
27
+ if shutil.which(command[0]) and not version:
28
+ full = command
29
+ elif image and shutil.which("docker"):
30
+ # The project's files stay the user's; caches go to the container's /tmp.
31
+ user = ["--user", f"{os.getuid()}:{os.getgid()}"] if hasattr(os, "getuid") else []
32
+ full = ["docker", "run", "--rm", "-v", f"{cwd}:/work", "-w", "/work", *user, "-e", "HOME=/tmp", image, *command]
33
+ else:
34
+ needed = f"Docker (to run {image})" if version else f"`{command[0]}` (or Docker)"
35
+ raise StarterError(f"{needed} is needed to create this project")
36
+ result = subprocess.run(full, cwd=cwd, capture_output=True, text=True)
37
+ if result.returncode != 0:
38
+ output = (result.stdout + result.stderr).strip().splitlines()
39
+ raise StarterError(f"`{' '.join(command)}` failed:\n " + "\n ".join(output[-15:]))
40
+
41
+
42
+ def in_container(command: str, version: str) -> bool:
43
+ """Whether `run` runs `command` in a container (a runtime version is asked for, or the tool is missing)."""
44
+ return bool(version) or not shutil.which(command)
45
+
46
+
47
+ def install_locally(directory: Path, version: str, command: Sequence[str], folder: str) -> List[str]:
48
+ """Dependencies installed in a (Linux) container hold its native binaries: reinstall them for this machine
49
+ from the lock file, or leave `folder` out. Returns a note for the user, if any."""
50
+ if not in_container(command[0], version):
51
+ return []
52
+ shutil.rmtree(directory / folder, ignore_errors=True)
53
+ if shutil.which(command[0]):
54
+ result = subprocess.run(list(command), cwd=directory, capture_output=True, text=True)
55
+ if result.returncode == 0:
56
+ return []
57
+ return [f"{directory.name}: run `{' '.join(command)}` there before working locally."]
58
+
59
+
60
+ def write(directory: Path, files: dict) -> None:
61
+ for rel, content in files.items():
62
+ path = directory / rel
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ path.write_text(content)
65
+
66
+
67
+ def from_template(
68
+ template: Path,
69
+ commands: Sequence[Sequence[str]] = (),
70
+ *,
71
+ runtime: str = "",
72
+ runtime_default: str = "",
73
+ version_default: str = "latest",
74
+ local_install: Sequence[str] = (),
75
+ local_folder: str = "",
76
+ renamed: Optional[dict] = None,
77
+ ):
78
+ """A starter's `create` copying `template`, then running `commands` in the project.
79
+
80
+ In files and commands, {{name}}, {{version}} (the framework's) and {{runtime}} (the runtime's) are replaced.
81
+ When the commands ran in a container, `local_install` reinstalls `local_folder` for this machine.
82
+ Templates live next to their plugin, in templates/<starter>/. Dot files are stored without their dot (see
83
+ `renamed`), so they don't apply to quoll's own repository.
84
+ """
85
+ renamed = {
86
+ "gitignore": ".gitignore",
87
+ "env.example": ".env.example",
88
+ "nvmrc": ".nvmrc",
89
+ "prettierignore": ".prettierignore",
90
+ **(renamed or {}),
91
+ }
92
+
93
+ def create(directory: Path, options: StarterOptions) -> List[str]:
94
+ values = {
95
+ "{{name}}": options.name,
96
+ "{{version}}": options.version or version_default,
97
+ "{{runtime}}": options.runtime or runtime_default,
98
+ }
99
+
100
+ def fill(text: str) -> str:
101
+ for key, value in values.items():
102
+ text = text.replace(key, value)
103
+ return text
104
+
105
+ files = {}
106
+ for path in sorted(template.rglob("*")):
107
+ if path.is_file():
108
+ rel = path.relative_to(template).as_posix()
109
+ files[renamed.get(rel, rel)] = fill(path.read_text())
110
+ write(directory, files)
111
+ for command in commands:
112
+ run([fill(part) for part in command], directory, runtime, options.runtime)
113
+ if local_install:
114
+ return install_locally(directory, options.runtime, local_install, local_folder)
115
+ return []
116
+
117
+ return create
quoll/workspace.py ADDED
@@ -0,0 +1,232 @@
1
+ """Repositories holding several projects (a front-end and a back-end, services...), each with its own quoll.toml.
2
+
3
+ The root quoll.toml lists them and holds what the repository shares; every member keeps the format of a
4
+ single project, so a member taken out of the repository stays a valid quoll project:
5
+
6
+ # quoll.toml
7
+ [project]
8
+ platform = "github"
9
+
10
+ [workspace]
11
+ members = ["backend", "frontend"]
12
+
13
+ [ci] # the repository's: when pipelines run, repository-wide scans
14
+ branches = ["main"]
15
+
16
+ # backend/quoll.toml, frontend/quoll.toml: [project], [ci], [docker], [cd] as usual
17
+
18
+ A repository with a single project is the one-member case: everything below works for both.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import List, Optional
25
+
26
+ from pydantic import Field, model_validator
27
+
28
+ from quoll.cd.config import CDConfig
29
+ from quoll.cd.config import load_config as load_cd_config
30
+ from quoll.ci.config import CIConfig, ScheduleConfig, load_config
31
+ from quoll.config.quoll_toml import CONFIG_FILE, ConfigError, PlatformField, Section, load_section, read, save_section
32
+ from quoll.k8s.config import K8sConfig
33
+ from quoll.k8s.config import load_config as load_k8s_config
34
+
35
+ WORKSPACE_SECTION = "workspace"
36
+
37
+
38
+ class WorkspaceConfig(Section):
39
+ # Directories of the projects, relative to the repository root ("." for a project at the root).
40
+ members: List[str]
41
+
42
+ @model_validator(mode="after")
43
+ def _check(self) -> "WorkspaceConfig":
44
+ if not self.members:
45
+ raise ValueError("workspace.members must list at least one project directory")
46
+ names = [member_name(m) for m in self.members]
47
+ duplicates = sorted({n for n in names if names.count(n) > 1})
48
+ if duplicates:
49
+ raise ValueError(f"two members would be named '{duplicates[0]}': rename one of their directories")
50
+ return self
51
+
52
+
53
+ class RepoSecurity(Section):
54
+ # Scans of the whole repository, run once whatever the members.
55
+ secrets: bool = True
56
+ sast: bool = True
57
+
58
+
59
+ class RepoCIConfig(Section, PlatformField):
60
+ """[ci] of a workspace root: what the members share."""
61
+
62
+ shared_fields = ("platform",)
63
+
64
+ branches: List[str] = Field(default_factory=lambda: ["main"])
65
+ dependency_updates: bool = True
66
+ schedule: ScheduleConfig = Field(default_factory=ScheduleConfig)
67
+ security: RepoSecurity = Field(default_factory=RepoSecurity)
68
+
69
+
70
+ @dataclass
71
+ class Member:
72
+ # Job prefix: the last part of its directory ("" for a single project).
73
+ name: str
74
+ cfg: CIConfig
75
+
76
+ @property
77
+ def directory(self) -> str:
78
+ return self.cfg.directory
79
+
80
+ def job(self, job: str) -> str:
81
+ return f"{self.name}-{job}" if self.name else job
82
+
83
+
84
+ @dataclass
85
+ class Delivery:
86
+ """The delivery of one project: [cd] of a single project ("" name), or of a workspace member."""
87
+
88
+ name: str
89
+ directory: str
90
+ cfg: CDConfig
91
+ # Its Helm chart, deployed to each environment once the image is published.
92
+ k8s: Optional[K8sConfig] = None
93
+
94
+
95
+ @dataclass
96
+ class Repo:
97
+ """Everything one pipeline covers: the repository's settings and its projects."""
98
+
99
+ platform: str
100
+ branches: List[str]
101
+ dependency_updates: bool
102
+ schedule: ScheduleConfig
103
+ secrets: bool
104
+ sast: bool
105
+ members: List[Member]
106
+
107
+ @property
108
+ def workspace(self) -> bool:
109
+ return any(m.name for m in self.members)
110
+
111
+ @classmethod
112
+ def single(cls, cfg: CIConfig) -> "Repo":
113
+ return cls(
114
+ platform=cfg.platform,
115
+ branches=list(cfg.branches),
116
+ dependency_updates=cfg.dependency_updates,
117
+ schedule=cfg.schedule,
118
+ secrets=cfg.security.secrets,
119
+ sast=cfg.security.sast,
120
+ members=[Member("", cfg)],
121
+ )
122
+
123
+
124
+ # Directories that hold no project of their own, even with a requirements.txt or a package.json (docs, examples).
125
+ _NOT_PROJECTS = {
126
+ "docs", "doc", "examples", "example", "samples", "test", "tests", "fixtures", "scripts", "node_modules",
127
+ "vendor", "dist", "build", "target", "venv", ".venv",
128
+ }
129
+ # Where monorepos usually keep their projects, one level deeper.
130
+ _CONTAINERS = ("apps", "packages", "services")
131
+
132
+
133
+ def discover(root: Path) -> List[str]:
134
+ """Project directories of the repository: the root, its first-level directories and apps/*, packages/*,
135
+ services/* — each where quoll recognizes a language."""
136
+ from quoll import languages
137
+ from quoll.project import Project
138
+
139
+ candidates = [root]
140
+ for child in sorted(root.iterdir()):
141
+ if not child.is_dir() or child.name.startswith(".") or child.name in _NOT_PROJECTS:
142
+ continue
143
+ if child.name in _CONTAINERS:
144
+ candidates += sorted(c for c in child.iterdir() if c.is_dir() and not c.name.startswith("."))
145
+ else:
146
+ candidates.append(child)
147
+ found = []
148
+ for directory in candidates:
149
+ if languages.matching(Project(directory)):
150
+ found.append(directory.relative_to(root).as_posix() or ".")
151
+ return found
152
+
153
+
154
+ def member_name(directory: str) -> str:
155
+ """backend -> backend, apps/web -> web, . -> root."""
156
+ parts = [p for p in directory.strip("/").split("/") if p and p != "."]
157
+ return parts[-1] if parts else "root"
158
+
159
+
160
+ def find_root(start: Path) -> Path:
161
+ """The workspace root `start` belongs to (itself or a parent listing it), else `start`."""
162
+ start = start.resolve()
163
+ for candidate in [start, *start.parents]:
164
+ try:
165
+ data = read(candidate)
166
+ except ConfigError:
167
+ continue
168
+ workspace = data.get(WORKSPACE_SECTION)
169
+ if workspace is None:
170
+ continue
171
+ members = [(candidate / m).resolve() for m in workspace.get("members", [])]
172
+ if candidate == start or start in members:
173
+ return candidate
174
+ return start
175
+
176
+
177
+ def load_workspace(root: Path) -> Optional[WorkspaceConfig]:
178
+ data = read(root)
179
+ if WORKSPACE_SECTION not in data:
180
+ return None
181
+ return load_section(root, WORKSPACE_SECTION, WorkspaceConfig)
182
+
183
+
184
+ def load_repo(root: Path) -> Repo:
185
+ """The repository's pipeline settings: a workspace's, or a single project's (its [ci])."""
186
+ workspace = load_workspace(root)
187
+ if workspace is None:
188
+ return Repo.single(load_config(root))
189
+ shared = load_section(root, "ci", RepoCIConfig)
190
+ members = []
191
+ for directory in workspace.members:
192
+ cfg = load_config(root / directory)
193
+ if cfg.platform != shared.platform:
194
+ raise ConfigError(
195
+ f"{directory}/{CONFIG_FILE} targets {cfg.platform}, the workspace {shared.platform}: "
196
+ "set the same [project] platform"
197
+ )
198
+ members.append(Member(member_name(directory), cfg.in_directory(directory)))
199
+ return Repo(
200
+ platform=shared.platform,
201
+ branches=list(shared.branches),
202
+ dependency_updates=shared.dependency_updates,
203
+ schedule=shared.schedule,
204
+ secrets=shared.security.secrets,
205
+ sast=shared.security.sast,
206
+ members=members,
207
+ )
208
+
209
+
210
+ def save_workspace(root: Path, members: List[str], shared: RepoCIConfig) -> None:
211
+ save_section(root, WORKSPACE_SECTION, WorkspaceConfig(members=members))
212
+ save_section(root, "ci", shared)
213
+
214
+
215
+ def load_deliveries(root: Path) -> List[Delivery]:
216
+ """The [cd] of a single project, or of each workspace member that has one."""
217
+ workspace = load_workspace(root)
218
+ if workspace is None:
219
+ return [Delivery("", ".", load_cd_config(root), _chart(root))]
220
+ deliveries = []
221
+ for directory in workspace.members:
222
+ if "cd" in read(root / directory):
223
+ deliveries.append(
224
+ Delivery(member_name(directory), directory, load_cd_config(root / directory), _chart(root / directory))
225
+ )
226
+ if not deliveries:
227
+ raise ConfigError("no workspace member has a [cd] section. Run `quoll cd init` in one of them first.")
228
+ return deliveries
229
+
230
+
231
+ def _chart(directory: Path) -> Optional[K8sConfig]:
232
+ return load_k8s_config(directory) if "k8s" in read(directory) else None
quoll/yaml_dump.py ADDED
@@ -0,0 +1,137 @@
1
+ """Minimal, deterministic YAML emitter (GitHub workflows, GitLab pipelines, compose files).
2
+
3
+ quoll builds documents as plain dicts and emits them itself so the output is stable (byte-for-byte, which the
4
+ `check` commands rely on), readable and prettier-compatible.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ from typing import Any, Dict, Iterable, List
11
+
12
+
13
+ class Commented(str):
14
+ """A scalar followed by a comment, e.g. an action pinned to a commit, with its version."""
15
+
16
+ comment: str
17
+
18
+ def __new__(cls, value: str, comment: str) -> "Commented":
19
+ obj = super().__new__(cls, value)
20
+ obj.comment = comment
21
+ return obj
22
+
23
+
24
+ _PLAIN_RE = re.compile(r"^[A-Za-z_][\w ./@()=,+-]*$")
25
+ _YAML_KEYWORDS = {"true", "false", "yes", "no", "on", "off", "null", "y", "n", "~"}
26
+ _RUN_UNSAFE_START = set("-?:,[]{}#&*!|>'\"%@`")
27
+
28
+
29
+ def dump(doc: Dict[str, Any], spaced: Iterable[str] = (), scripts: Iterable[str] = ("run",)) -> str:
30
+ """YAML for `doc`; children of the `spaced` mappings are separated by a blank line, for readability.
31
+
32
+ Values of the `scripts` keys are shell commands (a string or a list of them): emitted unquoted when YAML allows,
33
+ as literal blocks otherwise.
34
+ """
35
+ lines: List[str] = []
36
+ top_keys = list(doc)
37
+ for i, key in enumerate(top_keys):
38
+ if i:
39
+ lines.append("")
40
+ lines.extend(_emit_entry(key, doc[key], 0, frozenset(spaced), frozenset(scripts)))
41
+ return "\n".join(lines) + "\n"
42
+
43
+
44
+ def _emit_entry(key: str, value: Any, indent: int, spaced: frozenset, scripts: frozenset) -> List[str]:
45
+ pad = " " * indent
46
+ if isinstance(value, Commented):
47
+ return [f"{pad}{key}: {_scalar(str(value))} # {value.comment}"]
48
+ if key in scripts and isinstance(value, list):
49
+ out = [f"{pad}{key}:"]
50
+ for command in value:
51
+ out.extend(_emit_command(f"{pad} - ", command, indent + 4))
52
+ return out
53
+ if key in scripts and isinstance(value, str):
54
+ return _emit_command(f"{pad}{key}: ", value, indent + 2)
55
+ if isinstance(value, dict):
56
+ if not value:
57
+ return [f"{pad}{key}: {{}}"]
58
+ out = [f"{pad}{key}:"]
59
+ for i, (k, v) in enumerate(value.items()):
60
+ if i and key in spaced:
61
+ out.append("")
62
+ out.extend(_emit_entry(k, v, indent + 2, spaced, scripts))
63
+ return out
64
+ if isinstance(value, list):
65
+ if all(not isinstance(v, (dict, list)) for v in value):
66
+ return [f"{pad}{key}: [{', '.join(_scalar(v) for v in value)}]"]
67
+ out = [f"{pad}{key}:"]
68
+ for i, item in enumerate(value):
69
+ if i and key in spaced:
70
+ out.append("")
71
+ out.extend(_emit_list_item(item, indent + 2, spaced, scripts))
72
+ return out
73
+ if value is None:
74
+ return [f"{pad}{key}:"]
75
+ if isinstance(value, str) and "\n" in value:
76
+ return _block(f"{pad}{key}: ", value, indent + 2)
77
+ return [f"{pad}{key}: {_scalar(value)}"]
78
+
79
+
80
+ def _emit_command(prefix: str, command: str, indent: int) -> List[str]:
81
+ if "\n" in command or not _plain_run(command):
82
+ return _block(prefix, command, indent)
83
+ return [f"{prefix}{command}"]
84
+
85
+
86
+ def _block(prefix: str, text: str, indent: int) -> List[str]:
87
+ pad = " " * indent
88
+ return [f"{prefix}|", *(f"{pad}{line}" if line else "" for line in text.split("\n"))]
89
+
90
+
91
+ def _emit_list_item(item: Any, indent: int, spaced: frozenset, scripts: frozenset) -> List[str]:
92
+ pad = " " * indent
93
+ if not isinstance(item, dict):
94
+ return [f"{pad}- {_scalar(item)}"]
95
+ out: List[str] = []
96
+ for i, (k, v) in enumerate(item.items()):
97
+ entry = _emit_entry(k, v, indent + 2, spaced, scripts)
98
+ if i == 0:
99
+ entry[0] = f"{pad}- {entry[0].lstrip()}"
100
+ out.extend(entry)
101
+ return out
102
+
103
+
104
+ def _scalar(value: Any) -> str:
105
+ if isinstance(value, bool):
106
+ return "true" if value else "false"
107
+ if isinstance(value, (int, float)):
108
+ return str(value)
109
+ if value is None:
110
+ return "null"
111
+ text = str(value)
112
+ if text.startswith("${{") and ": " not in text and " #" not in text:
113
+ return text
114
+ if _plain_scalar(text):
115
+ return text
116
+ return json.dumps(text)
117
+
118
+
119
+ def _plain_scalar(text: str) -> bool:
120
+ """Strings that read the same unquoted: start with a letter, no YAML indicators inside."""
121
+ return (
122
+ bool(_PLAIN_RE.match(text))
123
+ and text.lower() not in _YAML_KEYWORDS
124
+ and ": " not in text
125
+ and " #" not in text
126
+ and not text.endswith((":", " "))
127
+ )
128
+
129
+
130
+ def _plain_run(cmd: str) -> bool:
131
+ return (
132
+ bool(cmd)
133
+ and cmd[0] not in _RUN_UNSAFE_START
134
+ and ": " not in cmd
135
+ and " #" not in cmd
136
+ and not cmd.endswith(":")
137
+ )