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
@@ -0,0 +1,40 @@
1
+ {% block base %}
2
+ FROM dunglas/frankenphp:1-php{{ cfg.version }} AS base
3
+ {{ os_updates }}
4
+ {% if extensions %}
5
+ RUN install-php-extensions {{ extensions | join(" ") }}
6
+ {% endif %}
7
+ {% if env %}
8
+ ENV {% for key, value in env.items() %}{{ key }}={{ value }}{{ " \\\n " if not loop.last }}{% endfor %}
9
+
10
+ {% endif %}
11
+ WORKDIR /app
12
+ {% endblock %}
13
+
14
+ {% block build %}
15
+ FROM base AS build
16
+ RUN install-php-extensions @composer
17
+ ENV COMPOSER_CACHE_DIR=/tmp/composer-cache
18
+ # Dependencies first: code changes don't reinstall them.
19
+ COPY composer.json {{ "composer.lock " if lockfile }}./
20
+ RUN --mount=type=cache,target=/tmp/composer-cache \
21
+ composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts --no-autoloader
22
+ COPY . .
23
+ RUN composer dump-autoload --no-dev --optimize
24
+ {% for command in post_build %}
25
+ RUN {{ command }}
26
+ {% endfor %}
27
+ {% endblock %}
28
+
29
+ {% block runtime %}
30
+ FROM base
31
+ # Plain HTTP on an unprivileged port: the container runs as non-root, a proxy terminates TLS.
32
+ ENV SERVER_NAME=:{{ cfg.port }}{{ " \\\n SERVER_ROOT=/app" if not public }}
33
+ # A numeric user: Kubernetes can then check it isn't root (runAsNonRoot).
34
+ RUN useradd --uid 10001 --no-create-home app \
35
+ && setcap -r /usr/local/bin/frankenphp \
36
+ && chown -R app:app /config/caddy /data/caddy
37
+ COPY --from=build --chown=app:app /app /app
38
+ USER 10001
39
+ EXPOSE {{ cfg.port }}
40
+ {% endblock %}
@@ -0,0 +1,19 @@
1
+ """PHP: Composer projects."""
2
+
3
+ from quoll.languages.base import Language
4
+ from quoll.languages.php.ci import PhpCI
5
+ from quoll.languages.php.detect import detect
6
+ from quoll.languages.php.docker import PhpImage
7
+ from quoll.languages.php.frameworks import laravel, symfony
8
+
9
+ language = Language(
10
+ name="php",
11
+ markers=("composer.json",),
12
+ package_managers=("composer",),
13
+ lint_tools=("php-cs-fixer", "phpcs", "pint", "phpstan", "psalm", "rector"),
14
+ test_runners=("phpunit", "pest"),
15
+ detect=detect,
16
+ ci=PhpCI(),
17
+ frameworks=(symfony.framework, laravel.framework),
18
+ docker=PhpImage(),
19
+ )
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence
4
+
5
+ from quoll.ci.steps import ROOT, Cache, Item, Step, on_github, step
6
+ from quoll.languages.base import THRESHOLD_REGEX, CISteps, TestReports
7
+ from quoll.platforms.github.actions import ACTIONS
8
+
9
+ if TYPE_CHECKING:
10
+ from quoll.ci.config import CIConfig
11
+
12
+ # tool: (step name, command, flag printing GitHub annotations)
13
+ _LINT = {
14
+ "php-cs-fixer": ("PHP CS Fixer", "vendor/bin/php-cs-fixer fix --dry-run --diff", ""),
15
+ "phpcs": ("PHP_CodeSniffer", "vendor/bin/phpcs", ""),
16
+ "pint": ("Pint", "vendor/bin/pint --test", ""),
17
+ "phpstan": ("PHPStan", "vendor/bin/phpstan analyse --no-progress", " --error-format=github"),
18
+ "psalm": ("Psalm", "vendor/bin/psalm --no-progress", " --output-format=github"),
19
+ "rector": ("Rector", "vendor/bin/rector process --dry-run --no-progress-bar", ""),
20
+ }
21
+
22
+ # PHPUnit has no CLI threshold: read the line rate from its Clover report.
23
+ _CLOVER_CHECK = """# shellcheck disable=SC2016 # PHP code: its $ are PHP's
24
+ php -r '
25
+ $m = simplexml_load_file("coverage.xml")->project->metrics;
26
+ $pct = (int) $m["statements"] ? 100 * (int) $m["coveredstatements"] / (int) $m["statements"] : 100;
27
+ $summary = getenv("GITHUB_STEP_SUMMARY");
28
+ if ($summary) file_put_contents($summary, sprintf("### Coverage: %.1f%%\\n", $pct), FILE_APPEND);
29
+ if ($pct < {threshold}) {{ printf("::error::Coverage %.1f%% is below the {threshold}%% threshold\\n", $pct); exit(1); }}
30
+ printf("Coverage %.1f%% (threshold {threshold}%%)\\n", $pct);'"""
31
+
32
+ # The official images have neither Composer nor most extensions: install them, then the ext-* the lock file needs.
33
+ _EXTENSIONS = """curl -sSLf -o /usr/local/bin/install-php-extensions \\
34
+ https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions
35
+ chmod +x /usr/local/bin/install-php-extensions
36
+ install-php-extensions @composer zip{coverage}
37
+ if [ -f composer.lock ]; then
38
+ # Exits 2 when something is missing, which is the point here (the runner sets pipefail).
39
+ missing=$({{ composer check-platform-reqs --lock --no-interaction 2>/dev/null || true; }} \\
40
+ | awk '$1 ~ /^ext-/ && /missing/ {{ sub("ext-", "", $1); print $1 }}')
41
+ if [ -n "$missing" ]; then install-php-extensions $missing; fi
42
+ fi"""
43
+
44
+
45
+ class PhpCI(CISteps):
46
+ version_key = "php-version"
47
+
48
+ def setup(self, cfg: CIConfig, version: Optional[str]) -> List[Item]:
49
+ with_: Dict[str, Any] = (
50
+ {"php-version": version} if version else {"php-version-file": cfg.path(cfg.version_file)}
51
+ )
52
+ with_["coverage"] = "pcov" if cfg.test.enabled and cfg.test.coverage else "none"
53
+ with_["tools"] = "composer:v2"
54
+ steps = [step("Set up PHP", uses=ACTIONS["setup-php"], with_=with_)]
55
+ if cfg.cache:
56
+ steps += [
57
+ step(
58
+ "Get Composer cache directory",
59
+ id_="composer-cache",
60
+ run='echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"',
61
+ ),
62
+ step(
63
+ "Cache Composer packages",
64
+ uses=ACTIONS["cache"],
65
+ with_={
66
+ "path": "${{ steps.composer-cache.outputs.dir }}",
67
+ "key": "${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}",
68
+ "restore-keys": "${{ runner.os }}-composer-",
69
+ },
70
+ ),
71
+ ]
72
+ return steps
73
+
74
+ def image(self, cfg: CIConfig, version: str) -> str:
75
+ return f"php:{version}-cli"
76
+
77
+ def image_setup(self, cfg: CIConfig) -> List[Step]:
78
+ coverage = " pcov" if cfg.test.enabled and cfg.test.coverage else ""
79
+ return [step("Install Composer and PHP extensions", run=_EXTENSIONS.format(coverage=coverage))]
80
+
81
+ def cache(self, cfg: CIConfig) -> Optional[Cache]:
82
+ if not cfg.cache:
83
+ return None
84
+ env = {"COMPOSER_CACHE_DIR": f"{ROOT}/.cache/composer"}
85
+ return Cache("composer", (".cache/composer",), key_files=("composer.lock",), env=env)
86
+
87
+ def install(self, cfg: CIConfig, packages: Sequence[str] = ()) -> List[Item]:
88
+ return [step("Install dependencies", run=cfg.install)]
89
+
90
+ def lint(self, cfg: CIConfig, tools: List[str]) -> List[Item]:
91
+ steps = self.install(cfg)
92
+ for tool in tools:
93
+ name, cmd, github = _LINT[tool]
94
+ steps.append(step(name, run=cmd + on_github(cfg, github)))
95
+ return steps
96
+
97
+ def test(self, cfg: CIConfig) -> List[Item]:
98
+ t = cfg.test
99
+ if t.runner == "pest":
100
+ cmd = "vendor/bin/pest"
101
+ if t.coverage:
102
+ cmd += f" --coverage --min={t.coverage_threshold} --coverage-clover coverage.xml"
103
+ cmd += report_flags(cfg)
104
+ name = "Tests" + (" with coverage" if t.coverage else "")
105
+ return [*self.install(cfg), step(name, run=cmd, secrets=t.env_secrets)]
106
+ if not t.coverage:
107
+ cmd = "vendor/bin/phpunit" + report_flags(cfg)
108
+ return [*self.install(cfg), step("Tests", run=cmd, secrets=t.env_secrets)]
109
+ cmd = "vendor/bin/phpunit --coverage-clover coverage.xml" + report_flags(cfg)
110
+ return [
111
+ *self.install(cfg),
112
+ step("Tests with coverage", run=cmd, secrets=t.env_secrets),
113
+ step("Coverage threshold", run=clover_check(t.coverage_threshold)),
114
+ ]
115
+
116
+ def coverage_file(self, cfg: CIConfig) -> str:
117
+ return "coverage.xml"
118
+
119
+ def reports(self, cfg: CIConfig) -> TestReports:
120
+ coverage = cfg.test.coverage
121
+ # PHPUnit prints the threshold line of clover_check; Pest and `artisan test` their own total.
122
+ regex = THRESHOLD_REGEX if cfg.test.runner == "phpunit" else r"/Total:\s+(\d+(?:\.\d+)?) ?%/"
123
+ return TestReports(
124
+ junit="junit.xml",
125
+ cobertura="cobertura.xml" if coverage else "",
126
+ coverage_regex=regex if coverage else "",
127
+ )
128
+
129
+ def audit(self, cfg: CIConfig) -> List[Item]:
130
+ return [step("Dependency vulnerability scan", run="composer audit --locked --no-dev")]
131
+
132
+ def build(self, cfg: CIConfig) -> List[Item]:
133
+ return [*self.install(cfg), step("Build", run=cfg.build.command)]
134
+
135
+ def dependabot_ecosystem(self, cfg: CIConfig) -> str:
136
+ return "composer"
137
+
138
+
139
+ def report_flags(cfg: CIConfig) -> str:
140
+ """PHPUnit (and Pest, Laravel's `artisan test`) options writing the JUnit and Cobertura reports."""
141
+ if not cfg.test.reports:
142
+ return ""
143
+ return " --log-junit junit.xml" + (" --coverage-cobertura cobertura.xml" if cfg.test.coverage else "")
144
+
145
+
146
+ def clover_check(threshold: int) -> str:
147
+ return _CLOVER_CHECK.format(threshold=threshold)
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Dict
5
+
6
+ from quoll.languages.base import Detection
7
+ from quoll.project import Project
8
+ from quoll.services import detect_services
9
+
10
+ PHP_SUPPORTED = ("8.2", "8.3", "8.4", "8.5")
11
+
12
+ # composer package: lint tool
13
+ _LINT_PACKAGES = {
14
+ "friendsofphp/php-cs-fixer": "php-cs-fixer",
15
+ "squizlabs/php_codesniffer": "phpcs",
16
+ "laravel/pint": "pint",
17
+ "phpstan/phpstan": "phpstan",
18
+ "vimeo/psalm": "psalm",
19
+ "rector/rector": "rector",
20
+ }
21
+
22
+
23
+ # Packages and extensions revealing a backing service.
24
+ _SERVICE_PACKAGES = {
25
+ "postgres": ("ext-pdo_pgsql", "ext-pgsql"),
26
+ "mysql": ("ext-pdo_mysql", "ext-mysqli"),
27
+ "redis": ("predis/predis", "ext-redis", "snc/redis-bundle"),
28
+ "mongodb": ("mongodb/mongodb", "ext-mongodb", "doctrine/mongodb-odm"),
29
+ }
30
+ # Database settings in Symfony's .env / Laravel's .env.example.
31
+ _ENV_DATABASES = {
32
+ "ext-pdo_pgsql": r"^(DATABASE_URL=\W*postgres|DB_CONNECTION=pgsql)",
33
+ "ext-pdo_mysql": r"^(DATABASE_URL=\W*(mysql|mariadb)|DB_CONNECTION=(mysql|mariadb))",
34
+ }
35
+
36
+
37
+ def detect(project: Project) -> Detection:
38
+ det = Detection(package_manager="composer", install="composer install --no-interaction --no-progress --prefer-dist")
39
+ det.lockfile = "composer.lock" if project.exists("composer.lock") else ""
40
+ if not det.lockfile:
41
+ det.warnings.append("No composer.lock found — commit it for reproducible builds.")
42
+
43
+ packages = composer_packages(project)
44
+ m = re.search(r"(\d+)\.(\d+)", packages.get("php", ""))
45
+ low = f"{m.group(1)}.{m.group(2)}" if m else PHP_SUPPORTED[0]
46
+ det.matrix_versions = [v for v in PHP_SUPPORTED if _key(v) >= _key(low)] or [PHP_SUPPORTED[-1]]
47
+ if project.exists(".php-version"):
48
+ det.version_file = ".php-version"
49
+ det.version = project.read_text(".php-version").strip()
50
+ else:
51
+ det.version = det.matrix_versions[-1]
52
+
53
+ det.lint_tools = [tool for package, tool in _LINT_PACKAGES.items() if package in packages]
54
+ if "pestphp/pest" in packages:
55
+ det.test_runner = "pest"
56
+ elif "phpunit/phpunit" in packages or "symfony/phpunit-bridge" in packages:
57
+ det.test_runner = "phpunit"
58
+ else:
59
+ det.warnings.append("No test framework found (phpunit/phpunit or pestphp/pest) — the test job is disabled.")
60
+ env = project.read_text(".env") + project.read_text(".env.example")
61
+ implied = [ext for ext, pattern in _ENV_DATABASES.items() if re.search(pattern, env, re.M)]
62
+ det.services = detect_services([*packages, *implied], _SERVICE_PACKAGES)
63
+ return det
64
+
65
+
66
+ def composer_packages(project: Project) -> Dict[str, str]:
67
+ """`require` and `require-dev` of composer.json, merged."""
68
+ composer = project.read_json("composer.json")
69
+ if not isinstance(composer, dict):
70
+ return {}
71
+ return {**(composer.get("require") or {}), **(composer.get("require-dev") or {})}
72
+
73
+
74
+ def _key(version: str) -> tuple:
75
+ return tuple(int(p) for p in version.split("."))
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
4
+
5
+ from quoll.languages.base import Detection, DockerDefaults, DockerImage
6
+ from quoll.languages.php.detect import composer_packages
7
+ from quoll.project import Project
8
+
9
+ if TYPE_CHECKING:
10
+ from quoll.docker.config import DockerConfig
11
+
12
+ # composer.json name -> install-php-extensions name, when they differ.
13
+ _EXTENSION_NAMES = {"zend-opcache": "opcache"}
14
+
15
+
16
+ class PhpImage(DockerImage):
17
+ """FrankenPHP: PHP and its web server (Caddy) in one process, so there's no command to run."""
18
+
19
+ requires_command = False
20
+
21
+ def defaults(self, project: Project, det: Detection) -> DockerDefaults:
22
+ warnings = [] if project.is_dir("public") else ["No public/ directory: the whole project is served."]
23
+ return DockerDefaults(port=8080, warnings=warnings)
24
+
25
+ def writable_paths(self, cfg: DockerConfig) -> Optional[List[str]]:
26
+ # Caddy's certificates and configuration.
27
+ return ["/tmp", "/data/caddy", "/config/caddy"]
28
+
29
+ def context(self, project: Project, cfg: DockerConfig) -> Dict[str, Any]:
30
+ extensions = sorted(
31
+ _EXTENSION_NAMES.get(name[4:], name[4:]) for name in composer_packages(project) if name.startswith("ext-")
32
+ )
33
+ return {
34
+ "extensions": extensions,
35
+ "lockfile": project.exists("composer.lock"),
36
+ "public": project.is_dir("public"),
37
+ "env": self.env(cfg),
38
+ "post_build": self.post_build(cfg),
39
+ }
40
+
41
+ def env(self, cfg: DockerConfig) -> Dict[str, str]:
42
+ """Environment of the build and the runtime."""
43
+ return {}
44
+
45
+ def post_build(self, cfg: DockerConfig) -> List[str]:
46
+ """Commands run once dependencies and code are in place."""
47
+ return []
48
+
49
+ def dockerignore(self, cfg: DockerConfig) -> List[str]:
50
+ return [
51
+ "vendor",
52
+ "node_modules",
53
+ "var",
54
+ ".phpunit.cache",
55
+ ".php-cs-fixer.cache",
56
+ ".env",
57
+ ".env.*",
58
+ "!.env.example",
59
+ ]
@@ -0,0 +1 @@
1
+ """PHP frameworks."""
@@ -0,0 +1,17 @@
1
+ """Laravel: tests run through artisan, which needs an .env and an application key."""
2
+
3
+ from quoll.languages.base import Framework
4
+ from quoll.languages.php.frameworks.laravel.ci import LaravelCI
5
+ from quoll.languages.php.frameworks.laravel.detect import detect
6
+ from quoll.languages.php.frameworks.laravel.docker import LaravelImage
7
+ from quoll.languages.php.frameworks.laravel.services import service_env
8
+
9
+ framework = Framework(
10
+ name="laravel",
11
+ language="php",
12
+ detect=detect,
13
+ ci=LaravelCI(),
14
+ test_runners=("artisan",),
15
+ docker=LaravelImage(),
16
+ service_env=service_env,
17
+ )
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, List
4
+
5
+ from quoll.ci.steps import Item, step
6
+ from quoll.languages.base import FrameworkCI
7
+ from quoll.languages.php.ci import report_flags
8
+
9
+ if TYPE_CHECKING:
10
+ from quoll.ci.config import CIConfig
11
+
12
+
13
+ class LaravelCI(FrameworkCI):
14
+ def test(self, cfg: CIConfig) -> List[Item]:
15
+ t = cfg.test
16
+ cmd = "php artisan test"
17
+ if t.coverage:
18
+ cmd += f" --coverage --min={t.coverage_threshold} --coverage-clover coverage.xml"
19
+ cmd += report_flags(cfg)
20
+ return [
21
+ *cfg.language.ci.install(cfg),
22
+ step("Prepare environment", run="[ -f .env ] || cp .env.example .env\nphp artisan key:generate"),
23
+ step("Tests" + (" with coverage" if t.coverage else ""), run=cmd, secrets=t.env_secrets),
24
+ ]
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from quoll.languages.base import Detection
4
+ from quoll.languages.php.detect import composer_packages
5
+ from quoll.project import Project
6
+
7
+
8
+ def detect(project: Project, det: Detection) -> bool:
9
+ if "laravel/framework" not in composer_packages(project) or not project.exists("artisan"):
10
+ return False
11
+ # `php artisan test` drives PHPUnit or Pest, whichever the project uses.
12
+ det.test_runner = "artisan"
13
+ return True
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Dict, List, Optional
4
+
5
+ from quoll.languages.php.docker import PhpImage
6
+
7
+ if TYPE_CHECKING:
8
+ from quoll.docker.config import DockerConfig
9
+
10
+
11
+ class LaravelImage(PhpImage):
12
+ def writable_paths(self, cfg: DockerConfig) -> Optional[List[str]]:
13
+ # The framework writes in storage/ and bootstrap/cache/, whose tree must stay: a writable root filesystem.
14
+ return None
15
+
16
+ def env(self, cfg: DockerConfig) -> Dict[str, str]:
17
+ return {"APP_ENV": "production"}
18
+
19
+ def dockerignore(self, cfg: DockerConfig) -> List[str]:
20
+ return [
21
+ *super().dockerignore(cfg),
22
+ "storage/logs/*",
23
+ "storage/framework/cache/data/*",
24
+ "storage/framework/sessions/*",
25
+ "storage/framework/views/*",
26
+ "bootstrap/cache/*.php",
27
+ "public/hot",
28
+ "public/storage",
29
+ ]
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict
4
+
5
+ from quoll.services import SERVICES
6
+
7
+ _CONNECTIONS = {"postgres": "pgsql", "mysql": "mysql"}
8
+
9
+
10
+ def service_env(service: str, host: str, database: str) -> Dict[str, str]:
11
+ """Laravel reads its database and Redis settings from DB_* and REDIS_* rather than URLs."""
12
+ if service in _CONNECTIONS:
13
+ user = "root" if service == "mysql" else "app"
14
+ password = "root" if service == "mysql" else "app"
15
+ return {
16
+ "DB_CONNECTION": _CONNECTIONS[service],
17
+ "DB_HOST": host,
18
+ "DB_PORT": str(SERVICES[service].port),
19
+ "DB_DATABASE": database,
20
+ "DB_USERNAME": user,
21
+ "DB_PASSWORD": password,
22
+ }
23
+ if service == "redis":
24
+ return {"REDIS_HOST": host}
25
+ return {}
@@ -0,0 +1,15 @@
1
+ """Symfony: the console's linters catch broken services, config and templates before the tests do."""
2
+
3
+ from quoll.languages.base import Framework
4
+ from quoll.languages.php.frameworks.symfony.ci import SymfonyCI
5
+ from quoll.languages.php.frameworks.symfony.detect import detect
6
+ from quoll.languages.php.frameworks.symfony.docker import SymfonyImage
7
+
8
+ framework = Framework(
9
+ name="symfony",
10
+ language="php",
11
+ detect=detect,
12
+ ci=SymfonyCI(),
13
+ lint_tools=("symfony-container", "symfony-yaml", "symfony-twig"),
14
+ docker=SymfonyImage(),
15
+ )
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, List
4
+
5
+ from quoll.ci.steps import Item, step
6
+ from quoll.languages.base import FrameworkCI
7
+
8
+ if TYPE_CHECKING:
9
+ from quoll.ci.config import CIConfig
10
+
11
+ # tool: (step name, command)
12
+ _LINT = {
13
+ "symfony-container": ("Lint service container", "php bin/console lint:container"),
14
+ "symfony-yaml": ("Lint YAML config", "php bin/console lint:yaml config --parse-tags"),
15
+ "symfony-twig": ("Lint Twig templates", "php bin/console lint:twig templates"),
16
+ }
17
+
18
+
19
+ class SymfonyCI(FrameworkCI):
20
+ def lint(self, cfg: CIConfig, tool: str) -> List[Item]:
21
+ name, cmd = _LINT[tool]
22
+ return [step(name, run=cmd)]
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from quoll.languages.base import Detection
4
+ from quoll.languages.php.detect import composer_packages
5
+ from quoll.project import Project
6
+
7
+
8
+ def detect(project: Project, det: Detection) -> bool:
9
+ packages = composer_packages(project)
10
+ if "symfony/framework-bundle" not in packages or not project.exists("bin/console"):
11
+ return False
12
+ det.lint_tools.append("symfony-container")
13
+ if project.is_dir("config"):
14
+ det.lint_tools.append("symfony-yaml")
15
+ if "symfony/twig-bundle" in packages and project.is_dir("templates"):
16
+ det.lint_tools.append("symfony-twig")
17
+ return True
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Dict, List, Optional
4
+
5
+ from quoll.languages.php.docker import PhpImage
6
+
7
+ if TYPE_CHECKING:
8
+ from quoll.docker.config import DockerConfig
9
+
10
+
11
+ class SymfonyImage(PhpImage):
12
+ def writable_paths(self, cfg: DockerConfig) -> Optional[List[str]]:
13
+ # The framework writes in var/, whose tree must stay: a writable root filesystem.
14
+ return None
15
+
16
+ def env(self, cfg: DockerConfig) -> Dict[str, str]:
17
+ return {"APP_ENV": "prod", "APP_DEBUG": "0"}
18
+
19
+ def post_build(self, cfg: DockerConfig) -> List[str]:
20
+ # Flex's auto-scripts: cache:clear (warms the prod cache) and assets:install.
21
+ return ["composer run-script --no-dev post-install-cmd"]
22
+
23
+ def dockerignore(self, cfg: DockerConfig) -> List[str]:
24
+ # Symfony commits .env (defaults); secrets live in .env.local.
25
+ return ["vendor", "node_modules", "var", ".phpunit.cache", ".env.local", ".env.local.php", ".env.*.local"]
@@ -0,0 +1,61 @@
1
+ {% block build %}
2
+ FROM python:{{ cfg.version }}-slim AS build
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PIP_DISABLE_PIP_VERSION_CHECK=1
5
+ WORKDIR /app
6
+ {% if cfg.package_manager == "uv" %}
7
+ COPY --from={{ uv_image }} /uv /uvx /bin/
8
+ ENV UV_COMPILE_BYTECODE=1 \
9
+ UV_LINK_MODE=copy \
10
+ UV_PYTHON_DOWNLOADS=0
11
+ # Dependencies first: code changes don't reinstall them.
12
+ RUN --mount=type=cache,target=/root/.cache/uv \
13
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
14
+ {% if lockfile %}
15
+ --mount=type=bind,source=uv.lock,target=uv.lock \
16
+ {% endif %}
17
+ uv sync {{ "--locked " if lockfile }}--no-dev --no-install-project
18
+ COPY . .
19
+ RUN --mount=type=cache,target=/root/.cache/uv \
20
+ uv sync {{ "--locked " if lockfile }}--no-dev
21
+ {% elif cfg.package_manager == "poetry" %}
22
+ ENV POETRY_NO_INTERACTION=1 \
23
+ POETRY_VIRTUALENVS_IN_PROJECT=1
24
+ RUN --mount=type=cache,target=/root/.cache/pip pip install poetry
25
+ # Dependencies first: code changes don't reinstall them.
26
+ COPY pyproject.toml {{ "poetry.lock " if lockfile }}./
27
+ RUN --mount=type=cache,target=/root/.cache/pypoetry poetry install --only main --no-root
28
+ COPY . .
29
+ RUN --mount=type=cache,target=/root/.cache/pypoetry poetry install --only main
30
+ {% else %}
31
+ RUN python -m venv .venv
32
+ ENV PATH="/app/.venv/bin:$PATH"
33
+ {% if requirements %}
34
+ # Dependencies first: code changes don't reinstall them.
35
+ COPY requirements.txt .
36
+ RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
37
+ {% endif %}
38
+ COPY . .
39
+ {% if installable %}
40
+ RUN --mount=type=cache,target=/root/.cache/pip pip install .
41
+ {% endif %}
42
+ {% endif %}
43
+ {% endblock %}
44
+
45
+ {% block runtime %}
46
+ FROM python:{{ cfg.version }}-slim
47
+ {{ os_updates }}
48
+ ENV PYTHONUNBUFFERED=1 \
49
+ PYTHONDONTWRITEBYTECODE=1 \
50
+ {% if src_layout %}
51
+ PYTHONPATH=/app/src \
52
+ {% endif %}
53
+ PATH="/app/.venv/bin:$PATH"
54
+ # A numeric user: Kubernetes can then check it isn't root (runAsNonRoot).
55
+ RUN groupadd --system --gid 10001 app && useradd --system --uid 10001 --gid app --no-create-home app
56
+ WORKDIR /app
57
+ COPY --from=build --chown=app:app /app /app
58
+ USER 10001:10001
59
+ EXPOSE {{ cfg.port }}
60
+ CMD {{ cfg.command | exec_form }}
61
+ {% endblock %}
@@ -0,0 +1,19 @@
1
+ """Python: pip, uv and poetry projects."""
2
+
3
+ from quoll.languages.base import Language
4
+ from quoll.languages.python.ci import PythonCI
5
+ from quoll.languages.python.detect import detect
6
+ from quoll.languages.python.docker import PythonImage
7
+ from quoll.languages.python.frameworks import django, fastapi, flask
8
+
9
+ language = Language(
10
+ name="python",
11
+ markers=("pyproject.toml", "requirements.txt", "setup.py", "setup.cfg", "Pipfile"),
12
+ package_managers=("pip", "uv", "poetry"),
13
+ lint_tools=("ruff", "ruff-format", "flake8", "black", "mypy"),
14
+ test_runners=("pytest",),
15
+ detect=detect,
16
+ ci=PythonCI(),
17
+ frameworks=(django.framework, fastapi.framework, flask.framework),
18
+ docker=PythonImage(),
19
+ )