gate-cache 0.5.0__tar.gz → 0.6.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: gate-cache
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Skip lint and test gates that already passed on the exact same git tree.
5
5
  Author: Nate Landau
6
6
  Author-email: Nate Landau <github@natenate.org>
@@ -29,8 +29,7 @@ Classifier: Programming Language :: Python :: 3.11
29
29
  Classifier: Programming Language :: Python :: 3.12
30
30
  Classifier: Programming Language :: Python :: 3.13
31
31
  Classifier: Programming Language :: Python :: 3.14
32
- Requires-Dist: cyclopts>=5.2.0,<6.0.0
33
- Requires-Dist: nclutils>=3.4.4,<4.0.0
32
+ Classifier: Programming Language :: Python :: 3.15
34
33
  Requires-Dist: platformdirs>=4.13.0,<5.0.0
35
34
  Requires-Python: >=3.11
36
35
  Description-Content-Type: text/markdown
@@ -4,17 +4,14 @@ classifiers = [
4
4
  "Programming Language :: Python :: 3.12",
5
5
  "Programming Language :: Python :: 3.13",
6
6
  "Programming Language :: Python :: 3.14",
7
+ "Programming Language :: Python :: 3.15",
7
8
  ]
8
- dependencies = [
9
- "cyclopts>=5.2.0,<6.0.0",
10
- "nclutils>=3.4.4,<4.0.0",
11
- "platformdirs>=4.13.0,<5.0.0",
12
- ]
9
+ dependencies = ["platformdirs>=4.13.0,<5.0.0"]
13
10
  description = "Skip lint and test gates that already passed on the exact same git tree."
14
11
  name = "gate-cache"
15
12
  readme = "README.md"
16
13
  requires-python = ">=3.11"
17
- version = "0.5.0"
14
+ version = "0.6.0"
18
15
 
19
16
  [[project.authors]]
20
17
  name = "Nate Landau"
@@ -28,7 +25,7 @@ gate-cache = "gate_cache.cli:main"
28
25
 
29
26
  [build-system]
30
27
  build-backend = "uv_build"
31
- requires = ["uv_build>=0.11.0,<0.12.0"]
28
+ requires = ["uv_build>=0.13.0,<0.14"]
32
29
 
33
30
  [tool.uv.build-backend]
34
31
  module-name = "gate_cache"
@@ -5,25 +5,24 @@
5
5
  "Programming Language :: Python :: 3.12",
6
6
  "Programming Language :: Python :: 3.13",
7
7
  "Programming Language :: Python :: 3.14",
8
+ "Programming Language :: Python :: 3.15",
8
9
  ]
9
10
  dependencies = [
10
- "cyclopts>=5.2.0,<6.0.0",
11
- "nclutils>=3.4.4,<4.0.0",
12
11
  "platformdirs>=4.13.0,<5.0.0",
13
- ]
12
+ ]
14
13
  description = "Skip lint and test gates that already passed on the exact same git tree."
15
14
  license = { file = "LICENSE" }
16
15
  name = "gate-cache"
17
16
  readme = "README.md"
18
17
  requires-python = ">=3.11"
19
- version = "0.5.0"
18
+ version = "0.6.0"
20
19
 
21
20
  [project.scripts]
22
21
  gate-cache = "gate_cache.cli:main"
23
22
 
24
23
  [build-system]
25
24
  build-backend = "uv_build"
26
- requires = ["uv_build>=0.11.0,<0.12.0"]
25
+ requires = ["uv_build>=0.13.0,<0.14"]
27
26
 
28
27
  [tool.uv.build-backend]
29
28
  module-name = "gate_cache"
