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 +8 -0
- lintsmith/__main__.py +6 -0
- lintsmith/bench.py +172 -0
- lintsmith/cli.py +59 -0
- lintsmith/config.py +81 -0
- lintsmith/data/filetypes.json +475 -0
- lintsmith/data/linters.json +1184 -0
- lintsmith/data/prune-node-modules.js +91 -0
- lintsmith/drift.py +186 -0
- lintsmith/generate.py +348 -0
- lintsmith/mapping_sync.py +163 -0
- lintsmith/release_notes.py +164 -0
- lintsmith/report.py +241 -0
- lintsmith/repository_settings.py +146 -0
- lintsmith/smoke.py +318 -0
- lintsmith/upstream.py +109 -0
- lintsmith-0.0.16.dist-info/METADATA +210 -0
- lintsmith-0.0.16.dist-info/RECORD +21 -0
- lintsmith-0.0.16.dist-info/WHEEL +4 -0
- lintsmith-0.0.16.dist-info/entry_points.txt +2 -0
- lintsmith-0.0.16.dist-info/licenses/LICENSE +21 -0
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
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)
|