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,157 @@
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 CISteps, TestReports
7
+ from quoll.platforms.github.actions import ACTIONS
8
+
9
+ if TYPE_CHECKING:
10
+ from quoll.ci.config import CIConfig
11
+ from quoll.languages.base import Detection
12
+
13
+ # The TOTAL line of `coverage report` / pytest-cov.
14
+ COVERAGE_REGEX = r"/TOTAL.*\s(\d+(?:\.\d+)?)%/"
15
+
16
+ _RUN_PREFIX = {"pip": "", "uv": "uv run --no-sync ", "poetry": "poetry run "}
17
+ _TOOL_INSTALL = {"pip": "pip install", "uv": "uv pip install", "poetry": "poetry run pip install"}
18
+
19
+ # tool: (step name, package to install, command)
20
+ _LINT = {
21
+ "ruff": ("Ruff", "ruff", "ruff check{github} ."),
22
+ "ruff-format": ("Ruff format", "ruff", "ruff format --check --diff ."),
23
+ "flake8": ("Flake8", "flake8", "flake8 ."),
24
+ "black": ("Black", "black", "black --check --diff ."),
25
+ "mypy": ("Mypy", "mypy", "mypy {source_dir}"),
26
+ }
27
+
28
+
29
+ class PythonCI(CISteps):
30
+ version_key = "python-version"
31
+ codeql_language = "python"
32
+
33
+ def setup(self, cfg: CIConfig, version: Optional[str]) -> List[Item]:
34
+ if cfg.package_manager == "uv":
35
+ with_: Dict[str, Any] = {}
36
+ if version:
37
+ with_["python-version"] = version
38
+ if cfg.cache:
39
+ with_["enable-cache"] = True
40
+ if cfg.directory != ".":
41
+ with_["working-directory"] = cfg.directory
42
+ return [step("Set up uv", uses=ACTIONS["setup-uv"], with_=with_ or None)]
43
+
44
+ steps = []
45
+ if cfg.package_manager == "poetry":
46
+ steps.append(step("Install Poetry", run="pipx install poetry"))
47
+ with_ = {"python-version": version} if version else {"python-version-file": cfg.path(cfg.version_file)}
48
+ if cfg.cache:
49
+ with_["cache"] = cfg.package_manager
50
+ if cfg.package_manager == "pip":
51
+ with_["cache-dependency-path"] = f"{cfg.path('**/requirements*.txt')}\n{cfg.path('**/pyproject.toml')}"
52
+ elif cfg.directory != ".":
53
+ with_["cache-dependency-path"] = cfg.path("poetry.lock")
54
+ steps.append(step("Set up Python", uses=ACTIONS["setup-python"], with_=with_))
55
+ return steps
56
+
57
+ def image(self, cfg: CIConfig, version: str) -> str:
58
+ if cfg.package_manager == "uv":
59
+ return f"ghcr.io/astral-sh/uv:python{version}-bookworm"
60
+ return f"python:{version}"
61
+
62
+ def image_setup(self, cfg: CIConfig) -> List[Step]:
63
+ if cfg.package_manager == "poetry":
64
+ return [step("Install Poetry", run="pip install poetry")]
65
+ return []
66
+
67
+ def cache(self, cfg: CIConfig) -> Optional[Cache]:
68
+ if not cfg.cache:
69
+ return None
70
+ if cfg.package_manager == "pip":
71
+ return Cache("pip", (".cache/pip",), env={"PIP_CACHE_DIR": f"{ROOT}/.cache/pip"})
72
+ # The virtualenv itself, in the project (linters and pytest skip .venv).
73
+ lockfile = "uv.lock" if cfg.package_manager == "uv" else "poetry.lock"
74
+ env = {"POETRY_VIRTUALENVS_IN_PROJECT": "true"} if cfg.package_manager == "poetry" else {}
75
+ return Cache("venv", (".venv",), key_files=(lockfile,), env=env)
76
+
77
+ def install(self, cfg: CIConfig, packages: Sequence[str] = ()) -> List[Item]:
78
+ steps = [step("Install dependencies", run=cfg.install)]
79
+ if packages:
80
+ steps.append(step("Install CI tools", run=f"{_TOOL_INSTALL[cfg.package_manager]} {' '.join(packages)}"))
81
+ return steps
82
+
83
+ def exec(self, cfg: CIConfig, command: str) -> str:
84
+ return _RUN_PREFIX[cfg.package_manager] + command
85
+
86
+ def lint(self, cfg: CIConfig, tools: List[str]) -> List[Item]:
87
+ specs = [_LINT[t] for t in tools]
88
+ steps = self.install(cfg, sorted({package for _, package, _ in specs}))
89
+ github = on_github(cfg, " --output-format=github")
90
+ for name, _, cmd in specs:
91
+ steps.append(step(name, run=self.exec(cfg, cmd.format(source_dir=cfg.source_dir, github=github))))
92
+ return steps
93
+
94
+ def test(self, cfg: CIConfig) -> List[Item]:
95
+ t = cfg.test
96
+ cmd = self.exec(cfg, "pytest")
97
+ if t.coverage:
98
+ cmd += (
99
+ f" --cov={cfg.source_dir} --cov-report=xml --cov-report=term-missing"
100
+ f" --cov-fail-under={t.coverage_threshold}"
101
+ )
102
+ if t.reports:
103
+ cmd += " --junitxml=junit.xml"
104
+ steps = self.install(cfg, ["pytest", "pytest-cov"] if t.coverage else ["pytest"])
105
+ steps.append(step("Tests" + (" with coverage" if t.coverage else ""), run=cmd, secrets=t.env_secrets))
106
+ if t.coverage:
107
+ steps.append(coverage_summary(cfg))
108
+ return steps
109
+
110
+ def coverage_file(self, cfg: CIConfig) -> str:
111
+ return "coverage.xml"
112
+
113
+ def reports(self, cfg: CIConfig) -> TestReports:
114
+ coverage = cfg.test.coverage
115
+ return TestReports(
116
+ junit="junit.xml" if cfg.test.runner == "pytest" else "",
117
+ cobertura="coverage.xml" if coverage else "",
118
+ coverage_regex=COVERAGE_REGEX if coverage else "",
119
+ )
120
+
121
+ def audit(self, cfg: CIConfig) -> List[Item]:
122
+ if cfg.package_manager == "uv":
123
+ # uv venvs have no pip, so audit the locked requirements instead of the environment.
124
+ return [
125
+ step(
126
+ "Export locked dependencies",
127
+ run="uv export --format requirements-txt --no-emit-project --output-file requirements-audit.txt",
128
+ ),
129
+ step(
130
+ "Dependency vulnerability scan",
131
+ run="uvx pip-audit --requirement requirements-audit.txt --require-hashes --disable-pip",
132
+ ),
133
+ ]
134
+ steps = self.install(cfg, ["pip-audit"])
135
+ steps.append(step("Dependency vulnerability scan", run=self.exec(cfg, "pip-audit --skip-editable")))
136
+ return steps
137
+
138
+ def build(self, cfg: CIConfig) -> List[Item]:
139
+ return [step("Build", run=cfg.build.command)]
140
+
141
+ def build_artifacts(self, det: Detection) -> List[str]:
142
+ # Wheels and source distributions (uv build, python -m build, poetry build).
143
+ return ["dist/"] if "build" in det.build_command else []
144
+
145
+ def dependabot_ecosystem(self, cfg: CIConfig) -> str:
146
+ return "uv" if cfg.package_manager == "uv" else "pip"
147
+
148
+
149
+ def coverage_summary(cfg: CIConfig) -> Step:
150
+ """Coverage table in the job summary page, from the data left by pytest-cov or `coverage run`."""
151
+ return step(
152
+ "Coverage summary",
153
+ run=_RUN_PREFIX[cfg.package_manager] + 'coverage report --format=markdown >> "$GITHUB_STEP_SUMMARY"',
154
+ always=True,
155
+ optional=True,
156
+ summary=True,
157
+ )
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import List, Tuple
5
+
6
+ from quoll.languages.base import Detection
7
+ from quoll.project import Project
8
+ from quoll.services import detect_services
9
+
10
+ # Used when the project doesn't say which version it targets.
11
+ PYTHON_LATEST = "3.14"
12
+ PYTHON_OLDEST = "3.9"
13
+
14
+ _REQUIREMENT_FILES = (
15
+ "requirements.txt",
16
+ "requirements-dev.txt",
17
+ "requirements-test.txt",
18
+ "dev-requirements.txt",
19
+ "requirements/dev.txt",
20
+ "requirements/test.txt",
21
+ )
22
+ # Client libraries revealing a backing service.
23
+ _SERVICE_PACKAGES = {
24
+ "postgres": ("psycopg", "psycopg2", "psycopg2-binary", "psycopg-binary", "asyncpg"),
25
+ "mysql": ("mysqlclient", "pymysql", "aiomysql", "mysql-connector-python"),
26
+ "redis": ("redis", "django-redis", "hiredis"),
27
+ "mongodb": ("pymongo", "motor", "mongoengine", "beanie"),
28
+ }
29
+ # Top-level packages that are never the code under test.
30
+ _NOT_SOURCE = ("tests", "test", "docs", "scripts")
31
+
32
+
33
+ def detect(project: Project) -> Detection:
34
+ det = Detection()
35
+ pyproject = project.read_toml("pyproject.toml")
36
+ tool = pyproject.get("tool", {})
37
+ poetry = tool.get("poetry", {})
38
+ meta = pyproject.get("project", {})
39
+
40
+ _package_manager(project, tool, det)
41
+ det.install = _install_command(project, pyproject, det)
42
+
43
+ requires = meta.get("requires-python") or poetry.get("dependencies", {}).get("python", "")
44
+ low, high = _python_bounds(requires if isinstance(requires, str) else "")
45
+ det.matrix_versions = [f"3.{m}" for m in range(low, high + 1)]
46
+ if project.exists(".python-version"):
47
+ det.version_file = ".python-version"
48
+ pinned = project.read_text(".python-version").split()
49
+ det.version = _major_minor(pinned[0]) if pinned else det.matrix_versions[-1]
50
+ else:
51
+ det.version = det.matrix_versions[-1]
52
+
53
+ det.source_dir = _source_dir(project, meta.get("name") or poetry.get("name", ""))
54
+ det.lint_tools = _lint_tools(project, tool)
55
+
56
+ has_tests = (
57
+ project.is_dir("tests")
58
+ or project.is_dir("test")
59
+ or project.exists("conftest.py")
60
+ or "pytest" in tool
61
+ or has_dependency(project, "pytest")
62
+ )
63
+ if has_tests:
64
+ det.test_runner = "pytest"
65
+ else:
66
+ det.warnings.append("No tests found (tests/ directory or pytest dependency) — the test job is disabled.")
67
+
68
+ packages = [p for names in _SERVICE_PACKAGES.values() for p in names if has_dependency(project, p)]
69
+ det.services = detect_services(packages, _SERVICE_PACKAGES)
70
+
71
+ if "build-system" in pyproject:
72
+ det.build_command = {
73
+ "uv": "uv build",
74
+ "poetry": "poetry build",
75
+ "pip": "pip install build\npython -m build",
76
+ }[det.package_manager]
77
+ return det
78
+
79
+
80
+ def _package_manager(project: Project, tool: dict, det: Detection) -> None:
81
+ if project.exists("uv.lock") or "uv" in tool:
82
+ det.package_manager = "uv"
83
+ det.lockfile = "uv.lock" if project.exists("uv.lock") else ""
84
+ elif project.exists("poetry.lock") or "poetry" in tool:
85
+ det.package_manager = "poetry"
86
+ det.lockfile = "poetry.lock" if project.exists("poetry.lock") else ""
87
+ else:
88
+ det.package_manager = "pip"
89
+
90
+
91
+ def _install_command(project: Project, pyproject: dict, det: Detection) -> str:
92
+ if det.package_manager == "uv":
93
+ if det.lockfile:
94
+ return "uv sync --locked --all-extras"
95
+ det.warnings.append("No uv.lock found — run `uv lock` and commit it for reproducible builds.")
96
+ return "uv sync --all-extras"
97
+ if det.package_manager == "poetry":
98
+ if not det.lockfile:
99
+ det.warnings.append("No poetry.lock found — run `poetry lock` and commit it for reproducible builds.")
100
+ return "poetry install --no-interaction --all-extras"
101
+
102
+ if not project.exists("requirements.txt", "pyproject.toml", "setup.py"):
103
+ det.warnings.append("No requirements.txt or installable pyproject.toml found — check `install` in quoll.toml.")
104
+ lines = ["python -m pip install --upgrade pip"]
105
+ req_files = [f for f in _REQUIREMENT_FILES if project.exists(f)]
106
+ if req_files:
107
+ lines.append("pip install " + " ".join(f"-r {f}" for f in req_files))
108
+ if "build-system" in pyproject or project.exists("setup.py"):
109
+ optional = pyproject.get("project", {}).get("optional-dependencies", {})
110
+ extras = [e for e in ("dev", "test", "tests") if e in optional]
111
+ target = f'".[{",".join(extras)}]"' if extras else "."
112
+ lines.append(f"pip install -e {target}")
113
+ groups = [g for g in ("dev", "test") if g in pyproject.get("dependency-groups", {})]
114
+ if groups:
115
+ lines.append("pip install " + " ".join(f"--group {g}" for g in groups))
116
+ return "\n".join(lines)
117
+
118
+
119
+ def _lint_tools(project: Project, tool: dict) -> List[str]:
120
+ precommit = project.read_text(".pre-commit-config.yaml")
121
+ setup_cfg = project.read_text("setup.cfg")
122
+ tox_ini = project.read_text("tox.ini")
123
+ tools: List[str] = []
124
+ if "ruff" in tool or project.exists("ruff.toml", ".ruff.toml") or has_dependency(project, "ruff"):
125
+ tools.append("ruff")
126
+ if "format" in tool.get("ruff", {}) or "ruff-format" in precommit:
127
+ tools.append("ruff-format")
128
+ if project.exists(".flake8") or "[flake8]" in setup_cfg or "[flake8]" in tox_ini:
129
+ tools.append("flake8")
130
+ if "black" in tool or re.search(r"\bblack\b", precommit):
131
+ tools.append("black")
132
+ if "mypy" in tool or project.exists("mypy.ini", ".mypy.ini") or "[mypy]" in setup_cfg:
133
+ tools.append("mypy")
134
+ return tools or ["ruff"]
135
+
136
+
137
+ def _python_bounds(spec: str) -> Tuple[int, int]:
138
+ oldest = int(PYTHON_OLDEST.split(".")[1])
139
+ latest = int(PYTHON_LATEST.split(".")[1])
140
+ low, high = latest, latest
141
+ m = re.search(r"(?:>=|\^|~=?|==)\s*3\.(\d+)", spec)
142
+ if m:
143
+ low = max(int(m.group(1)), oldest)
144
+ m = re.search(r"<\s*3\.(\d+)", spec)
145
+ if m:
146
+ high = min(int(m.group(1)) - 1, latest)
147
+ m = re.search(r"<=\s*3\.(\d+)", spec)
148
+ if m:
149
+ high = min(int(m.group(1)), latest)
150
+ return min(low, high), high
151
+
152
+
153
+ def _source_dir(project: Project, project_name: str) -> str:
154
+ if project.is_dir("src"):
155
+ return "src"
156
+ for name in (project_name.replace("-", "_").lower(), project.name.replace("-", "_").lower()):
157
+ if name and project.exists(f"{name}/__init__.py"):
158
+ return name
159
+ packages = [p.parent.name for p in project.glob("*/__init__.py") if p.parent.name not in _NOT_SOURCE]
160
+ return packages[0] if len(packages) == 1 else "."
161
+
162
+
163
+ def has_dependency(project: Project, name: str) -> bool:
164
+ """True when `name` (exactly, not `name-extra`) appears in pyproject.toml or a requirements file."""
165
+ return bool(re.search(rf"(?<![\w-]){re.escape(name)}(?![\w-])", deps_text(project)))
166
+
167
+
168
+ def deps_text(project: Project) -> str:
169
+ files = [project.read_text("pyproject.toml")]
170
+ files += [p.read_text() for p in project.glob("requirements*.txt", "requirements/*.txt")]
171
+ return "\n".join(files).lower()
172
+
173
+
174
+ def _major_minor(version: str) -> str:
175
+ return ".".join(version.strip().split(".")[:2])
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
5
+
6
+ from quoll.languages.base import Detection, DockerDefaults, DockerImage
7
+ from quoll.project import Project
8
+
9
+ if TYPE_CHECKING:
10
+ from quoll.docker.config import DockerConfig
11
+
12
+ UV_IMAGE = "ghcr.io/astral-sh/uv:0.13"
13
+
14
+ _LOCKFILES = {"uv": "uv.lock", "poetry": "poetry.lock"}
15
+ # Where an application module may live, shallowest first.
16
+ _MODULE_GLOBS = ("*.py", "*/*.py", "src/*/*.py", "*/*/*.py", "src/*/*/*.py")
17
+ _NOT_APP_DIRS = {"tests", "test", ".venv", "venv", "node_modules", "migrations", "docs"}
18
+
19
+
20
+ class PythonImage(DockerImage):
21
+ def defaults(self, project: Project, det: Detection) -> DockerDefaults:
22
+ command = _default_command(project)
23
+ warnings = [] if command else ["Couldn't infer how to start the app — set docker.command in quoll.toml."]
24
+ return DockerDefaults(port=8000, command=command, warnings=warnings)
25
+
26
+ def context(self, project: Project, cfg: DockerConfig) -> Dict[str, Any]:
27
+ lockfile = _LOCKFILES.get(cfg.package_manager, "")
28
+ return {
29
+ "uv_image": UV_IMAGE,
30
+ "lockfile": bool(lockfile) and project.exists(lockfile),
31
+ "requirements": project.exists("requirements.txt"),
32
+ "installable": "build-system" in project.read_toml("pyproject.toml") or project.exists("setup.py"),
33
+ "src_layout": project.is_dir("src"),
34
+ }
35
+
36
+ def dockerignore(self, cfg: DockerConfig) -> List[str]:
37
+ return [
38
+ ".venv",
39
+ "**/__pycache__",
40
+ "**/*.py[co]",
41
+ ".pytest_cache",
42
+ ".mypy_cache",
43
+ ".ruff_cache",
44
+ ".coverage",
45
+ "htmlcov",
46
+ "dist",
47
+ "*.egg-info",
48
+ ".env",
49
+ ".env.*",
50
+ "!.env.example",
51
+ ]
52
+
53
+
54
+ def find_app(project: Project, pattern: str) -> Optional[Tuple[str, str]]:
55
+ """(module, name) of the first `<name> = <pattern>` assignment, e.g. ("app.main", "app") for FastAPI()."""
56
+ regex = re.compile(rf"^(\w+)\s*(?::\s*\w+\s*)?=\s*{pattern}", re.M)
57
+ for path in project.glob(*_MODULE_GLOBS):
58
+ rel = path.relative_to(project.root)
59
+ if _NOT_APP_DIRS & set(rel.parts):
60
+ continue
61
+ m = regex.search(path.read_text(errors="ignore"))
62
+ if m:
63
+ parts = list(rel.with_suffix("").parts)
64
+ if parts[0] == "src": # importable through PYTHONPATH=/app/src
65
+ parts = parts[1:]
66
+ return ".".join(parts), m.group(1)
67
+ return None
68
+
69
+
70
+ def _default_command(project: Project) -> str:
71
+ scripts = project.read_toml("pyproject.toml").get("project", {}).get("scripts", {})
72
+ if scripts:
73
+ return next(iter(scripts))
74
+ for main in project.glob("*/__main__.py", "src/*/__main__.py"):
75
+ if main.parent.name not in _NOT_APP_DIRS:
76
+ return f"python -m {main.parent.name}"
77
+ for name in ("main.py", "app.py", "src/main.py", "src/app.py"):
78
+ if project.exists(name):
79
+ return f"python {name}"
80
+ return ""
@@ -0,0 +1 @@
1
+ """Python frameworks."""
@@ -0,0 +1,16 @@
1
+ """Django: system checks and missing migrations fail the lint job; tests run through manage.py or pytest-django."""
2
+
3
+ from quoll.languages.base import Framework
4
+ from quoll.languages.python.frameworks.django.ci import DjangoCI
5
+ from quoll.languages.python.frameworks.django.detect import detect
6
+ from quoll.languages.python.frameworks.django.docker import DjangoImage
7
+
8
+ framework = Framework(
9
+ name="django",
10
+ language="python",
11
+ detect=detect,
12
+ ci=DjangoCI(),
13
+ lint_tools=("django-check", "django-migrations"),
14
+ test_runners=("django",),
15
+ docker=DjangoImage(),
16
+ )
@@ -0,0 +1,43 @@
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.python.ci import coverage_summary
8
+
9
+ if TYPE_CHECKING:
10
+ from quoll.ci.config import CIConfig
11
+
12
+ # tool: (step name, command)
13
+ _LINT = {
14
+ "django-check": ("Django system checks", "python manage.py check"),
15
+ "django-migrations": ("Missing migrations", "python manage.py makemigrations --check --dry-run"),
16
+ }
17
+
18
+
19
+ class DjangoCI(FrameworkCI):
20
+ def lint(self, cfg: CIConfig, tool: str) -> List[Item]:
21
+ name, cmd = _LINT[tool]
22
+ return [step(name, run=cfg.language.ci.exec(cfg, cmd))]
23
+
24
+ def test(self, cfg: CIConfig) -> List[Item]:
25
+ t = cfg.test
26
+ ci = cfg.language.ci
27
+ if not t.coverage:
28
+ return [*ci.install(cfg), step("Tests", run=ci.exec(cfg, "python manage.py test"), secrets=t.env_secrets)]
29
+ return [
30
+ *ci.install(cfg, ["coverage"]),
31
+ step(
32
+ "Tests with coverage",
33
+ run=ci.exec(cfg, f"coverage run --source={cfg.source_dir} manage.py test"),
34
+ secrets=t.env_secrets,
35
+ ),
36
+ step(
37
+ "Coverage threshold",
38
+ run="\n".join(
39
+ [ci.exec(cfg, "coverage xml"), ci.exec(cfg, f"coverage report --fail-under={t.coverage_threshold}")]
40
+ ),
41
+ ),
42
+ coverage_summary(cfg),
43
+ ]
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from quoll.languages.base import Detection
4
+ from quoll.languages.python.detect import has_dependency
5
+ from quoll.project import Project
6
+
7
+
8
+ def detect(project: Project, det: Detection) -> bool:
9
+ if not has_dependency(project, "django") or not project.exists("manage.py"):
10
+ return False
11
+ det.lint_tools += ["django-check", "django-migrations"]
12
+ # Apps sit next to the settings package, so measure the whole project.
13
+ det.source_dir = "."
14
+ # pytest-django brings pytest; otherwise manage.py finds the tests.py of each app.
15
+ det.test_runner = "pytest" if has_dependency(project, "pytest-django") else "django"
16
+ det.warnings = [w for w in det.warnings if not w.startswith("No tests found")]
17
+ return True
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from quoll.languages.base import Detection, DockerDefaults
6
+ from quoll.languages.python.detect import has_dependency
7
+ from quoll.languages.python.docker import PythonImage
8
+ from quoll.project import Project
9
+
10
+
11
+ class DjangoImage(PythonImage):
12
+ def defaults(self, project: Project, det: Detection) -> DockerDefaults:
13
+ m = re.search(r"DJANGO_SETTINGS_MODULE[\"']\s*,\s*[\"']([\w.]+)\.settings", project.read_text("manage.py"))
14
+ module = m.group(1) if m else project.name.replace("-", "_")
15
+ warnings = []
16
+ if not has_dependency(project, "gunicorn"):
17
+ warnings.append("Add gunicorn to your dependencies: the image serves the app with it.")
18
+ if not has_dependency(project, "whitenoise"):
19
+ warnings.append("Django doesn't serve static files in production — add WhiteNoise or serve them elsewhere.")
20
+ return DockerDefaults(port=8000, command=f"gunicorn {module}.wsgi --bind 0.0.0.0:8000", warnings=warnings)
@@ -0,0 +1,7 @@
1
+ """FastAPI: CI is plain Python; the framework matters for how the app is served."""
2
+
3
+ from quoll.languages.base import Framework
4
+ from quoll.languages.python.frameworks.fastapi.detect import detect
5
+ from quoll.languages.python.frameworks.fastapi.docker import FastapiImage
6
+
7
+ framework = Framework(name="fastapi", language="python", detect=detect, docker=FastapiImage())
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from quoll.languages.base import Detection
4
+ from quoll.languages.python.detect import has_dependency
5
+ from quoll.project import Project
6
+
7
+
8
+ def detect(project: Project, det: Detection) -> bool:
9
+ return has_dependency(project, "fastapi")
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from quoll.languages.base import Detection, DockerDefaults
4
+ from quoll.languages.python.detect import deps_text, has_dependency
5
+ from quoll.languages.python.docker import PythonImage, find_app
6
+ from quoll.project import Project
7
+
8
+
9
+ class FastapiImage(PythonImage):
10
+ def defaults(self, project: Project, det: Detection) -> DockerDefaults:
11
+ warnings = []
12
+ app = find_app(project, r"FastAPI\(")
13
+ if app is None:
14
+ app = ("main", "app")
15
+ warnings.append("No `app = FastAPI()` found — check docker.command in quoll.toml.")
16
+ if not has_dependency(project, "uvicorn") and "fastapi[standard" not in deps_text(project):
17
+ warnings.append("Add uvicorn (or fastapi[standard]) to your dependencies: the image runs the app with it.")
18
+ module, name = app
19
+ return DockerDefaults(
20
+ port=8000, command=f"uvicorn {module}:{name} --host 0.0.0.0 --port 8000", warnings=warnings
21
+ )
@@ -0,0 +1,7 @@
1
+ """Flask: CI is plain Python; the framework matters for how the app is served."""
2
+
3
+ from quoll.languages.base import Framework
4
+ from quoll.languages.python.frameworks.flask.detect import detect
5
+ from quoll.languages.python.frameworks.flask.docker import FlaskImage
6
+
7
+ framework = Framework(name="flask", language="python", detect=detect, docker=FlaskImage())
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from quoll.languages.base import Detection
4
+ from quoll.languages.python.detect import has_dependency
5
+ from quoll.project import Project
6
+
7
+
8
+ def detect(project: Project, det: Detection) -> bool:
9
+ return has_dependency(project, "flask")
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from quoll.languages.base import Detection, DockerDefaults
4
+ from quoll.languages.python.detect import has_dependency
5
+ from quoll.languages.python.docker import PythonImage, find_app
6
+ from quoll.project import Project
7
+
8
+
9
+ class FlaskImage(PythonImage):
10
+ def defaults(self, project: Project, det: Detection) -> DockerDefaults:
11
+ warnings = []
12
+ app = find_app(project, r"Flask\(")
13
+ if app:
14
+ target = f"{app[0]}:{app[1]}"
15
+ else:
16
+ factory = find_app(project, r"create_app\(") # e.g. `app = create_app()` in wsgi.py
17
+ target = f"{factory[0]}:{factory[1]}" if factory else "app:app"
18
+ if not factory:
19
+ warnings.append("No `app = Flask(...)` found — check docker.command in quoll.toml.")
20
+ if not has_dependency(project, "gunicorn"):
21
+ warnings.append("Add gunicorn to your dependencies: the image serves the app with it.")
22
+ return DockerDefaults(port=8000, command=f"gunicorn {target} --bind 0.0.0.0:8000", warnings=warnings)
@@ -0,0 +1,45 @@
1
+ {% block base %}
2
+ FROM ruby:{{ cfg.version }}-slim AS base
3
+ {{ os_updates }}
4
+ WORKDIR {{ workdir }}
5
+ {% if runtime_packages %}
6
+ RUN apt-get update -qq \
7
+ && apt-get install --no-install-recommends -y {{ runtime_packages | join(" ") }} \
8
+ && rm -rf /var/lib/apt/lists/*
9
+ {% endif %}
10
+ ENV {% for key, value in env.items() %}{{ key }}={{ value }}{{ " \\\n " if not loop.last }}{% endfor %}
11
+
12
+ {% endblock %}
13
+
14
+ {% block build %}
15
+ FROM base AS build
16
+ RUN apt-get update -qq \
17
+ && apt-get install --no-install-recommends -y {{ (["build-essential", "git", "pkg-config", "libyaml-dev"] + build_packages) | join(" ") }} \
18
+ && rm -rf /var/lib/apt/lists/*
19
+ {% if gemspec %}
20
+ # The Gemfile reads the gemspec, which needs the sources.
21
+ COPY . .
22
+ RUN {{ bundle_install | join(" \\\n && ") }}
23
+ {% else %}
24
+ # Dependencies first: code changes don't reinstall them.
25
+ COPY Gemfile {{ "Gemfile.lock " if lockfile }}{{ ".ruby-version " if ruby_version_file }}./
26
+ RUN {{ bundle_install | join(" \\\n && ") }}
27
+ COPY . .
28
+ {% endif %}
29
+ {% for command in after_copy %}
30
+ RUN {{ command }}
31
+ {% endfor %}
32
+ {% endblock %}
33
+
34
+ {% block runtime %}
35
+ FROM base
36
+ {% block user %}
37
+ # A numeric user: Kubernetes can then check it isn't root (runAsNonRoot).
38
+ RUN groupadd --system --gid 10001 app && useradd --system --uid 10001 --gid app --no-create-home app
39
+ COPY --from=build /usr/local/bundle /usr/local/bundle
40
+ COPY --from=build --chown=app:app {{ workdir }} {{ workdir }}
41
+ USER 10001:10001
42
+ {% endblock %}
43
+ EXPOSE {{ cfg.port }}
44
+ CMD {{ cfg.command | exec_form }}
45
+ {% endblock %}
@@ -0,0 +1,19 @@
1
+ """Ruby: Bundler projects and gems."""
2
+
3
+ from quoll.languages.base import Language
4
+ from quoll.languages.ruby.ci import RubyCI
5
+ from quoll.languages.ruby.detect import detect
6
+ from quoll.languages.ruby.docker import RubyImage
7
+ from quoll.languages.ruby.frameworks import rails
8
+
9
+ language = Language(
10
+ name="ruby",
11
+ markers=("Gemfile",),
12
+ package_managers=("bundler",),
13
+ lint_tools=("rubocop", "standard"),
14
+ test_runners=("rspec", "minitest"),
15
+ detect=detect,
16
+ ci=RubyCI(),
17
+ frameworks=(rails.framework,),
18
+ docker=RubyImage(),
19
+ )