@@ -1,3 +1,3 @@
1
1
  """Skip lint and test gates that already passed on the exact same git tree."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.6.0"
@@ -0,0 +1,247 @@
1
+ """Command line interface for gate-cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import shlex
7
+ import sys
8
+ from typing import TYPE_CHECKING
9
+
10
+ from gate_cache import __version__
11
+ from gate_cache.console import say
12
+ from gate_cache.gate import clear_cache, gate_status, run_gate
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Sequence
16
+
17
+ _USAGE_ERROR = 2
18
+ _INTERRUPTED = 130
19
+ _INFO_FLAGS = {"-h", "--help", "--version"}
20
+ _NAME_NEEDS_VALUE = "--name needs a value, e.g. gate-cache --name pytest -- uv run pytest"
21
+ _NEEDS_SEPARATOR = "put -- before the command, e.g. gate-cache -- uv run pytest"
22
+ _NAME_REPEATED = "--name given more than once"
23
+ _NO_COMMAND = "no command given after --"
24
+
25
+ _USAGE = """\
26
+ gate-cache [--name NAME] -- COMMAND [ARGS...]
27
+ gate-cache status [--name NAME] [--json]
28
+ gate-cache clear"""
29
+
30
+ _HELP = """\
31
+ Skip a lint or test command that already passed on the same git tree. gate-cache
32
+ runs the command, records a pass keyed on the git tree of the working copy
33
+ (tracked and untracked, non-ignored files), and skips the command next time the
34
+ tree is identical. A pass on uncommitted work therefore skips when the same
35
+ content is committed.
36
+
37
+ -- is required, and only --name may come before it. Everything after -- is the
38
+ command, verbatim. Without --name, the gate name is the command itself, so each
39
+ set of arguments has its own records. Give --name to share records between
40
+ commands that check the same thing, or to key on inputs that the tree does not
41
+ cover.
42
+
43
+ Exit codes:
44
+ run the command's own exit code, or 0 when a recorded pass lets it skip.
45
+ Usage errors exit 2.
46
+ status 0 when the named gate, or any gate without --name, passed on the
47
+ current tree. 1 otherwise. --json prints one JSON object instead.
48
+ clear 0 on success, 1 on failure.
49
+
50
+ GATE_CACHE_DISABLE and CI are on when the value is not empty, 0, or false (any
51
+ case). If GATE_CACHE_DISABLE is on, the command always runs without the cache.
52
+ If it is 0 or false, caching stays on. If it is empty or not set, CI decides the
53
+ same way.
54
+ Set GATE_CACHE_MAX_AGE_DAYS to a positive whole number to change how many days a
55
+ recorded pass lasts (default 30).
56
+
57
+ Only the name and the file content are keyed. The working directory is not, so
58
+ a command whose meaning depends on it needs a distinct --name. To also key on an
59
+ ignored file, an environment variable, or a tool version, put its value in
60
+ --name. A changed value gives a new name, so the command runs again. Never put a
61
+ secret in a name, because markers and status store it as plain text. Without
62
+ --name the command line is the name, so give --name to a command that takes a
63
+ secret argument. Never wrap checks that depend on the commit message or the
64
+ branch name.
65
+
66
+ Typical use:
67
+ gate-cache -- uv run pytest -x
68
+ gate-cache --name pytest -- uv run pytest -x
69
+ gate-cache --name "test-$(git hash-object .env)" -- uv run pytest
70
+ """
71
+
72
+ _RUN_HELP = """\
73
+ subcommands:
74
+ status Report whether gates would skip, without running anything.
75
+ clear Delete every recorded pass for this repository.
76
+
77
+ run:
78
+ gate-cache [--name NAME] -- COMMAND [ARGS...] runs COMMAND unless it already
79
+ passed on the current git tree. On a skip, it prints one line to stderr and
80
+ exits 0 without running anything. Otherwise it runs the command and exits with
81
+ its exit code. The command runs without caching (and nothing is recorded) when
82
+ GATE_CACHE_DISABLE or CI turns caching off, the tree is unknown (for example an
83
+ unmerged index), or this is not a git repository. A pass is recorded only when
84
+ the command exits 0 and the run leaves the tree unchanged.
85
+
86
+ Run gate-cache SUBCOMMAND --help for the options of a subcommand.
87
+ """
88
+
89
+ _STATUS_HELP = """\
90
+ Report whether gates would skip, without running anything.
91
+
92
+ With --name, print the tree hash (or why it is unknown), the marker path,
93
+ hit or miss, and how the last run on this tree failed when it did. Exit 0 on a
94
+ hit, 1 otherwise.
95
+
96
+ Without --name, list the gates that passed on the current tree and the gates
97
+ that ran on it without passing. Exit 0 when at least one gate passed, 1 otherwise.
98
+ """
99
+
100
+ _CLEAR_HELP = """\
101
+ Delete every recorded pass for this repository.
102
+
103
+ Use after something outside git changes, such as a tool upgrade. Exits 1 outside a
104
+ git repository.
105
+ """
106
+
107
+
108
+ class _UsageError(Exception):
109
+ """A malformed run invocation, reported as one line on stderr with exit code 2."""
110
+
111
+
112
+ class _StoreOnce(argparse.Action):
113
+ """Store an option's value, rejecting a second occurrence instead of overwriting the first."""
114
+
115
+ def __call__(
116
+ self,
117
+ parser: argparse.ArgumentParser,
118
+ namespace: argparse.Namespace,
119
+ values: str | Sequence[object] | None,
120
+ option_string: str | None = None,
121
+ ) -> None:
122
+ if getattr(namespace, self.dest) is not None:
123
+ parser.error(f"{option_string} given more than once")
124
+ setattr(namespace, self.dest, values)
125
+
126
+
127
+ def _main_parser() -> argparse.ArgumentParser:
128
+ """Build the top-level parser, which serves only --help and --version.
129
+
130
+ The run form is not parsed here because everything after -- must reach the wrapped
131
+ command verbatim. The parser still declares --name so that --help documents it.
132
+ """
133
+ parser = argparse.ArgumentParser(
134
+ prog="gate-cache",
135
+ usage=_USAGE,
136
+ description=_HELP,
137
+ epilog=_RUN_HELP,
138
+ formatter_class=argparse.RawDescriptionHelpFormatter,
139
+ allow_abbrev=False,
140
+ )
141
+ parser.add_argument("--version", action="version", version=__version__)
142
+ parser.add_argument(
143
+ "--name",
144
+ metavar="NAME",
145
+ help="Gate name. Records match only when the name and the tree both match. "
146
+ "Defaults to the command and its arguments, quoted as a shell would.",
147
+ )
148
+ return parser
149
+
150
+
151
+ def _status_parser() -> argparse.ArgumentParser:
152
+ """Build the parser for the status subcommand."""
153
+ parser = argparse.ArgumentParser(
154
+ prog="gate-cache status",
155
+ description=_STATUS_HELP,
156
+ formatter_class=argparse.RawDescriptionHelpFormatter,
157
+ allow_abbrev=False,
158
+ )
159
+ parser.add_argument(
160
+ "--name",
161
+ action=_StoreOnce,
162
+ help="Gate name to look up. Omit it to list every gate that ran on this tree.",
163
+ )
164
+ parser.add_argument(
165
+ "--json",
166
+ dest="as_json",
167
+ action="store_true",
168
+ help="Print one JSON object to stdout instead of text. Exit codes are unchanged.",
169
+ )
170
+ return parser
171
+
172
+
173
+ def _clear_parser() -> argparse.ArgumentParser:
174
+ """Build the parser for the clear subcommand."""
175
+ return argparse.ArgumentParser(
176
+ prog="gate-cache clear",
177
+ description=_CLEAR_HELP,
178
+ formatter_class=argparse.RawDescriptionHelpFormatter,
179
+ allow_abbrev=False,
180
+ )
181
+
182
+
183
+ def _split_run(tokens: list[str]) -> tuple[str | None, list[str]]:
184
+ """Split a run invocation into its --name value and the command after the first --.
185
+
186
+ Only `--name X` or `--name=X` may precede `--`, so a stray option such as `--version`
187
+ or the first word of the command can never be mistaken for one of ours.
188
+
189
+ Raises:
190
+ _UsageError: When anything else precedes `--`, `--` is missing, or `--name` is
191
+ missing its value or given twice.
192
+ """
193
+ name: str | None = None
194
+ index = 0
195
+ while index < len(tokens):
196
+ arg = tokens[index]
197
+ if arg == "--":
198
+ return name, tokens[index + 1 :]
199
+ if arg == "--name":
200
+ if index + 1 == len(tokens) or tokens[index + 1] == "--":
201
+ raise _UsageError(_NAME_NEEDS_VALUE)
202
+ value = tokens[index + 1]
203
+ index += 2
204
+ elif arg.startswith("--name="):
205
+ value = arg.removeprefix("--name=")
206
+ index += 1
207
+ else:
208
+ break
209
+ if name is not None:
210
+ raise _UsageError(_NAME_REPEATED)
211
+ name = value
212
+ raise _UsageError(_NEEDS_SEPARATOR)
213
+
214
+
215
+ def _dispatch(tokens: list[str]) -> int:
216
+ """Run the invocation in tokens and return its exit code.
217
+
218
+ Raises:
219
+ _UsageError: When a run invocation is malformed.
220
+ """
221
+ if tokens and tokens[0] in _INFO_FLAGS:
222
+ _main_parser().parse_args(tokens) # prints help or the version, then exits
223
+ if tokens and tokens[0] == "status":
224
+ args = _status_parser().parse_args(tokens[1:])
225
+ return gate_status(args.name, as_json=args.as_json)
226
+ if tokens and tokens[0] == "clear":
227
+ _clear_parser().parse_args(tokens[1:])
228
+ return clear_cache()
229
+
230
+ name, command = _split_run(tokens)
231
+ if not command:
232
+ raise _UsageError(_NO_COMMAND)
233
+ if name == "":
234
+ raise _UsageError(_NAME_NEEDS_VALUE)
235
+ return run_gate(shlex.join(command) if name is None else name, command)
236
+
237
+
238
+ def main() -> None:
239
+ """Run the gate-cache command line interface and exit with its code."""
240
+ try:
241
+ code = _dispatch(sys.argv[1:])
242
+ except _UsageError as error:
243
+ say(str(error))
244
+ sys.exit(_USAGE_ERROR)
245
+ except KeyboardInterrupt:
246
+ sys.exit(_INTERRUPTED)
247
+ sys.exit(code)
@@ -0,0 +1,43 @@
1
+ """User-facing gate-cache messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ _DIM = "\033[2m"
9
+ _RESET = "\033[0m"
10
+
11
+
12
+ def say(message: str) -> None:
13
+ """Print a gate-cache message to stderr, verbatim.
14
+
15
+ Args:
16
+ message: Text to print after the `gate-cache: ` prefix.
17
+ """
18
+ print(f"gate-cache: {message}", file=sys.stderr) # noqa: T201
19
+
20
+
21
+ def _use_color() -> bool:
22
+ """Return True when stdout is a terminal that accepts ANSI styling."""
23
+ if os.environ.get("NO_COLOR") or os.environ.get("TERM") == "dumb":
24
+ return False
25
+ return sys.stdout.isatty()
26
+
27
+
28
+ def kv(rows: dict[str, str]) -> None:
29
+ """Print key/value rows on stdout with the values aligned in one column.
30
+
31
+ Args:
32
+ rows: Labels mapped to their values, printed in insertion order.
33
+ """
34
+ if not rows:
35
+ return
36
+ cell_width = max(len(key) for key in rows) + len(": ")
37
+ dim, reset = (_DIM, _RESET) if _use_color() else ("", "")
38
+ for key, value in rows.items():
39
+ pad = " " * (cell_width - len(key) - len(": "))
40
+ first, *rest = value.splitlines() or [""]
41
+ print(f" {dim}{key}{reset}: {pad}{first}") # noqa: T201
42
+ for line in rest:
43
+ print(f"{' ' * (2 + cell_width)}{line}") # noqa: T201
@@ -7,9 +7,7 @@ import json
7
7
  import os
8
8
  from typing import TYPE_CHECKING, Any
9
9
 
10
- from nclutils import pp
11
-
12
- from gate_cache.console import say
10
+ from gate_cache.console import kv, say
13
11
  from gate_cache.markers import (
14
12
  clear_markers,
15
13
  marker_exists,
@@ -285,7 +283,7 @@ def _named_status(name: str, snapshot: Snapshot, common_dir: Path | None, *, as_
285
283
  }
286
284
  if last_run is not None:
287
285
  rows["last run"] = str(last_run["reason"])
288
- pp.kv(rows)
286
+ kv(rows)
289
287
  return 0 if hit else 1
290
288
 
291
289
 
@@ -312,14 +310,14 @@ def _list_gates(snapshot: Snapshot, common_dir: Path | None, *, as_json: bool) -
312
310
  return code
313
311
 
314
312
  if tree is None:
315
- pp.kv({"tree": f"unknown: {snapshot.reason}"})
313
+ kv({"tree": f"unknown: {snapshot.reason}"})
316
314
  return 1
317
315
  if common_dir is None:
318
- pp.kv({"tree": tree, "passed": f"unknown: {_NO_COMMON_DIR_REASON}"})
316
+ kv({"tree": tree, "passed": f"unknown: {_NO_COMMON_DIR_REASON}"})
319
317
  return 1
320
318
  if not passed and not missed:
321
- pp.kv({"tree": tree})
322
- pp.console().print("No gates match the current tree", markup=False, highlight=False)
319
+ kv({"tree": tree})
320
+ print("No gates match the current tree") # noqa: T201
323
321
  return 1
324
322
  rows = {"tree": tree}
325
323
  if passed:
@@ -328,7 +326,7 @@ def _list_gates(snapshot: Snapshot, common_dir: Path | None, *, as_json: bool) -
328
326
  rows["missed"] = ", ".join(
329
327
  f"{m['name']} ({m['reason']})" if m["reason"] else m["name"] for m in missed
330
328
  )
331
- pp.kv(rows)
329
+ kv(rows)
332
330
  return code
333
331
 
334
332
 
@@ -0,0 +1,98 @@
1
+ """Run git subcommands and capture their output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Mapping
12
+
13
+
14
+ class GitError(Exception):
15
+ """Base class for every error raised while running git."""
16
+
17
+
18
+ class GitNotFoundError(GitError):
19
+ """Raised when the git executable is not on PATH."""
20
+
21
+
22
+ class GitFailedError(GitError):
23
+ """Raised when git cannot start in the requested directory or exits with an error."""
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class GitResult:
28
+ """The exit code and decoded output of a finished git command.
29
+
30
+ Attributes:
31
+ returncode: The exit code of the git process.
32
+ stdout: Standard output with trailing newlines removed.
33
+ stderr: Standard error with trailing newlines removed.
34
+ """
35
+
36
+ returncode: int
37
+ stdout: str
38
+ stderr: str
39
+
40
+
41
+ def run_git(
42
+ *args: str,
43
+ cwd: Path | str | None = None,
44
+ env: Mapping[str, str] | None = None,
45
+ check: bool = True,
46
+ ) -> GitResult:
47
+ """Run `git` with the given arguments and return its decoded output.
48
+
49
+ Output is decoded as strict UTF-8 from raw bytes. Text mode would use the locale
50
+ encoding and translate carriage returns in NUL-delimited path listings.
51
+
52
+ Args:
53
+ *args: The git subcommand and its arguments.
54
+ cwd: Directory to run git in. None inherits the process working directory.
55
+ env: Environment that replaces the inherited one, or None to inherit it.
56
+ check: Raise GitFailedError when git exits with a non-zero code.
57
+
58
+ Returns:
59
+ The exit code and output of the command.
60
+
61
+ Raises:
62
+ GitNotFoundError: git is not on PATH.
63
+ GitFailedError: cwd is not a directory, git cannot start, or git fails under check.
64
+ UnicodeDecodeError: git printed output that is not valid UTF-8.
65
+ """
66
+ argv = ["git", *args]
67
+ resolved: Path | None = None
68
+ if cwd is not None:
69
+ resolved = Path(cwd).expanduser().resolve()
70
+ # Popen reports a missing cwd as FileNotFoundError, the same as a missing git.
71
+ if not resolved.is_dir():
72
+ msg = f"Cannot enter directory {resolved}: not a directory"
73
+ raise GitFailedError(msg)
74
+ try:
75
+ proc = subprocess.run( # noqa: S603
76
+ argv,
77
+ cwd=resolved,
78
+ env=dict(env) if env is not None else None,
79
+ stdin=subprocess.DEVNULL,
80
+ capture_output=True,
81
+ check=False,
82
+ )
83
+ except FileNotFoundError as e:
84
+ msg = "git not found on PATH"
85
+ raise GitNotFoundError(msg) from e
86
+ except OSError as e:
87
+ msg = f"Failed to start git: {e}"
88
+ raise GitFailedError(msg) from e
89
+
90
+ result = GitResult(
91
+ returncode=proc.returncode,
92
+ stdout=proc.stdout.decode("utf-8").rstrip("\n"),
93
+ stderr=proc.stderr.decode("utf-8").rstrip("\n"),
94
+ )
95
+ if check and result.returncode != 0:
96
+ msg = f"git {' '.join(args)} failed with exit code {result.returncode}: {result.stderr}"
97
+ raise GitFailedError(msg)
98
+ return result
@@ -8,11 +8,10 @@ import json
8
8
  import shutil
