lintsmith 0.0.16__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.
lintsmith/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Lean, pinned Super-Linter images generated from VALIDATE_* flags."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("lintsmith")
7
+ except PackageNotFoundError: # pragma: no cover - running from a source tree without metadata
8
+ __version__ = "0.0.0"
lintsmith/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for ``python -m lintsmith``."""
2
+
3
+ from lintsmith.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
lintsmith/bench.py ADDED
@@ -0,0 +1,172 @@
1
+ """Measure the pull time and container start time of an image.
2
+
3
+ Usage:
4
+ lintsmith bench IMAGE [RUNS] VARIANT RUNNER [--mode cold|warm] [--out FILE] [--split]
5
+
6
+ IMAGE the image to pull
7
+ RUNS how many runs (default 5)
8
+ VARIANT label written to every row, normally ``<profile>/<kind>``
9
+ RUNNER label of the runner, written to every row
10
+
11
+ Modes (``--mode``, default ``$BENCH_MODE`` or ``cold``):
12
+
13
+ cold
14
+ remove the local image and every other unused image before every run,
15
+ then time ``docker pull``. Other images must go too: the gzip and zstd
16
+ variants of an image share their uncompressed layers, and a variant
17
+ pulled next to the other one would reuse them and look almost free.
18
+ This clears unused images on the runner.
19
+ warm
20
+ the image stays on the runner (a first, unmeasured pull makes sure it
21
+ is there); time the no-op ``docker pull``, which is what a job pays
22
+ when nothing needs downloading.
23
+
24
+ Rows are printed and appended to ``--out`` (default ``$BENCH_OUT`` or
25
+ ``bench-results.jsonl``). ``--split`` (or ``BENCH_SPLIT=1``) also times, in cold
26
+ mode, a download-only pull to a tarball (crane) and a ``docker load`` of it, a
27
+ rough download against unpack split (approximate: load is not the unpack done
28
+ by ``docker pull``). Without it crane, if present, only reads the compressed size.
29
+
30
+ Exit code: 0 done, 1 a docker or crane call failed, 2 a bad argument.
31
+ """
32
+
33
+ import argparse
34
+ import json
35
+ import os
36
+ import shutil
37
+ import subprocess
38
+ import sys
39
+ import tempfile
40
+ import time
41
+ from collections.abc import Callable, Sequence
42
+ from pathlib import Path
43
+ from typing import Any
44
+
45
+ PLATFORM = "linux/amd64"
46
+
47
+
48
+ class BenchError(Exception):
49
+ """A docker or crane call that must succeed failed."""
50
+
51
+
52
+ def run(command: Sequence[str], check: bool = True) -> subprocess.CompletedProcess[str]:
53
+ completed = subprocess.run(command, capture_output=True, text=True, check=False)
54
+ if check and completed.returncode != 0:
55
+ raise BenchError(f"{' '.join(command[:3])} failed: {completed.stderr.strip() or completed.stdout.strip()}")
56
+ return completed
57
+
58
+
59
+ def timed(action: Callable[[], object]) -> float:
60
+ start = time.perf_counter()
61
+ action()
62
+ return round(time.perf_counter() - start, 2)
63
+
64
+
65
+ def clear_local_images(image: str) -> None:
66
+ """Leave no layer behind that a pull could reuse."""
67
+ run(["docker", "image", "rm", "-f", image], check=False)
68
+ run(["docker", "image", "prune", "-af"], check=False)
69
+ run(["docker", "builder", "prune", "-af"], check=False)
70
+
71
+
72
+ def have_crane() -> bool:
73
+ return shutil.which("crane") is not None
74
+
75
+
76
+ def compressed_bytes(image: str) -> int | None:
77
+ """Sum of the compressed layer sizes, read with crane when it is installed."""
78
+ if not have_crane():
79
+ return None
80
+ completed = run(["crane", "manifest", "--platform", PLATFORM, image], check=False)
81
+ if completed.returncode != 0:
82
+ return None
83
+ try:
84
+ layers = json.loads(completed.stdout)["layers"]
85
+ return sum(int(layer["size"]) for layer in layers)
86
+ except (ValueError, KeyError, TypeError):
87
+ return None
88
+
89
+
90
+ def uncompressed_bytes(image: str) -> int:
91
+ return int(run(["docker", "image", "inspect", "--format", "{{.Size}}", image]).stdout.strip())
92
+
93
+
94
+ def split_download(image: str) -> tuple[float, float]:
95
+ """Seconds to download the image to a tarball with crane, and to docker load it."""
96
+ with tempfile.TemporaryDirectory() as directory:
97
+ tarball = str(Path(directory) / "image.tar")
98
+ # like the shell script, a failure here still gives a timing instead of stopping the run
99
+ download = timed(lambda: run(["crane", "pull", "--platform", PLATFORM, image, tarball], check=False))
100
+ load = timed(lambda: run(["docker", "load", "--quiet", "-i", tarball], check=False))
101
+ return download, load
102
+
103
+
104
+ def measure(image: str, number: int, mode: str, split: bool, size: int | None) -> dict[str, Any]:
105
+ """One run; `split` already includes whether crane is installed."""
106
+ download: float | None = None
107
+ load: float | None = None
108
+ if mode == "cold":
109
+ clear_local_images(image)
110
+ pull = timed(lambda: run(["docker", "pull", "--quiet", image]))
111
+ start = timed(lambda: run(["docker", "run", "--rm", "--entrypoint", "true", image], check=False))
112
+ unpacked = uncompressed_bytes(image)
113
+ if mode == "cold" and split:
114
+ clear_local_images(image)
115
+ download, load = split_download(image)
116
+ return {
117
+ "run": number,
118
+ "pull_s": pull,
119
+ "start_s": start,
120
+ "download_s": download,
121
+ "load_s": load,
122
+ "compressed_bytes": size,
123
+ "uncompressed_bytes": unpacked,
124
+ }
125
+
126
+
127
+ def bench(image: str, runs: int, variant: str, runner: str, mode: str, out: Path, split: bool) -> list[dict[str, Any]]:
128
+ size = compressed_bytes(image)
129
+ split = split and have_crane()
130
+ if mode == "warm":
131
+ run(["docker", "pull", "--quiet", image]) # make sure the image is there; not measured
132
+ rows = []
133
+ for number in range(1, runs + 1):
134
+ row = {"image": image, "variant": variant, "runner": runner, "mode": mode}
135
+ row.update(measure(image, number, mode, split, size))
136
+ text = json.dumps(row)
137
+ print(text)
138
+ with out.open("a") as handle:
139
+ handle.write(text + "\n")
140
+ rows.append(row)
141
+ return rows
142
+
143
+
144
+ def main(argv: Sequence[str] | None = None) -> int:
145
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
146
+ ap.add_argument("image")
147
+ ap.add_argument("runs_or_variant", help="RUNS (default 5), then VARIANT and RUNNER")
148
+ ap.add_argument("more", nargs="*")
149
+ ap.add_argument("--mode", choices=["cold", "warm"], default=os.environ.get("BENCH_MODE", "cold"))
150
+ ap.add_argument("--out", default=os.environ.get("BENCH_OUT", "bench-results.jsonl"))
151
+ ap.add_argument("--split", action="store_true", default=os.environ.get("BENCH_SPLIT") == "1")
152
+ args = ap.parse_args(argv)
153
+
154
+ positional = [args.runs_or_variant, *args.more]
155
+ if positional[0].isdigit():
156
+ runs, rest = int(positional[0]), positional[1:]
157
+ else:
158
+ runs, rest = 5, positional
159
+ if len(rest) != 2:
160
+ print("::error::usage: lintsmith bench IMAGE [RUNS] VARIANT RUNNER")
161
+ return 2
162
+ variant, runner = rest
163
+ try:
164
+ bench(args.image, runs, variant, runner, args.mode, Path(args.out), args.split)
165
+ except BenchError as e:
166
+ print(f"::error::{e}")
167
+ return 1
168
+ return 0
169
+
170
+
171
+ if __name__ == "__main__":
172
+ sys.exit(main())
lintsmith/cli.py ADDED
@@ -0,0 +1,59 @@
1
+ """Command line entry point for ``lintsmith``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from collections.abc import Callable, Sequence
7
+
8
+ from lintsmith import (
9
+ __version__,
10
+ bench,
11
+ drift,
12
+ generate,
13
+ mapping_sync,
14
+ release_notes,
15
+ report,
16
+ repository_settings,
17
+ smoke,
18
+ upstream,
19
+ )
20
+
21
+ COMMANDS: dict[str, tuple[Callable[[Sequence[str] | None], int | None], str]] = {
22
+ "generate": (generate.main, "Generate a lean image build context from VALIDATE_* flags."),
23
+ "drift": (drift.main, "Fail when changed files are not covered by the configured linters."),
24
+ "sync-mapping": (mapping_sync.main, "Compare the linter mapping with an upstream checkout and add drafts."),
25
+ "release-notes": (release_notes.main, "Write release notes comparing benchmarks of two upstream versions."),
26
+ "bench": (bench.main, "Time cold or warm pulls and container start of an image."),
27
+ "compare": (report.main_compare, "Compare lean images with their upstream baseline from benchmark results."),
28
+ "summarize": (report.main_summarize, "Summarize benchmark results, one row per variant."),
29
+ "repository-settings": (repository_settings.main, "Print or apply the repository settings (maintainers)."),
30
+ "smoke": (smoke.main, "Lint upstream's good and bad fixtures with a built image."),
31
+ "select-upstream": (upstream.main, "Pick the upstream release to sync to, honouring a cooldown."),
32
+ }
33
+
34
+
35
+ def usage() -> str:
36
+ width = max(len(name) for name in COMMANDS)
37
+ lines = ["usage: lintsmith <command> [options]", "", "commands:"]
38
+ lines += [f" {name:<{width}} {help_text}" for name, (_, help_text) in COMMANDS.items()]
39
+ lines += [
40
+ "",
41
+ "Run 'lintsmith <command> --help' for the options of a command.",
42
+ " --version show the version",
43
+ ]
44
+ return "\n".join(lines)
45
+
46
+
47
+ def main(argv: Sequence[str] | None = None) -> int:
48
+ args = list(sys.argv[1:] if argv is None else argv)
49
+ if not args or args[0] in ("-h", "--help", "help"):
50
+ print(usage())
51
+ return 0
52
+ if args[0] == "--version":
53
+ print(f"lintsmith {__version__}")
54
+ return 0
55
+ command = args[0]
56
+ if command not in COMMANDS:
57
+ print(f"lintsmith: unknown command '{command}'\n\n{usage()}", file=sys.stderr)
58
+ return 2
59
+ return COMMANDS[command][0](args[1:]) or 0
lintsmith/config.py ADDED
@@ -0,0 +1,81 @@
1
+ """Read a repository's Super-Linter configuration.
2
+
3
+ Sources: env files (VALIDATE_*=true lines) and GitHub Actions workflows (the
4
+ `env:` of the workflow, the job and the Super-Linter step).
5
+
6
+ Semantics follow upstream lib/functions/validation.sh:
7
+ - no VALIDATE_<LANGUAGE> variable set: every linter is enabled
8
+ - any set to true and none to false: only those are enabled
9
+ - any set to false and none to true: every linter except those is enabled
10
+ - mixing true and false is an error
11
+ """
12
+
13
+ import re
14
+ from collections.abc import Iterable, Mapping
15
+ from pathlib import Path
16
+
17
+ NON_LINTER = {"ALL_CODEBASE"}
18
+ SETTINGS = ("FILTER_REGEX_INCLUDE", "FILTER_REGEX_EXCLUDE", "LINTER_RULES_PATH")
19
+
20
+
21
+ class ConfigError(Exception):
22
+ pass
23
+
24
+
25
+ def parse_env_text(text: str) -> dict[str, str]:
26
+ out = {}
27
+ for line in text.splitlines():
28
+ m = re.match(r"\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*?)\s*$", line)
29
+ if m:
30
+ out[m.group(1)] = m.group(2).strip("\"'")
31
+ return out
32
+
33
+
34
+ def parse_env_files(paths: Iterable[str | Path]) -> dict[str, str]:
35
+ out = {}
36
+ for p in paths:
37
+ out.update(parse_env_text(Path(p).read_text()))
38
+ return out
39
+
40
+
41
+ def parse_workflow(path: str | Path) -> dict[str, str]:
42
+ """Merge the env of every Super-Linter step found in a workflow."""
43
+ import yaml # PyYAML is on GitHub-hosted runners
44
+
45
+ doc = yaml.safe_load(Path(path).read_text()) or {}
46
+ out, found = {}, False
47
+ base = {k: str(v) for k, v in (doc.get("env") or {}).items()}
48
+ for job in (doc.get("jobs") or {}).values():
49
+ job_env = {k: str(v) for k, v in (job.get("env") or {}).items()}
50
+ for step in job.get("steps") or []:
51
+ uses = str(step.get("uses", ""))
52
+ if not uses.startswith("super-linter/super-linter"):
53
+ continue
54
+ found = True
55
+ step_env = {k: str(v) for k, v in (step.get("env") or {}).items()}
56
+ out.update({**base, **job_env, **step_env})
57
+ if not found:
58
+ raise ConfigError(f"no super-linter/super-linter step found in {path}")
59
+ return out
60
+
61
+
62
+ def effective_flags(env: Mapping[str, str], all_flags: Iterable[str]) -> list[str]:
63
+ """Flags that run, given the environment and the full list of upstream flags."""
64
+ set_true: set[str] = set()
65
+ set_false: set[str] = set()
66
+ for key, value in env.items():
67
+ m = re.fullmatch(r"VALIDATE_([A-Z0-9_]+)", key)
68
+ if not m or m.group(1) in NON_LINTER:
69
+ continue
70
+ flag = m.group(1)
71
+ v = value.strip().lower()
72
+ if v not in ("true", "false"):
73
+ raise ConfigError(f"{key} must be true or false, got {value!r}")
74
+ (set_true if v == "true" else set_false).add(flag)
75
+ if set_true and set_false:
76
+ raise ConfigError("VALIDATE_* variables mix true and false: " + ", ".join(sorted(set_true | set_false)))
77
+ if set_true:
78
+ return sorted(set_true)
79
+ if set_false:
80
+ return sorted(set(all_flags) - set_false)
81
+ return sorted(all_flags)