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.
- quoll/__init__.py +0 -0
- quoll/cd/__init__.py +1 -0
- quoll/cd/config.py +75 -0
- quoll/ci/__init__.py +0 -0
- quoll/ci/config.py +213 -0
- quoll/ci/custom.py +32 -0
- quoll/ci/detect.py +139 -0
- quoll/ci/steps.py +85 -0
- quoll/ci/tools.py +104 -0
- quoll/commands/__init__.py +0 -0
- quoll/commands/_common.py +145 -0
- quoll/commands/_workspace.py +83 -0
- quoll/commands/add.py +104 -0
- quoll/commands/add_secret.py +75 -0
- quoll/commands/cd.py +214 -0
- quoll/commands/ci.py +521 -0
- quoll/commands/docker.py +244 -0
- quoll/commands/k8s.py +211 -0
- quoll/commands/new.py +337 -0
- quoll/commands/remove.py +79 -0
- quoll/config/__init__.py +0 -0
- quoll/config/quoll_toml.py +196 -0
- quoll/config/settings.py +22 -0
- quoll/config/store.py +39 -0
- quoll/console.py +3 -0
- quoll/docker/__init__.py +1 -0
- quoll/docker/config.py +64 -0
- quoll/docker/detect.py +41 -0
- quoll/docker/render.py +162 -0
- quoll/files.py +64 -0
- quoll/k8s/__init__.py +0 -0
- quoll/k8s/chart/templates/_helpers.tpl +38 -0
- quoll/k8s/chart/templates/deployment.yaml +93 -0
- quoll/k8s/chart/templates/hpa.yaml +24 -0
- quoll/k8s/chart/templates/ingress.yaml +35 -0
- quoll/k8s/chart/templates/pdb.yaml +16 -0
- quoll/k8s/chart/templates/service.yaml +18 -0
- quoll/k8s/chart/templates/serviceaccount.yaml +10 -0
- quoll/k8s/ci.py +39 -0
- quoll/k8s/config.py +97 -0
- quoll/k8s/deploy.py +22 -0
- quoll/k8s/detect.py +32 -0
- quoll/k8s/render.py +94 -0
- quoll/languages/__init__.py +121 -0
- quoll/languages/base.py +350 -0
- quoll/languages/cpp/Dockerfile.j2 +37 -0
- quoll/languages/cpp/__init__.py +17 -0
- quoll/languages/cpp/ci.py +192 -0
- quoll/languages/cpp/detect.py +84 -0
- quoll/languages/cpp/docker.py +47 -0
- quoll/languages/dotnet/Dockerfile.j2 +21 -0
- quoll/languages/dotnet/__init__.py +17 -0
- quoll/languages/dotnet/ci.py +88 -0
- quoll/languages/dotnet/detect.py +53 -0
- quoll/languages/dotnet/docker.py +47 -0
- quoll/languages/go/Dockerfile.j2 +25 -0
- quoll/languages/go/__init__.py +17 -0
- quoll/languages/go/ci.py +126 -0
- quoll/languages/go/detect.py +30 -0
- quoll/languages/go/docker.py +40 -0
- quoll/languages/java/Dockerfile.j2 +28 -0
- quoll/languages/java/__init__.py +20 -0
- quoll/languages/java/ci.py +163 -0
- quoll/languages/java/detect.py +77 -0
- quoll/languages/java/docker.py +45 -0
- quoll/languages/java/frameworks/__init__.py +1 -0
- quoll/languages/java/frameworks/spring_boot/Dockerfile.j2 +18 -0
- quoll/languages/java/frameworks/spring_boot/__init__.py +10 -0
- quoll/languages/java/frameworks/spring_boot/detect.py +9 -0
- quoll/languages/java/frameworks/spring_boot/docker.py +19 -0
- quoll/languages/java/frameworks/spring_boot/services.py +24 -0
- quoll/languages/node/Dockerfile.j2 +98 -0
- quoll/languages/node/__init__.py +29 -0
- quoll/languages/node/ci.py +200 -0
- quoll/languages/node/detect.py +164 -0
- quoll/languages/node/docker.py +141 -0
- quoll/languages/node/frameworks/__init__.py +1 -0
- quoll/languages/node/frameworks/angular/__init__.py +18 -0
- quoll/languages/node/frameworks/angular/ci.py +34 -0
- quoll/languages/node/frameworks/angular/detect.py +20 -0
- quoll/languages/node/frameworks/angular/docker.py +31 -0
- quoll/languages/node/frameworks/angular/starter.py +74 -0
- quoll/languages/node/frameworks/astro/__init__.py +10 -0
- quoll/languages/node/frameworks/astro/ci.py +17 -0
- quoll/languages/node/frameworks/astro/detect.py +14 -0
- quoll/languages/node/frameworks/astro/docker.py +17 -0
- quoll/languages/node/frameworks/nestjs/__init__.py +7 -0
- quoll/languages/node/frameworks/nestjs/detect.py +9 -0
- quoll/languages/node/frameworks/nestjs/docker.py +10 -0
- quoll/languages/node/frameworks/nextjs/Dockerfile.j2 +14 -0
- quoll/languages/node/frameworks/nextjs/__init__.py +8 -0
- quoll/languages/node/frameworks/nextjs/ci.py +29 -0
- quoll/languages/node/frameworks/nextjs/detect.py +13 -0
- quoll/languages/node/frameworks/nextjs/docker.py +41 -0
- quoll/languages/node/frameworks/nuxt/__init__.py +10 -0
- quoll/languages/node/frameworks/nuxt/ci.py +17 -0
- quoll/languages/node/frameworks/nuxt/detect.py +17 -0
- quoll/languages/node/frameworks/nuxt/docker.py +24 -0
- quoll/languages/node/frameworks/sveltekit/__init__.py +15 -0
- quoll/languages/node/frameworks/sveltekit/ci.py +15 -0
- quoll/languages/node/frameworks/sveltekit/detect.py +18 -0
- quoll/languages/node/frameworks/sveltekit/docker.py +18 -0
- quoll/languages/node/starter.py +42 -0
- quoll/languages/node/templates/express/README.md +11 -0
- quoll/languages/node/templates/express/env.example +2 -0
- quoll/languages/node/templates/express/eslint.config.js +15 -0
- quoll/languages/node/templates/express/gitignore +4 -0
- quoll/languages/node/templates/express/nvmrc +1 -0
- quoll/languages/node/templates/express/package.json +16 -0
- quoll/languages/node/templates/express/prettierignore +6 -0
- quoll/languages/node/templates/express/src/app.ts +27 -0
- quoll/languages/node/templates/express/src/server.ts +11 -0
- quoll/languages/node/templates/express/test/app.test.ts +29 -0
- quoll/languages/node/templates/express/tsconfig.build.json +8 -0
- quoll/languages/node/templates/express/tsconfig.json +14 -0
- quoll/languages/node/templates/express/vitest.config.ts +12 -0
- quoll/languages/php/Dockerfile.j2 +40 -0
- quoll/languages/php/__init__.py +19 -0
- quoll/languages/php/ci.py +147 -0
- quoll/languages/php/detect.py +75 -0
- quoll/languages/php/docker.py +59 -0
- quoll/languages/php/frameworks/__init__.py +1 -0
- quoll/languages/php/frameworks/laravel/__init__.py +17 -0
- quoll/languages/php/frameworks/laravel/ci.py +24 -0
- quoll/languages/php/frameworks/laravel/detect.py +13 -0
- quoll/languages/php/frameworks/laravel/docker.py +29 -0
- quoll/languages/php/frameworks/laravel/services.py +25 -0
- quoll/languages/php/frameworks/symfony/__init__.py +15 -0
- quoll/languages/php/frameworks/symfony/ci.py +22 -0
- quoll/languages/php/frameworks/symfony/detect.py +17 -0
- quoll/languages/php/frameworks/symfony/docker.py +25 -0
- quoll/languages/python/Dockerfile.j2 +61 -0
- quoll/languages/python/__init__.py +19 -0
- quoll/languages/python/ci.py +157 -0
- quoll/languages/python/detect.py +175 -0
- quoll/languages/python/docker.py +80 -0
- quoll/languages/python/frameworks/__init__.py +1 -0
- quoll/languages/python/frameworks/django/__init__.py +16 -0
- quoll/languages/python/frameworks/django/ci.py +43 -0
- quoll/languages/python/frameworks/django/detect.py +17 -0
- quoll/languages/python/frameworks/django/docker.py +20 -0
- quoll/languages/python/frameworks/fastapi/__init__.py +7 -0
- quoll/languages/python/frameworks/fastapi/detect.py +9 -0
- quoll/languages/python/frameworks/fastapi/docker.py +21 -0
- quoll/languages/python/frameworks/flask/__init__.py +7 -0
- quoll/languages/python/frameworks/flask/detect.py +9 -0
- quoll/languages/python/frameworks/flask/docker.py +22 -0
- quoll/languages/ruby/Dockerfile.j2 +45 -0
- quoll/languages/ruby/__init__.py +19 -0
- quoll/languages/ruby/ci.py +110 -0
- quoll/languages/ruby/detect.py +72 -0
- quoll/languages/ruby/docker.py +63 -0
- quoll/languages/ruby/frameworks/__init__.py +1 -0
- quoll/languages/ruby/frameworks/rails/Dockerfile.j2 +16 -0
- quoll/languages/ruby/frameworks/rails/__init__.py +18 -0
- quoll/languages/ruby/frameworks/rails/ci.py +26 -0
- quoll/languages/ruby/frameworks/rails/detect.py +15 -0
- quoll/languages/ruby/frameworks/rails/docker.py +62 -0
- quoll/languages/ruby/frameworks/rails/services.py +10 -0
- quoll/languages/rust/Dockerfile.j2 +23 -0
- quoll/languages/rust/__init__.py +17 -0
- quoll/languages/rust/ci.py +125 -0
- quoll/languages/rust/detect.py +46 -0
- quoll/languages/rust/docker.py +38 -0
- quoll/main.py +50 -0
- quoll/platforms/__init__.py +62 -0
- quoll/platforms/base.py +103 -0
- quoll/platforms/github/__init__.py +4 -0
- quoll/platforms/github/actions.py +78 -0
- quoll/platforms/github/api.py +124 -0
- quoll/platforms/github/cd.py +265 -0
- quoll/platforms/github/ci.py +497 -0
- quoll/platforms/github/dependabot.yml.j2 +49 -0
- quoll/platforms/github/platform.py +52 -0
- quoll/platforms/github/workflow.py +74 -0
- quoll/platforms/gitlab/__init__.py +4 -0
- quoll/platforms/gitlab/api.py +158 -0
- quoll/platforms/gitlab/cd.py +226 -0
- quoll/platforms/gitlab/ci.py +400 -0
- quoll/platforms/gitlab/pipeline.py +136 -0
- quoll/platforms/gitlab/platform.py +54 -0
- quoll/project.py +60 -0
- quoll/services.py +117 -0
- quoll/starters.py +117 -0
- quoll/workspace.py +232 -0
- quoll/yaml_dump.py +137 -0
- quoll_cli-0.2.0.dist-info/METADATA +641 -0
- quoll_cli-0.2.0.dist-info/RECORD +191 -0
- quoll_cli-0.2.0.dist-info/WHEEL +4 -0
- quoll_cli-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|