tangier 0.2.1__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.
tangier/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """tangier — a content-addressed CI/deploy pipeline toolkit.
2
+
3
+ Five concerns, one config (`pipeline.toml`):
4
+
5
+ changemap which parts of the repo does this diff touch?
6
+ image what is this bucket's content hash, and how do I build it?
7
+ deploy render and apply k8s manifests for those image tags.
8
+ tailnet can this machine reach the cluster, and as what identity?
9
+ gate has this content already passed its gate?
10
+
11
+ Pure stdlib. See `docs/specs/changemap.md` and `docs/specs/gate.md`.
12
+ """
13
+
14
+ # Kept equal to `pyproject.toml` by a test. Gate records carry it.
15
+ __version__ = "0.2.1"
tangier/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """`python -m tangier` entry point."""
2
+
3
+ from tangier.cli import cli_main
4
+
5
+ cli_main()
tangier/changemap.py ADDED
@@ -0,0 +1,310 @@
1
+ """The answer set: which tags does this diff touch, and what are their SHAs?
2
+
3
+ The single computed view that every `changemap` subcommand formats. Nothing
4
+ here formats output or shells out to a cluster.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ from collections.abc import Callable, Iterable
11
+ from dataclasses import dataclass
12
+
13
+ from tangier import git, globs
14
+ from tangier.config import Config
15
+
16
+ # `from tangier import git` — NOT `from tangier.git import changed_files`. Tests
17
+ # patch `git.changed_files`, and a direct-symbol import would bind a second
18
+ # reference that the patch never reaches.
19
+
20
+
21
+ @dataclass
22
+ class AnswerSet:
23
+ matched: set[str]
24
+ expanded: set[str]
25
+ shas: dict[str, str]
26
+ items: dict[str, list[str]]
27
+ touched: dict[str, bool]
28
+ # File-set group name (e.g. `unittest-files`) -> directly-changed files
29
+ # selected by that group's globs. Drives each runner's `--files` flag.
30
+ file_sets: dict[str, list[str]]
31
+ ignored: list[str]
32
+
33
+
34
+ def tag_matches(cfg: Config, tag: str, path: str) -> bool:
35
+ """Whether `path` belongs to `tag`: matches its `paths`, minus its `exclude`.
36
+
37
+ The single definition of tag membership — every match site (diff resolution
38
+ and SHA bucket hashing) goes through here so the two cannot diverge.
39
+ """
40
+ return globs.matches(path, cfg.paths.get(tag, [])) and not globs.matches(path, cfg.exclude.get(tag, []))
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # SHA — content hash for a bucket
45
+ # ---------------------------------------------------------------------------
46
+
47
+
48
+ def sha_of_paths(paths: list[str], head: str, keep: Callable[[str], bool] | None = None) -> str:
49
+ """Hash the `git ls-tree -r` walk of `paths`, optionally filtered by `keep`.
50
+
51
+ `keep` receives each walked file path and decides whether it contributes —
52
+ the only way to express exclusion, since `ls-tree` has no negation.
53
+ `keep=None` skips the per-path call entirely; it is an optimisation, not a
54
+ parity mechanism, since a `keep` returning True for every line hashes
55
+ identically.
56
+
57
+ What byte-identical hashing actually rests on is here and in
58
+ `globs.globs_to_ls_tree_paths`: the exact `ls-tree` line text, joined with
59
+ `\\n` and hashed with no trailing newline, truncated to 10 hex chars. Change
60
+ any of that and every image tag in every consuming repo moves.
61
+
62
+ Policy-free by design — the caller composes `keep`, so `[sha] exclude` and
63
+ per-tag `exclude` meet in one place rather than two.
64
+ """
65
+ return _sha_of_lines(tree_lines(paths, head, keep))
66
+
67
+
68
+ def tree_lines(paths: list[str], head: str, keep: Callable[[str], bool] | None = None) -> list[str]:
69
+ """The `git ls-tree -r` lines for `paths`, optionally filtered by `keep`."""
70
+ lines = git.ls_tree(head, paths)
71
+ return [line for line in lines if keep is None or keep(git.ls_tree_path(line))]
72
+
73
+
74
+ def _sha_of_lines(lines: list[str]) -> str:
75
+ return hashlib.sha1("\n".join(lines).encode()).hexdigest()[:10]
76
+
77
+
78
+ def transitive_deps(tag: str, depends: dict[str, list[str]]) -> set[str]:
79
+ """Transitive closure of `tag`'s forward dependencies (what `tag` depends on)."""
80
+ seen: set[str] = set()
81
+ queue = [tag]
82
+ while queue:
83
+ cur = queue.pop()
84
+ for dep in depends.get(cur, ()):
85
+ if dep not in seen:
86
+ seen.add(dep)
87
+ queue.append(dep)
88
+ return seen
89
+
90
+
91
+ def buckets(cfg: Config) -> dict[str, list[str]]:
92
+ """bucket-name -> member tags (tags whose `sha` field maps here)."""
93
+ out: dict[str, list[str]] = {}
94
+ for tag, bucket in cfg.sha_bucket.items():
95
+ out.setdefault(bucket, []).append(tag)
96
+ return out
97
+
98
+
99
+ def sha_for_bucket(cfg: Config, bucket: str, head: str = "HEAD") -> str:
100
+ """SHA of the union of a bucket's members' paths + their transitive deps' paths."""
101
+ return _sha_of_lines(scope_lines(cfg, buckets(cfg).get(bucket, []), head))
102
+
103
+
104
+ def scope_lines(cfg: Config, tags: Iterable[str], head: str = "HEAD") -> list[str]:
105
+ """The `ls-tree` lines for `tags`' paths plus their transitive deps' paths.
106
+
107
+ A SHA bucket and a gate scope both hash these lines, so a package means
108
+ the same thing to an image and to a gate.
109
+
110
+ Contributing tags' `exclude` globs are honoured so the lines match
111
+ the tags' match sets. `git ls-tree` cannot express negation, so the file list
112
+ it returns is filtered afterwards. Exclusion is a property of the individual
113
+ tag, not the union: a path one contributing tag excludes still counts if
114
+ another contributing tag claims it (that tag genuinely ships the file).
115
+
116
+ Two filters compose here, and they are deliberately different:
117
+ - `[sha] exclude` applies unconditionally (docs never rebuild an image).
118
+ - the per-tag `claimed_by` filter applies only when some contributing tag
119
+ actually declares `exclude`.
120
+ Conflating them would change the hash of every bucket and scope that has no exclusions.
121
+ """
122
+ bucket_globs, keep = _scope_filter(cfg, tags)
123
+ return tree_lines(globs.globs_to_ls_tree_paths(bucket_globs), head, keep)
124
+
125
+
126
+ def scope_touched(cfg: Config, tags: Iterable[str], files: Iterable[str]) -> bool:
127
+ """Whether any of `files` is one of `scope_lines`' inputs for `tags`.
128
+
129
+ The same globs and the same `keep` filter as `scope_lines`, so a file
130
+ touches the scope exactly when it can move the scope's lines. Gate scopes
131
+ hold only literal paths and `dir/**` globs, for which a glob match equals
132
+ membership in the `ls-tree` walk. A deleted file counts: it left the walk.
133
+ """
134
+ scope_globs, keep = _scope_filter(cfg, tags)
135
+ return any(globs.matches(f, scope_globs) and (keep is None or keep(f)) for f in files)
136
+
137
+
138
+ def _scope_filter(cfg: Config, tags: Iterable[str]) -> tuple[list[str], Callable[[str], bool] | None]:
139
+ """The globs `tags` contribute, with their transitive deps, and the `keep` filter for them.
140
+
141
+ `keep` is None when neither filter applies (see `scope_lines`).
142
+ """
143
+ bucket_globs: list[str] = []
144
+ contributing: set[str] = set()
145
+ for m in tags:
146
+ # Set iteration, so the order of `bucket_globs` — and hence of the path
147
+ # arguments to `git ls-tree` — is not stable across runs. That is safe
148
+ # only because `git ls-tree -r` sorts and dedups its output regardless
149
+ # of argument order; the SHA depends on the output, not the arguments.
150
+ for tag in {m, *transitive_deps(m, cfg.depends)}:
151
+ if tag in contributing:
152
+ continue
153
+ contributing.add(tag)
154
+ bucket_globs.extend(cfg.paths.get(tag, []))
155
+
156
+ sha_exclude = cfg.sha.exclude
157
+ needs_claim_filter = any(cfg.exclude.get(tag) for tag in contributing)
158
+
159
+ def _keep(path: str) -> bool:
160
+ if sha_exclude and globs.matches(path, sha_exclude):
161
+ return False
162
+ if needs_claim_filter:
163
+ return any(tag_matches(cfg, tag, path) for tag in contributing)
164
+ return True
165
+
166
+ # Neither filter applies -> walk everything unfiltered, the byte-stable fast path.
167
+ return bucket_globs, _keep if (sha_exclude or needs_claim_filter) else None
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Dependency expansion
172
+ # ---------------------------------------------------------------------------
173
+
174
+
175
+ def _invert_depends(depends: dict[str, list[str]]) -> dict[str, set[str]]:
176
+ """Build dep -> {tags that directly depend on dep}."""
177
+ out: dict[str, set[str]] = {}
178
+ for tag, deps in depends.items():
179
+ for dep in deps:
180
+ out.setdefault(dep, set()).add(tag)
181
+ return out
182
+
183
+
184
+ def expand_with_dependents(tags: Iterable[str], depends: dict[str, list[str]]) -> set[str]:
185
+ """Expand a matched tag set via the reverse-transitive closure of depends.
186
+
187
+ For each input tag X, union in every tag whose dependency closure
188
+ transitively contains X.
189
+ """
190
+ inverse = _invert_depends(depends)
191
+ result: set[str] = set(tags)
192
+ queue: list[str] = list(tags)
193
+ while queue:
194
+ cur = queue.pop()
195
+ for dependent in inverse.get(cur, ()):
196
+ if dependent not in result:
197
+ result.add(dependent)
198
+ queue.append(dependent)
199
+ return result
200
+
201
+
202
+ def provenance(matched_raw: set[str], expanded: set[str], depends: dict[str, list[str]]) -> dict[str, set[str]]:
203
+ """For each tag in `expanded - matched_raw`, the matched-raw tags whose
204
+ dependents transitively include it."""
205
+ added = expanded - matched_raw
206
+ out: dict[str, set[str]] = {}
207
+ for tag in added:
208
+ sources = {src for src in matched_raw if tag in expand_with_dependents({src}, depends)}
209
+ if sources:
210
+ out[tag] = sources
211
+ return out
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Answer set
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ def match_files_to_tags(cfg: Config, files: Iterable[str]) -> tuple[dict[str, list[str]], list[str]]:
220
+ """Group files by the tag(s) they match. Returns (per_tag, ignored)."""
221
+ per_tag: dict[str, list[str]] = {}
222
+ ignored: list[str] = []
223
+ for f in files:
224
+ hit = False
225
+ for tag in cfg.paths:
226
+ if tag_matches(cfg, tag, f):
227
+ per_tag.setdefault(tag, []).append(f)
228
+ hit = True
229
+ if not hit:
230
+ ignored.append(f)
231
+ return per_tag, ignored
232
+
233
+
234
+ def project_items(cfg: Config, name: str, expanded: set[str]) -> list[str]:
235
+ """Stable, deduped projection of one items name through the expanded set."""
236
+ tag_map = cfg.items.get(name, {})
237
+ seen: set[str] = set()
238
+ collected: list[str] = []
239
+ for tag in sorted(expanded):
240
+ for path in tag_map.get(tag, []):
241
+ if path not in seen:
242
+ seen.add(path)
243
+ collected.append(path)
244
+ return collected
245
+
246
+
247
+ def compute_answer_set(
248
+ cfg: Config, base: str, head: str, *, expand: bool = True, compute_shas: bool = True
249
+ ) -> AnswerSet:
250
+ return answer_set_for_files(cfg, git.changed_files(base, head), head, expand=expand, compute_shas=compute_shas)
251
+
252
+
253
+ def answer_set_for_files(
254
+ cfg: Config, files: list[str], head: str, *, expand: bool = True, compute_shas: bool = True
255
+ ) -> AnswerSet:
256
+ """The answer set for a list of changed files. `head` is read only for the bucket SHAs."""
257
+ per_tag, ignored = match_files_to_tags(cfg, files)
258
+ matched = set(per_tag.keys())
259
+ expanded = expand_with_dependents(matched, cfg.depends) if expand else set(matched)
260
+
261
+ items = {name: project_items(cfg, name, expanded) for name in cfg.items}
262
+
263
+ shas: dict[str, str] = {}
264
+ if compute_shas:
265
+ for bucket in sorted(buckets(cfg).keys()):
266
+ shas[bucket] = sha_for_bucket(cfg, bucket, head)
267
+
268
+ touched = {tag: (tag in expanded) for tag in cfg.touched}
269
+
270
+ # File-set projection: each `files = true` table's globs select the
271
+ # directly-changed files for a runner's `--files` flag. Membership is decided
272
+ # purely by the table's globs over the raw diff — scoped to the code each
273
+ # runner can actually execute. Files outside every file-set's globs silently
274
+ # drop out, the same ignore-by-default contract as the rest of changemap.
275
+ file_sets = {
276
+ group: [f for f in files if globs.matches(f, group_globs)] for group, group_globs in cfg.file_sets.items()
277
+ }
278
+
279
+ return AnswerSet(
280
+ matched=matched,
281
+ expanded=expanded,
282
+ shas=shas,
283
+ items=items,
284
+ touched=touched,
285
+ file_sets=file_sets,
286
+ ignored=ignored,
287
+ )
288
+
289
+
290
+ def full_answer_set(cfg: Config, head: str, *, compute_shas: bool = True) -> AnswerSet:
291
+ """The answer set as if every tag changed: `--full`. Reads no diff, so it needs no merge base.
292
+
293
+ Each file-set holds every tracked file at `head` that its globs match. See
294
+ `[run-full]` in `docs/specs/gate.md`.
295
+ """
296
+ # `ls_tree` reads a bad ref as an empty tree, which would empty every file-set without a word.
297
+ _ = git.rev_parse_tree(head)
298
+ every_tag = set(cfg.paths)
299
+ tracked = [git.ls_tree_path(line) for line in git.ls_tree(head, [])]
300
+ return AnswerSet(
301
+ matched=every_tag,
302
+ expanded=every_tag,
303
+ shas={bucket: sha_for_bucket(cfg, bucket, head) for bucket in sorted(buckets(cfg))} if compute_shas else {},
304
+ items={name: project_items(cfg, name, every_tag) for name in cfg.items},
305
+ touched={tag: True for tag in cfg.touched},
306
+ file_sets={
307
+ group: [f for f in tracked if globs.matches(f, group_globs)] for group, group_globs in cfg.file_sets.items()
308
+ },
309
+ ignored=[],
310
+ )
tangier/cli.py ADDED
@@ -0,0 +1,164 @@
1
+ """tangier CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+
9
+ from tangier.commands import changemap_cmds, deploy_cmds, gate_cmds, image_cmds, tailnet_cmds
10
+ from tangier.commands.args import add_diff_args, add_full
11
+ from tangier.config import Config, ConfigError, read_config
12
+ from tangier.deploy import DeployError
13
+ from tangier.gate import GateError
14
+ from tangier.git import GitError
15
+ from tangier.image import ImageError
16
+ from tangier.runner import Runner
17
+ from tangier.tailnet import TailnetError
18
+
19
+ # The config lives in the repo tangier runs from — each project carries its own
20
+ # pipeline.toml at its root. TANGIER_CONFIG lets the parity harness point both
21
+ # implementations at the same file without copying or symlinking it.
22
+ DEFAULT_CONFIG = os.environ.get("TANGIER_CONFIG") or "pipeline.toml"
23
+
24
+
25
+ def cli_main() -> None:
26
+ """Console-script entry point."""
27
+ raise SystemExit(main())
28
+
29
+
30
+ def main(argv: list[str] | None = None, *, runner: Runner | None = None) -> int:
31
+ """Run one command. `runner` replaces the subprocess seam, for tests."""
32
+ parser = _build_parser()
33
+ args = parser.parse_args(argv)
34
+ # The arguments as given, so a gate job can run the same command again.
35
+ args.argv = list(sys.argv[1:] if argv is None else argv)
36
+ if runner is not None:
37
+ args.runner = runner
38
+ if not getattr(args, "func", None):
39
+ parser.print_help()
40
+ return 1
41
+ try:
42
+ config = read_config(args.config)
43
+ except ConfigError as e:
44
+ print(f"config error: {e}", file=sys.stderr)
45
+ return 2
46
+ except FileNotFoundError as e:
47
+ # A repo with no images and no deploy environments still wants the
48
+ # tailnet preflight — k8s-cluster is helm and kubectl, and a config
49
+ # file there would describe nothing. Every other command needs the
50
+ # config to mean anything, so only this one degrades.
51
+ if not getattr(args, "config_optional", False):
52
+ print(f"config error: {e}", file=sys.stderr)
53
+ return 2
54
+ config = Config()
55
+ try:
56
+ return args.func(config, args) or 0
57
+ except ConfigError as e:
58
+ print(f"config error: {e}", file=sys.stderr)
59
+ return 2
60
+ except (ImageError, DeployError, TailnetError, GateError, GitError) as e:
61
+ print(f"error: {e}", file=sys.stderr)
62
+ return 2
63
+
64
+
65
+ def _add_no_expand(p: argparse.ArgumentParser) -> None:
66
+ _ = p.add_argument(
67
+ "--no-expand",
68
+ action="store_true",
69
+ help="don't expand matched tags via depends (debug)",
70
+ )
71
+
72
+
73
+ def _build_parser() -> argparse.ArgumentParser:
74
+ p = argparse.ArgumentParser(
75
+ prog="tangier",
76
+ description="smart CI for monorepos: change-scoped tests, local gate reuse, content-addressed builds",
77
+ formatter_class=argparse.RawDescriptionHelpFormatter,
78
+ )
79
+ _ = p.add_argument(
80
+ "--config",
81
+ default=DEFAULT_CONFIG,
82
+ help=f"path to TOML config (default: {DEFAULT_CONFIG})",
83
+ )
84
+ sub = p.add_subparsers(dest="group")
85
+ _add_changemap_parser(sub)
86
+ image_cmds.add_parsers(sub)
87
+ deploy_cmds.add_parsers(sub)
88
+ tailnet_cmds.add_parsers(sub)
89
+ gate_cmds.add_parsers(sub)
90
+ return p
91
+
92
+
93
+ def _add_changemap_parser(sub: argparse._SubParsersAction) -> None:
94
+ cm = sub.add_parser("changemap", help="which parts of the repo does this diff touch?")
95
+ cmsub = cm.add_subparsers(dest="cmd", required=True)
96
+
97
+ lp = cmsub.add_parser("list", help="print all tags and their globs")
98
+ _ = lp.add_argument(
99
+ "--graph",
100
+ action="store_true",
101
+ help="print each tag with its depends entries indented underneath",
102
+ )
103
+ lp.set_defaults(func=changemap_cmds.cmd_list)
104
+
105
+ sp = cmsub.add_parser("sha", help="git-tree content hash for a SHA bucket")
106
+ _ = sp.add_argument("bucket", nargs="?")
107
+ _ = sp.add_argument("--all", action="store_true")
108
+ _ = sp.add_argument("--github-notice", action="store_true")
109
+ _ = sp.add_argument("--head", default="HEAD", help="git ref to hash at (default: HEAD)")
110
+ sp.set_defaults(func=changemap_cmds.cmd_sha)
111
+
112
+ ip = cmsub.add_parser("items", help="project the expanded changed set into a named item list")
113
+ _ = ip.add_argument("name", help="items name (e.g. `unittest`, `e2e`)")
114
+ add_diff_args(ip)
115
+ _add_no_expand(ip)
116
+ add_full(ip)
117
+ ip.set_defaults(func=changemap_cmds.cmd_items)
118
+
119
+ li = cmsub.add_parser(
120
+ "list-ignored",
121
+ help="list changed files in the diff that matched no tag — sanity-check helper",
122
+ )
123
+ add_diff_args(li)
124
+ li.set_defaults(func=changemap_cmds.cmd_list_ignored)
125
+
126
+ ep = cmsub.add_parser("explain", help="show modified tags, dependents, and resulting CI invocations")
127
+ add_diff_args(ep)
128
+ _add_no_expand(ep)
129
+ add_full(ep)
130
+ _ = ep.add_argument(
131
+ "--files",
132
+ action="append",
133
+ metavar="GROUP=CSV",
134
+ help="csv of changed files for a file-set group, forwarded into that runner's --files arg (repeatable)",
135
+ )
136
+ ep.set_defaults(func=_explain_with_files)
137
+
138
+ bm = cmsub.add_parser(
139
+ "build-matrix",
140
+ help="emit the buildable buckets this diff touches, as a JSON array for a build matrix",
141
+ )
142
+ add_diff_args(bm)
143
+ _add_no_expand(bm)
144
+ add_full(bm)
145
+ bm.set_defaults(func=changemap_cmds.cmd_build_matrix)
146
+
147
+ gp = cmsub.add_parser(
148
+ "github-outputs",
149
+ help="emit the answer set (SHAs, items, touched, file-sets) as $GITHUB_OUTPUT lines",
150
+ )
151
+ add_diff_args(gp)
152
+ _add_no_expand(gp)
153
+ add_full(gp)
154
+ gp.set_defaults(func=changemap_cmds.cmd_github_outputs)
155
+
156
+
157
+ def _explain_with_files(config, args: argparse.Namespace) -> int:
158
+ """Validate `--files GROUP=CSV` before running explain, so a malformed pair exits 2."""
159
+ try:
160
+ args.files_map = changemap_cmds.parse_files_args(args.files)
161
+ except ValueError as e:
162
+ print(f"error: {e}", file=sys.stderr)
163
+ return 2
164
+ return changemap_cmds.cmd_explain(config, args)
@@ -0,0 +1 @@
1
+ """CLI subcommand implementations — formatters over the computed answer set."""
@@ -0,0 +1,18 @@
1
+ """Arguments that the `changemap` and `gate` parsers share, so each flag is defined once."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+
8
+ def add_diff_args(p: argparse.ArgumentParser, *, head: bool = True) -> None:
9
+ """`--base`, and `--head` unless the command keys the working tree."""
10
+ _ = p.add_argument("--base", default="origin/main")
11
+ if head:
12
+ _ = p.add_argument("--head", default="HEAD")
13
+
14
+
15
+ def add_full(
16
+ p: argparse.ArgumentParser, help: str = "answer as if every tag changed; reads no diff, so --base is ignored"
17
+ ) -> None:
18
+ _ = p.add_argument("--full", action="store_true", help=help)