generic-gitlab-cicd 0.3.2__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.
@@ -0,0 +1,186 @@
1
+ """Temporary uv environments with workspace ownership and immutable candidates."""
2
+ import contextlib
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ import tomllib
11
+
12
+ import tomlkit
13
+ from packaging.requirements import Requirement
14
+
15
+ from .config import allowed_url
16
+ from .models import relative
17
+
18
+
19
+ def normalize(name):
20
+ return re.sub(r"[-_.]+", "-", name).lower()
21
+
22
+
23
+ def resolve_candidates(environment, hosts, projects):
24
+ single = [environment.get("CI_DEPENDENCY_" + key, "") for key in ("REPO", "REF", "PACKAGE")]
25
+ raw = environment.get("CI_DEPENDENCY_OVERRIDES", "[]") or "[]"
26
+ filename = environment.get("CI_DEPENDENCY_FILE", "")
27
+ decoded = json.loads(raw)
28
+ if not isinstance(decoded, list):
29
+ raise ValueError("CI_DEPENDENCY_OVERRIDES must be a JSON array")
30
+ if sum([bool(any(single)), bool(decoded), bool(filename)]) > 1:
31
+ raise ValueError("provide only one override input: single fields, JSON, or file")
32
+ if filename:
33
+ decoded = json.loads(Path(filename).read_text())
34
+ if any(single):
35
+ if not all(single):
36
+ raise ValueError("provide repository, ref and package together")
37
+ decoded = [dict(zip(("repository", "ref", "package"), single),
38
+ subdirectory=environment.get("CI_DEPENDENCY_SUBDIRECTORY", ""))]
39
+ if not isinstance(decoded, list):
40
+ raise ValueError("dependency file must contain a JSON array")
41
+ seen, result = set(), []
42
+ for item in decoded:
43
+ if not isinstance(item, dict) or set(item) - {"repository", "repo", "ref", "package", "subdirectory", "projects"}:
44
+ raise ValueError("override fields: repository, ref, package, subdirectory, projects")
45
+ if "repo" in item and "repository" in item:
46
+ raise ValueError("use repository or legacy repo, not both")
47
+ repo, ref, name = item.get("repository", item.get("repo", "")), item.get("ref", ""), item.get("package", "")
48
+ if not all(isinstance(v, str) for v in (repo, ref, name)):
49
+ raise ValueError("override repository/ref/package must be strings")
50
+ allowed_url(repo, hosts)
51
+ if not re.fullmatch(r"(?:@[A-Za-z0-9._-]+/)?[A-Za-z0-9][A-Za-z0-9._-]*", name):
52
+ raise ValueError("invalid override distribution name")
53
+ if normalize(name) in seen:
54
+ raise ValueError(f"duplicate override package {name}")
55
+ seen.add(normalize(name))
56
+ if not ref or ref.startswith("-") or re.search(r"[\s~^:?*\[\\]", ref):
57
+ raise ValueError("invalid dependency ref")
58
+ sub = item.get("subdirectory", "")
59
+ if sub:
60
+ relative(sub)
61
+ scope = item.get("projects", list(projects))
62
+ if not isinstance(scope, list) or not scope or any(p not in projects for p in scope):
63
+ raise ValueError("override projects must name existing projects")
64
+ if re.fullmatch(r"[a-fA-F0-9]{40}", ref):
65
+ sha = ref.lower()
66
+ else:
67
+ wanted = [ref] if ref.startswith("refs/") else ["refs/heads/" + ref, "refs/tags/" + ref]
68
+ output = subprocess.check_output(["git", "ls-remote", "--exit-code", repo, *wanted, *[v + "^{}" for v in wanted]], text=True)
69
+ matches = dict(line.split()[::-1] for line in output.splitlines())
70
+ found = [v for v in wanted if v in matches]
71
+ if len(found) != 1:
72
+ raise ValueError("ambiguous/missing dependency ref; use refs/heads/... or refs/tags/...")
73
+ sha = matches.get(found[0] + "^{}", matches[found[0]])
74
+ result.append({"package": name, "repository": repo, "requested_ref": ref, "commit": sha,
75
+ "subdirectory": sub, "projects": scope})
76
+ return result
77
+
78
+
79
+ def workspace(directory, repository):
80
+ directory, repository = Path(directory).resolve(), Path(repository).resolve()
81
+ directory.relative_to(repository)
82
+ root = directory
83
+ for parent in (directory, *directory.parents):
84
+ if not parent.is_relative_to(repository):
85
+ break
86
+ manifest = parent / "pyproject.toml"
87
+ if manifest.is_file():
88
+ data = tomllib.loads(manifest.read_text())
89
+ settings = data.get("tool", {}).get("uv", {}).get("workspace")
90
+ if settings:
91
+ members = {p.resolve() for pattern in settings.get("members", []) for p in parent.glob(pattern)}
92
+ excluded = {p.resolve() for pattern in settings.get("exclude", []) for p in parent.glob(pattern)}
93
+ if directory == parent or directory in members - excluded:
94
+ root = parent
95
+ break
96
+ return root
97
+
98
+
99
+ def validate_indexes(root, hosts):
100
+ data = tomllib.loads((root / "pyproject.toml").read_text())
101
+ uv = data.get("tool", {}).get("uv", {})
102
+ for index in uv.get("index", []):
103
+ for key in ("url", "publish-url"):
104
+ if key in index:
105
+ allowed_url(index[key], hosts)
106
+ for key in ("PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "UV_INDEX_URL", "UV_DEFAULT_INDEX", "UV_EXTRA_INDEX_URL"):
107
+ for url in os.environ.get(key, "").split():
108
+ allowed_url(url, hosts)
109
+ for dependency in data.get("project", {}).get("dependencies", []):
110
+ url = Requirement(dependency).url
111
+ if url and not url.startswith("file:"):
112
+ allowed_url(url.removeprefix("git+"), hosts)
113
+ return data
114
+
115
+
116
+ @contextlib.contextmanager
117
+ def prepared(directory, repository, policy, candidates, project, hosts, record_dir):
118
+ """Keep temporary metadata active until caller completes; always restore it."""
119
+ if policy["manager"] == "none":
120
+ yield {"manager": "none"}
121
+ return
122
+ root = workspace(directory, repository)
123
+ manifest, lock = root / "pyproject.toml", root / "uv.lock"
124
+ if not lock.is_file():
125
+ raise ValueError(f"commit workspace lockfile {lock}")
126
+ validate_indexes(root, hosts)
127
+ original, original_lock = manifest.read_bytes(), lock.read_bytes()
128
+ selected = [r for r in candidates if project in r["projects"]]
129
+ groups = policy.get("groups")
130
+ options = [] if groups is None else ["--no-default-groups"]
131
+ if groups == "all" or (groups is not None and "*" in groups):
132
+ options += ["--all-groups"]
133
+ else:
134
+ for group in groups or []:
135
+ options += ["--group", group]
136
+ if policy["extras"] == "all":
137
+ options += ["--all-extras"]
138
+ else:
139
+ for extra in policy["extras"]:
140
+ options += ["--extra", extra]
141
+ if Path(directory).resolve() != root:
142
+ member = tomllib.loads((Path(directory) / "pyproject.toml").read_text())["project"]["name"]
143
+ options += ["--package", member]
144
+ environment = {**os.environ, "UV_PYTHON_DOWNLOADS": "never", "UV_PROJECT_ENVIRONMENT": str(root / ".venv")}
145
+ python_options = ["--python", policy["python"]] if policy.get("python") else []
146
+ try:
147
+ data = tomlkit.parse(original.decode())
148
+ uv = data.setdefault("tool", {}).setdefault("uv", {})
149
+ if selected:
150
+ names = {normalize(r["package"]) for r in selected}
151
+ previous = uv.get("override-dependencies", [])
152
+ if any(not isinstance(v, str) for v in previous):
153
+ raise ValueError("scoped uv overrides require an explicit adapter; string overrides supported")
154
+ uv["override-dependencies"] = [v for v in previous if normalize(Requirement(v).name) not in names] + [
155
+ f"{r['package']} @ git+{r['repository']}@{r['commit']}" + (f"#subdirectory={r['subdirectory']}" if r["subdirectory"] else "") for r in selected]
156
+ for key in list(uv.get("sources", {})):
157
+ if normalize(key) in names:
158
+ del uv["sources"][key]
159
+ manifest.write_text(tomlkit.dumps(data))
160
+ upgrade = policy["upgrade"]
161
+ upgrade_options = ["--upgrade"] if upgrade == "all" else []
162
+ if isinstance(upgrade, list):
163
+ for package in upgrade:
164
+ upgrade_options += ["--upgrade-package", package]
165
+ if selected or upgrade != "none":
166
+ subprocess.run(["uv", "lock", *upgrade_options, *python_options], cwd=root, env=environment, check=True)
167
+ subprocess.run(["uv", "sync", "--locked", *options, *python_options], cwd=root, env=environment, check=True)
168
+ interpreter = root / ".venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
169
+ actual = subprocess.check_output([str(interpreter), "-c", "import platform; print(platform.python_version())"], text=True).strip()
170
+ if policy.get("python") and not (actual == policy["python"] or actual.startswith(policy["python"] + ".")):
171
+ raise ValueError(f"requested Python {policy['python']}; got {actual}")
172
+ for candidate in selected:
173
+ code = "import importlib.metadata as m; print(m.distribution(__import__('sys').argv[1]).read_text('direct_url.json') or '{}')"
174
+ source = json.loads(subprocess.check_output([str(interpreter), "-c", code, candidate["package"]], text=True))
175
+ if source.get("vcs_info", {}).get("commit_id") != candidate["commit"] or source.get("url", "").rstrip("/") != candidate["repository"].rstrip("/"):
176
+ raise ValueError(f"installed {candidate['package']} does not match candidate repository/commit")
177
+ record_dir = Path(record_dir)
178
+ record_dir.mkdir(parents=True, exist_ok=True)
179
+ (record_dir / "uv.lock").write_bytes(lock.read_bytes())
180
+ record = {"manager": "uv", "python": actual, "workspace": root.relative_to(repository).as_posix(),
181
+ "lock_sha256": hashlib.sha256(lock.read_bytes()).hexdigest(), "candidates": selected,
182
+ "policy": policy, "interpreter": str(interpreter)}
183
+ yield record
184
+ finally:
185
+ manifest.write_bytes(original)
186
+ lock.write_bytes(original_lock)
generic_ci/models.py ADDED
@@ -0,0 +1,189 @@
1
+ """Toolkit vocabulary. Native job execution fields retain their GitLab meanings."""
2
+ from typing import Annotated, Any, Literal
3
+ from pathlib import PurePosixPath
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
6
+
7
+ Name = Annotated[str, Field(pattern=r"^[a-z][a-z0-9-]{0,39}$")]
8
+
9
+
10
+ class Model(BaseModel):
11
+ model_config = ConfigDict(extra="forbid", populate_by_name=True, strict=True,
12
+ alias_generator=lambda value: value.replace("_", "-"))
13
+
14
+
15
+ def relative(value: str) -> str:
16
+ if not value or "\\" in value or "$" in value or ":" in value:
17
+ raise ValueError("use a literal POSIX relative path")
18
+ path = PurePosixPath(value)
19
+ if path.is_absolute() or ".." in path.parts:
20
+ raise ValueError("path must remain inside the repository")
21
+ return value
22
+
23
+
24
+ class RootPath(Model):
25
+ repository: str = Field(description="Explicit path relative to the repository root.")
26
+ _path = field_validator("repository")(relative)
27
+
28
+
29
+ class Dependencies(Model):
30
+ manager: Literal["uv", "none"] = "uv"
31
+ upgrade: Literal["none", "all"] | list[str] = Field(default="none", description="Upgrade policy within project constraints; changes are temporary.")
32
+ groups: list[str] = Field(default_factory=lambda: ["*"], description="Dependency groups; * selects all, [] excludes dev groups.")
33
+ extras: list[str] = Field(default_factory=list)
34
+ python: str | None = Field(default=None, description="Required interpreter version, verified without downloading Python.")
35
+
36
+
37
+ class NativeJob(Model):
38
+ model_config = ConfigDict(extra="forbid", populate_by_name=True, strict=True, alias_generator=None)
39
+ image: str | None = None
40
+ tags: list[str] | None = None
41
+ services: list[Any] | None = None
42
+ parallel: dict[str, Any] | None = Field(default=None, description="Native parallel:matrix; compiled into explicit jobs for receipt identity.")
43
+ rules: list[dict[str, Any]] | None = None
44
+ timeout: str | None = None
45
+ cache: dict[str, Any] | None = None
46
+ resource_group: str | None = None
47
+ variables: dict[str, str] = Field(default_factory=dict)
48
+ allow_failure: bool = False
49
+
50
+ @field_validator("variables")
51
+ @classmethod
52
+ def reserved(cls, value):
53
+ if any(k.startswith(("CI_", "TOOLKIT_")) or k in {"PYTHONPATH", "PYTHONHOME"} for k in value):
54
+ raise ValueError("CI_*, TOOLKIT_* and Python bootstrap variables are reserved")
55
+ return value
56
+
57
+
58
+ class Check(Model):
59
+ runtime: str = Field(default="default", description="Platform runtime profile; selects tools and shell, independently of runner tags.")
60
+ script: list[str] | None = Field(default=None, min_length=1, description="Commands after dependency setup; omit only when inheriting a preset check.")
61
+ dependencies: Dependencies | None = None
62
+ gitlab: NativeJob = Field(default_factory=NativeJob)
63
+ needs: list[str] = Field(default_factory=list, description="Public step references: step or project.step.")
64
+ junit: list[str] = Field(default_factory=list, description="Project-relative report paths.")
65
+ _paths = field_validator("junit")(lambda paths: [relative(p) for p in paths])
66
+
67
+
68
+ class Step(Check):
69
+ script: list[str] = Field(min_length=1)
70
+ outputs: dict[Name, str] = Field(default_factory=dict, description="Named files/directories passed to dependent steps and builds.")
71
+ _output_paths = field_validator("outputs")(lambda paths: {k: relative(v) for k, v in paths.items()})
72
+
73
+
74
+ class Package(Model):
75
+ directory: str = Field(default=".", description="Package build directory relative to project.path; useful for generated SDKs.")
76
+ index: str | None = Field(default=None, description="Named tool.uv.index in pyproject; required to publish.")
77
+ needs: list[str] = Field(default_factory=list)
78
+ checks: list[str] | None = None
79
+ _directory = field_validator("directory")(relative)
80
+
81
+
82
+ class Container(Model):
83
+ dockerfile: str = "Dockerfile"
84
+ context: str | RootPath = "."
85
+ repository: str | None = None
86
+ platform: str = "linux/amd64"
87
+ target: str | None = Field(default=None, description="Optional Dockerfile build stage.")
88
+ build_args: dict[str, str] = Field(default_factory=dict)
89
+ secrets: dict[str, str] = Field(default_factory=dict, description="BuildKit secret ID to file-type CI variable name.")
90
+ needs: list[str] = Field(default_factory=list)
91
+ checks: list[str] | None = None
92
+ dependency_bundle: bool = Field(default=False, description="Dockerfile explicitly consumes named ci-dependencies context (see recipe).")
93
+ _dockerfile = field_validator("dockerfile")(relative)
94
+
95
+ @field_validator("context")
96
+ @classmethod
97
+ def path(cls, value):
98
+ return relative(value) if isinstance(value, str) else value
99
+
100
+
101
+ class ImageBinding(Model):
102
+ repository: list[str] = Field(min_length=1, description="Path in chart values for image repository.")
103
+ digest: list[str] = Field(min_length=1, description="Path in chart values for image sha256 digest.")
104
+
105
+
106
+ class Deployment(Model):
107
+ target: str
108
+ chart: str | None = None
109
+ chart_version: str | None = None
110
+ values: list[str] = Field(default_factory=list, description="Project-relative values files, applied in order.")
111
+ checks: list[str] | None = Field(default=None, description="Required source checks; omitted inherits all enabled project checks, [] opts out.")
112
+ before: list[str] = Field(default_factory=list, description="Fresh commands after approval and before rollout, e.g. migrations.")
113
+ after: list[str] = Field(default_factory=list, description="Fresh commands after rollout; failure marks deployment failed.")
114
+ images: dict[str, ImageBinding] = Field(default_factory=dict, description="Container project to chart values binding; stock chart defaults to own container.")
115
+ preview: bool = False
116
+ auto_stop_in: str = "2 days"
117
+ when: Literal["manual", "on_success"] = "manual"
118
+ _values = field_validator("values")(lambda paths: [relative(p) for p in paths])
119
+
120
+
121
+ class Release(Model):
122
+ tag: str = Field(default="v{version}", description="Existing tag pattern; exactly one {version} placeholder.")
123
+ version_file: str = "pyproject.toml"
124
+ bump: bool = True
125
+ needs: list[str] = Field(default_factory=list, description="Projects whose publication must complete first.")
126
+ notes: str = "CHANGELOG.md"
127
+ gitlab_release: bool = True
128
+ _version = field_validator("version_file", "notes")(relative)
129
+
130
+ @field_validator("tag")
131
+ @classmethod
132
+ def pattern(cls, value):
133
+ if value.count("{version}") != 1 or "{" in value.replace("{version}", ""):
134
+ raise ValueError("tag must contain exactly one {version}")
135
+ return value
136
+
137
+
138
+ class Project(Model):
139
+ path: str = "."
140
+ preset: Literal["generic", "python-package", "python-service"] = "generic"
141
+ dependencies: Dependencies | None = None
142
+ checks: dict[Name, Check | Literal[False]] = Field(default_factory=dict)
143
+ steps: dict[Name, Step] = Field(default_factory=dict)
144
+ package: Package | None = None
145
+ container: Container | None = None
146
+ deploy: dict[Name, Deployment] = Field(default_factory=dict)
147
+ release: Release | None = None
148
+ depends_on: list[str] = Field(default_factory=list, description="Project dependencies used for transitive changed-only selection.")
149
+ watch: list[str] = Field(default_factory=list, description="Additional repository-relative changed paths/globs.")
150
+ _path = field_validator("path")(relative)
151
+
152
+
153
+ class Pipeline(Model):
154
+ schema_version: Literal[1] = 1
155
+ projects: dict[Name, Project] = Field(min_length=1)
156
+
157
+
158
+ class Runtime(Model):
159
+ image: str | None = Field(default=None, description="Approved image with generic-gitlab-ci at the compiler version plus required tools.")
160
+ tags: list[str] = Field(default_factory=list)
161
+ shell: Literal["sh", "powershell"] = "sh"
162
+
163
+
164
+ class Target(Model):
165
+ namespace: str
166
+ release_prefix: str = "app"
167
+ url: str = ""
168
+ production: bool = True
169
+ kubeconfig_variable: str = "KUBECONFIG"
170
+
171
+
172
+ class Platform(Model):
173
+ schema_version: Literal[1] = 1
174
+ runtimes: dict[str, Runtime] = Field(description="Must include default; optional build, helm, release runtime overrides.")
175
+ registry: str
176
+ preview_registry: str
177
+ chart: str
178
+ chart_version: str
179
+ targets: dict[str, Target] = Field(default_factory=dict)
180
+ allowed_hosts: list[str] = Field(min_length=1, description="Internal Git, registry, index and chart hosts; no implicit public fallback.")
181
+ max_jobs: int = Field(default=150, ge=1, le=1000)
182
+ artifact_retention: str = "30 days"
183
+ mandatory_checks: list[str] = Field(default_factory=list)
184
+
185
+ @model_validator(mode="after")
186
+ def default_runtime(self):
187
+ if "default" not in self.runtimes:
188
+ raise ValueError("runtimes.default is required")
189
+ return self