9
9
  import tempfile
10
10
  import time
11
+ from datetime import UTC, datetime
11
12
  from pathlib import Path
12
13
  from typing import TYPE_CHECKING, Any
13
14
 
14
- from nclutils.utils import iso_timestamp
15
-
16
15
  if TYPE_CHECKING:
17
16
  from collections.abc import Sequence
18
17
 
@@ -125,7 +124,8 @@ def write_miss(
125
124
  def _write_json(path: Path, payload: dict[str, object]) -> None:
126
125
  """Write a timestamped JSON record via a temp file and rename, so readers never see half."""
127
126
  path.parent.mkdir(parents=True, exist_ok=True)
128
- payload = {**payload, "timestamp": iso_timestamp()}
127
+ timestamp = datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
128
+ payload = {**payload, "timestamp": timestamp}
129
129
  tmp_name: str | None = None
130
130
  try:
131
131
  with tempfile.NamedTemporaryFile(
@@ -7,10 +7,8 @@ import shutil
7
7
  from dataclasses import dataclass
8
8
  from pathlib import Path
9
9
 
10
- from nclutils.git import run_git
11
- from nclutils.sh import ShellCommandError, ShellCommandNotFoundError
12
-
13
10
  from gate_cache import scratch
11
+ from gate_cache.git import GitError, GitNotFoundError, run_git
14
12
 
15
13
 
16
14
  @dataclass(frozen=True)
@@ -327,16 +325,16 @@ def take_snapshot(cwd: Path | None = None) -> Snapshot:
327
325
  try:
328
326
  env = _git_env(cwd)
329
327
  state = _inspect(cwd, env)
330
- except ShellCommandNotFoundError:
328
+ except GitNotFoundError:
331
329
  return Snapshot(tree=None, reason="git not found")
332
- except (ShellCommandError, UnicodeDecodeError):
330
+ except (GitError, UnicodeDecodeError):
333
331
  return Snapshot(tree=None, reason="could not inspect git state")
334
332
  if state.reason or state.root is None:
335
333
  return Snapshot(tree=None, reason=state.reason or "could not locate work tree")
336
334
 
337
335
  try:
338
336
  return _private_snapshot(cwd, state.root, env)
339
- except (ShellCommandError, OSError, UnicodeDecodeError):
337
+ except (GitError, OSError, UnicodeDecodeError):
340
338
  return Snapshot(tree=None, reason="could not hash working copy")
341
339
 
342
340
 
@@ -351,7 +349,7 @@ def git_common_dir(cwd: Path | None = None) -> Path | None:
351
349
  """
352
350
  try:
353
351
  result = run_git("rev-parse", "--git-common-dir", cwd=cwd)
354
- except (ShellCommandError, UnicodeDecodeError):
352
+ except (GitError, UnicodeDecodeError):
355
353
  return None
356
354
  output = result.stdout.strip()
357
355
  if not output or "\n" in output:
@@ -1,179 +0,0 @@
1
- """Command line interface for gate-cache."""
2
-
3
- from __future__ import annotations
4
-
5
- import shlex
6
- import sys
7
- from typing import Annotated
8
-
9
- from cyclopts import App, Parameter
10
-
11
- from gate_cache import __version__
12
- from gate_cache.console import say
13
- from gate_cache.gate import clear_cache, gate_status, run_gate
14
-
15
- _USAGE_ERROR = 2
16
- _INTERRUPTED = 130
17
- _SUBCOMMANDS = {"status", "clear"}
18
- _INFO_FLAGS = {"-h", "--help", "--version"}
19
-
20
- _HELP = """\
21
- Skip a lint or test command that already passed on the same git tree. gate-cache
22
- runs the command, records a pass keyed on the git tree of the working copy
23
- (tracked and untracked, non-ignored files), and skips the command next time the
24
- tree is identical. A pass on uncommitted work therefore skips when the same
25
- content is committed.
26
-
27
- -- is required, and only --name may come before it. Everything after -- is the
28
- command, verbatim. Without --name, the gate name is the command itself, so each
29
- set of arguments has its own records. Give --name to share records between
30
- commands that check the same thing, or to key on inputs that the tree does not
31
- cover.
32
-
33
- Exit codes:
34
- run the command's own exit code, or 0 when a recorded pass lets it skip.
35
- Usage errors exit 2.
36
- status 0 when the named gate, or any gate without --name, passed on the
37
- current tree. 1 otherwise. --json prints one JSON object instead.
38
- clear 0 on success, 1 on failure.
39
-
40
- GATE_CACHE_DISABLE and CI are on when the value is not empty, 0, or false (any
41
- case). If GATE_CACHE_DISABLE is on, the command always runs without the cache.
42
- If it is 0 or false, caching stays on. If it is empty or not set, CI decides the
43
- same way.
44
- Set GATE_CACHE_MAX_AGE_DAYS to a positive whole number to change how many days a
45
- recorded pass lasts (default 30).
46
-
47
- Only the name and the file content are keyed. The working directory is not, so
48
- a command whose meaning depends on it needs a distinct --name. To also key on an
49
- ignored file, an environment variable, or a tool version, put its value in
50
- --name. A changed value gives a new name, so the command runs again. Never put a
51
- secret in a name, because markers and status store it as plain text. Without
52
- --name the command line is the name, so give --name to a command that takes a
53
- secret argument. Never wrap checks that depend on the commit message or the
54
- branch name.
55
-
56
- Typical use:
57
- gate-cache -- uv run pytest -x
58
- gate-cache --name pytest -- uv run pytest -x
59
- gate-cache --name "test-$(git hash-object .env)" -- uv run pytest
60
- """
61
-
62
- app = App(
63
- name="gate-cache",
64
- version=__version__,
65
- help=_HELP,
66
- help_format="plaintext",
67
- usage=(
68
- "Usage: gate-cache [--name NAME] -- COMMAND [ARGS...]\n"
69
- " gate-cache status [--name NAME] [--json]\n"
70
- " gate-cache clear"
71
- ),
72
- result_action="return_int_as_exit_code_else_zero",
73
- )
74
-
75
-
76
- @app.default
77
- def run(
78
- command: Annotated[
79
- list[str], Parameter(allow_leading_hyphen=True, negative_iterable="", show_default=False)
80
- ],
81
- /,
82
- *,
83
- name: str | None = None,
84
- ) -> int:
85
- """Run COMMAND unless it already passed on the current git tree.
86
-
87
- On a skip, prints one line to stderr and exits 0 without running anything. Otherwise
88
- runs the command and exits with its exit code. The command runs without caching
89
- (and nothing is recorded) when GATE_CACHE_DISABLE or CI turns caching off, the tree
90
- is unknown (for example an unmerged index), or this is not a git repository. A pass is recorded only
91
- when the command exits 0 and the run leaves the tree unchanged.
92
-
93
- Parameters
94
- ----------
95
- command
96
- The command and its arguments, given after `--`. Example: -- uv run pytest -x
97
- name
98
- Gate name. Records match only when the name and the tree both match.
99
- Defaults to the command and its arguments, quoted as a shell would.
100
- """
101
- if not command:
102
- say("no command given after --")
103
- return _USAGE_ERROR
104
- if name == "":
105
- say("--name needs a value, e.g. gate-cache --name pytest -- uv run pytest")
106
- return _USAGE_ERROR
107
- return run_gate(shlex.join(command) if name is None else name, command)
108
-
109
-
110
- @app.command
111
- def status(
112
- *,
113
- name: str | None = None,
114
- as_json: Annotated[bool, Parameter(name="--json", negative="")] = False,
115
- ) -> int:
116
- """Report whether gates would skip, without running anything.
117
-
118
- With --name, print the tree hash (or why it is unknown), the marker path,
119
- hit or miss, and how the last run on this tree failed when it did. Exit 0 on a
120
- hit, 1 otherwise.
121
-
122
- Without --name, list the gates that passed on the current tree and the gates
123
- that ran on it without passing. Exit 0 when at least one gate passed, 1 otherwise.
124
-
125
- Parameters
126
- ----------
127
- name
128
- Gate name to look up. Omit it to list every gate that ran on this tree.
129
- as_json
130
- Print one JSON object to stdout instead of text. Exit codes are unchanged.
131
- """
132
- return gate_status(name, as_json=as_json)
133
-
134
-
135
- @app.command
136
- def clear() -> int:
137
- """Delete every recorded pass for this repository.
138
-
139
- Use after something outside git changes, such as a tool upgrade. Exits 1 outside a
140
- git repository.
141
- """
142
- return clear_cache()
143
-
144
-
145
- def _usage_error(tokens: list[str]) -> str | None:
146
- """Return the usage problem with a run invocation, or None when it is well formed.
147
-
148
- Only `--name X` or `--name=X` may precede `--`, so a stray option such as `--version`
149
- or the first word of the command can never be mistaken for one of ours.
150
- """
151
- if not tokens or tokens[0] in _SUBCOMMANDS | _INFO_FLAGS:
152
- return None
153
- index = 0
154
- while index < len(tokens):
155
- arg = tokens[index]
156
- if arg == "--":
157
- return None
158
- if arg == "--name":
159
- if index + 1 == len(tokens) or tokens[index + 1] == "--":
160
- return "--name needs a value, e.g. gate-cache --name pytest -- uv run pytest"
161
- index += 2
162
- elif arg.startswith("--name="):
163
- index += 1
164
- else:
165
- break
166
- return "put -- before the command, e.g. gate-cache -- uv run pytest"
167
-
168
-
169
- def main() -> None:
170
- """Run the gate-cache command line interface and exit with its code."""
171
- tokens = sys.argv[1:]
172
- if problem := _usage_error(tokens):
173
- say(problem)
174
- sys.exit(_USAGE_ERROR)
175
- try:
176
- code = app(tokens)
177
- except KeyboardInterrupt:
178
- sys.exit(_INTERRUPTED)
179
- sys.exit(code)
@@ -1,14 +0,0 @@
1
- """User-facing gate-cache messages."""
2
-
3
- from __future__ import annotations
4
-
5
- from nclutils import pp
6
-
7
-
8
- def say(message: str) -> None:
9
- """Print a gate-cache message to stderr, verbatim.
10
-
11
- Args:
12
- message: Text to print after the `gate-cache: ` prefix.
13
- """
14
- pp.err_console().print(f"gate-cache: {message}", markup=False, highlight=False, soft_wrap=True)
File without changes
File without changes