"""CI/CD project specs: GitHub Actions, GitLab CI, CircleCI, Taskfile, JustFile, Tox."""
import os
import yaml
from projspec.proj.base import ParseFailed, ProjectSpec, ProjectExtra
from projspec.utils import AttrDict
[docs]
class GitHubActions(ProjectExtra):
"""GitHub Actions CI/CD workflows
Each YAML file under .github/workflows/ defines one workflow.
"""
icon = "🐙"
spec_doc = "https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions"
def match(self) -> bool:
# Check for the .github/workflows directory
workflows_dir = f"{self.proj.url}/.github/workflows"
try:
entries = self.proj.fs.ls(workflows_dir, detail=False)
return any(e.endswith((".yml", ".yaml")) for e in entries)
except (FileNotFoundError, NotADirectoryError, Exception):
return False
def parse(self) -> None:
from projspec.content.cicd import CIWorkflow
workflows_dir = f"{self.proj.url}/.github/workflows"
try:
entries = self.proj.fs.ls(workflows_dir, detail=False)
except Exception as exc:
raise ParseFailed(f"Could not list .github/workflows: {exc}") from exc
ci_workflows = AttrDict()
for entry in entries:
if not entry.endswith((".yml", ".yaml")):
continue
try:
with self.proj.fs.open(entry, "rt") as f:
wf = yaml.safe_load(f)
except Exception:
continue
if not isinstance(wf, dict):
continue
name = wf.get(
"name",
os.path.basename(entry).replace(".yml", "").replace(".yaml", ""),
)
on = wf.get("on", wf.get(True, {})) # 'on' is a YAML boolean alias
triggers = []
if isinstance(on, dict):
triggers = list(on.keys())
elif isinstance(on, list):
triggers = on
elif isinstance(on, str):
triggers = [on]
jobs = list(wf.get("jobs", {}).keys())
key = name.lower().replace(" ", "_").replace("-", "_")
ci_workflows[key] = CIWorkflow(
proj=self.proj,
name=name,
triggers=[str(t) for t in triggers],
jobs=jobs,
provider="github",
)
if not ci_workflows:
raise ParseFailed("No valid GitHub Actions workflows found")
self._contents = AttrDict(ci_workflow=ci_workflows)
self._artifacts = AttrDict()
@staticmethod
def _create(path: str) -> None:
"""Scaffold a minimal GitHub Actions CI workflow."""
workflows_dir = os.path.join(path, ".github", "workflows")
os.makedirs(workflows_dir, exist_ok=True)
with open(os.path.join(workflows_dir, "ci.yml"), "wt") as f:
f.write(
"name: CI\n"
"\n"
"on:\n"
" push:\n"
" branches: [main]\n"
" pull_request:\n"
" branches: [main]\n"
"\n"
"jobs:\n"
" test:\n"
" runs-on: ubuntu-latest\n"
" steps:\n"
" - uses: actions/checkout@v4\n"
" - name: Run tests\n"
" run: echo 'Add your test command here'\n"
)
[docs]
class GitLabCI(ProjectExtra):
"""GitLab CI/CD pipeline defined in .gitlab-ci.yml."""
icon = "🦊"
spec_doc = "https://docs.gitlab.com/ci/yaml/"
def match(self) -> bool:
return ".gitlab-ci.yml" in self.proj.basenames
def parse(self) -> None:
from projspec.content.cicd import CIWorkflow
try:
with self.proj.get_file(".gitlab-ci.yml") as f:
cfg = yaml.safe_load(f)
except Exception as exc:
raise ParseFailed(f"Could not read .gitlab-ci.yml: {exc}") from exc
if not isinstance(cfg, dict):
raise ParseFailed(".gitlab-ci.yml did not parse to a mapping")
stages = cfg.get("stages", [])
# Jobs are any top-level keys that are not reserved keywords
reserved = {
"stages",
"variables",
"include",
"workflow",
"default",
"image",
"services",
"before_script",
"after_script",
"cache",
}
jobs = [k for k in cfg if k not in reserved and not k.startswith(".")]
self._contents = AttrDict(
ci_workflow=CIWorkflow(
proj=self.proj,
name="GitLab CI",
triggers=stages,
jobs=jobs,
provider="gitlab",
)
)
self._artifacts = AttrDict()
@staticmethod
def _create(path: str) -> None:
"""Scaffold a minimal .gitlab-ci.yml."""
with open(os.path.join(path, ".gitlab-ci.yml"), "wt") as f:
f.write(
"stages:\n"
" - test\n"
"\n"
"test:\n"
" stage: test\n"
" script:\n"
" - echo 'Add your test command here'\n"
)
[docs]
class CircleCI(ProjectExtra):
"""CircleCI pipeline defined in .circleci/config.yml."""
icon = "⦿"
spec_doc = "https://circleci.com/docs/configuration-reference/"
def match(self) -> bool:
config_path = f"{self.proj.url}/.circleci/config.yml"
try:
return self.proj.fs.isfile(config_path)
except Exception:
return False
def parse(self) -> None:
from projspec.content.cicd import CIWorkflow
config_path = f"{self.proj.url}/.circleci/config.yml"
try:
with self.proj.fs.open(config_path, "rt") as f:
cfg = yaml.safe_load(f)
except Exception as exc:
raise ParseFailed(f"Could not read .circleci/config.yml: {exc}") from exc
if not isinstance(cfg, dict):
raise ParseFailed(".circleci/config.yml did not parse to a mapping")
jobs = list(cfg.get("jobs", {}).keys())
workflows = cfg.get("workflows", {})
workflow_names = [k for k in workflows if k != "version"]
self._contents = AttrDict(
ci_workflow=CIWorkflow(
proj=self.proj,
name="CircleCI",
triggers=workflow_names,
jobs=jobs,
provider="circleci",
)
)
self._artifacts = AttrDict()
@staticmethod
def _create(path: str) -> None:
"""Scaffold a minimal CircleCI config."""
circleci_dir = os.path.join(path, ".circleci")
os.makedirs(circleci_dir, exist_ok=True)
with open(os.path.join(circleci_dir, "config.yml"), "wt") as f:
f.write(
"version: 2.1\n"
"\n"
"jobs:\n"
" test:\n"
" docker:\n"
" - image: cimg/base:stable\n"
" steps:\n"
" - checkout\n"
" - run: echo 'Add your test command here'\n"
"\n"
"workflows:\n"
" main:\n"
" jobs:\n"
" - test\n"
)
[docs]
class Taskfile(ProjectSpec):
"""Task runner using Taskfile (go-task)."""
icon = "✅"
spec_doc = "https://taskfile.dev/reference/schema/"
_NAMES = {"Taskfile.yml", "Taskfile.yaml", "taskfile.yml", "taskfile.yaml"}
def match(self) -> bool:
return bool(self._NAMES.intersection(self.proj.basenames))
def parse(self) -> None:
from projspec.artifact.process import Process
from projspec.content.executable import Command
fname = next(n for n in self._NAMES if n in self.proj.basenames)
try:
with self.proj.get_file(fname) as f:
cfg = yaml.safe_load(f)
except Exception as exc:
raise ParseFailed(f"Could not read {fname}: {exc}") from exc
if not isinstance(cfg, dict):
raise ParseFailed(f"{fname} did not parse to a mapping")
tasks = cfg.get("tasks", {})
cmds = AttrDict()
arts = AttrDict()
for task_name, task_def in tasks.items():
if not task_name or task_name.startswith("_"):
continue
cmd = ["task", task_name]
cmds[task_name] = Command(proj=self.proj, cmd=cmd)
arts[task_name] = Process(proj=self.proj, cmd=cmd)
self._contents = AttrDict(command=cmds) if cmds else AttrDict()
self._artifacts = AttrDict(process=arts) if arts else AttrDict()
@staticmethod
def _create(path: str) -> None:
"""Scaffold a minimal Taskfile.yml."""
with open(os.path.join(path, "Taskfile.yml"), "wt") as f:
f.write(
"version: '3'\n"
"\n"
"tasks:\n"
" default:\n"
" desc: Default task\n"
" cmds:\n"
" - echo 'Hello from Taskfile!'\n"
"\n"
" test:\n"
" desc: Run tests\n"
" cmds:\n"
" - echo 'Add your test command here'\n"
)
[docs]
class JustFile(ProjectSpec):
"""Task runner using Just (justfile / Justfile).
A justfile defines named recipes that can be run with `just <recipe>`.
"""
icon = "▶️"
spec_doc = "https://just.systems/man/en/"
_NAMES = {"justfile", "Justfile", ".justfile"}
def match(self) -> bool:
return bool(self._NAMES.intersection(self.proj.basenames))
def parse(self) -> None:
import re
from projspec.artifact.process import Process
from projspec.content.executable import Command
fname = next(n for n in self._NAMES if n in self.proj.basenames)
try:
with self.proj.get_file(fname) as f:
text = f.read()
except Exception as exc:
raise ParseFailed(f"Could not read {fname}: {exc}") from exc
# Recipes are lines matching: recipe-name ...: (not starting with #/@/space)
recipe_names = re.findall(
r"^([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s.*)?:", text, re.MULTILINE
)
cmds = AttrDict()
arts = AttrDict()
for name in recipe_names:
cmd = ["just", name]
cmds[name] = Command(proj=self.proj, cmd=cmd)
arts[name] = Process(proj=self.proj, cmd=cmd)
self._contents = AttrDict(command=cmds) if cmds else AttrDict()
self._artifacts = AttrDict(process=arts) if arts else AttrDict()
@staticmethod
def _create(path: str) -> None:
"""Scaffold a minimal justfile."""
with open(os.path.join(path, "justfile"), "wt") as f:
f.write(
"# Default recipe\n"
"default:\n"
" echo 'Hello from just!'\n"
"\n"
"# Run tests\n"
"test:\n"
" echo 'Add your test command here'\n"
)
[docs]
class Tox(ProjectSpec):
"""Python test automation using tox.
A set of environments and run commands to be run as a workflow.
"""
icon = "🧪"
spec_doc = "https://tox.wiki/en/stable/config.html"
def match(self) -> bool:
if "tox.ini" in self.proj.basenames or "tox.toml" in self.proj.basenames:
return True
return bool(self.proj.pyproject.get("tool", {}).get("tox"))
def parse(self) -> None:
import configparser
import re
from projspec.artifact.process import Process
from projspec.content.executable import Command
env_names: list[str] = []
if "tox.ini" in self.proj.basenames:
try:
with self.proj.get_file("tox.ini") as f:
text = f.read()
cfg = configparser.ConfigParser()
cfg.read_string(text)
# envlist can be a comma/space/newline separated list with optional braces
envlist_raw = cfg.get("tox", "envlist", fallback="")
if envlist_raw:
# Strip braces notation like {py39,py310}-django
flat = re.sub(r"\{[^}]*\}", "", envlist_raw)
env_names = [
e.strip() for e in re.split(r"[,\s]+", flat) if e.strip()
]
# Also pick up [testenv:*] sections
for section in cfg.sections():
if section.startswith("testenv:"):
name = section[len("testenv:") :]
if name not in env_names:
env_names.append(name)
except Exception as exc:
raise ParseFailed(f"Could not parse tox.ini: {exc}") from exc
elif "tox.toml" in self.proj.basenames:
try:
import toml
from projspec.utils import PickleableTomlDecoder
with self.proj.get_file("tox.toml", text=False) as f:
cfg = toml.loads(f.read().decode(), decoder=PickleableTomlDecoder())
env_names = list(cfg.get("env", {}).keys())
except Exception as exc:
raise ParseFailed(f"Could not parse tox.toml: {exc}") from exc
else:
tox_cfg = self.proj.pyproject.get("tool", {}).get("tox", {})
env_names = list(tox_cfg.get("env", {}).keys())
cmds = AttrDict()
arts = AttrDict()
if not env_names:
# At minimum expose a generic tox run
cmds["tox"] = Command(proj=self.proj, cmd=["tox"])
arts["tox"] = Process(proj=self.proj, cmd=["tox"])
else:
for name in env_names:
cmd = ["tox", "-e", name]
cmds[name] = Command(proj=self.proj, cmd=cmd)
arts[name] = Process(proj=self.proj, cmd=cmd)
self._contents = AttrDict(command=cmds)
self._artifacts = AttrDict(process=arts)
@staticmethod
def _create(path: str) -> None:
"""Scaffold a minimal tox.ini."""
with open(os.path.join(path, "tox.ini"), "wt") as f:
f.write(
"[tox]\n"
"envlist = py311\n"
"\n"
"[testenv]\n"
"deps = pytest\n"
"commands = pytest {posargs}\n"
)