gate-cache 0.4.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.4.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
@@ -41,14 +40,14 @@ Skip a command that already passed on the same code.
41
40
 
42
41
  A lint or test command often runs many times on code that did not change. It runs when you finish the work, in a pre-commit hook, after a rebase that changed nothing, and in a pre-push hook. When an agent works in the repository, it runs the same command again. If the command takes minutes, the repeat runs take most of the time between finished code and a pushed branch.
43
42
 
44
- gate-cache remembers which commands passed on which code. It wraps a command and gives it a name. When the command passes, gate-cache writes a marker for that name and for the exact content of the files in the working copy that git does not ignore. The next time the same name runs on the same content, gate-cache finds the marker, skips the command, and exits 0 at once.
43
+ gate-cache remembers which commands passed on which code. It wraps a command, and the command line is the name of the gate. When the command passes, gate-cache writes a marker for that name and for the exact content of the files in the working copy that git does not ignore. The next time the same name runs on the same content, gate-cache finds the marker, skips the command, and exits 0 at once.
45
44
 
46
45
  ```console
47
- $ gate-cache --name pytest -- uv run pytest
46
+ $ gate-cache -- uv run pytest
48
47
  ============================= 412 passed in 94.20s =============================
49
48
 
50
- $ gate-cache --name pytest -- uv run pytest
51
- gate-cache: pytest already passed on tree aaff74984ccc, skipping
49
+ $ gate-cache -- uv run pytest
50
+ gate-cache: uv run pytest already passed on tree aaff74984ccc, skipping
52
51
  ```
53
52
 
54
53
  A change to any file that git does not ignore changes the content, so no marker matches and the command runs again. A CI runner that starts from a fresh clone has no markers, so it runs every command.
@@ -65,14 +64,22 @@ The prek and pre-commit hooks install gate-cache themselves, so the hooks work w
65
64
 
66
65
  ## Run a command through gate-cache
67
66
 
68
- The name goes after `--name`, and the command goes after `--`:
67
+ The command goes after `--`:
68
+
69
+ ```sh
70
+ gate-cache -- uv run pytest
71
+ gate-cache -- uv run ruff check .
72
+ ```
73
+
74
+ The command and its arguments are the name of the gate. Each name has its own markers, so a pass of `uv run pytest -k unit` never skips `uv run pytest`.
75
+
76
+ To choose the name yourself, put `--name` before `--`:
69
77
 
70
78
  ```sh
71
79
  gate-cache --name pytest -- uv run pytest
72
- gate-cache --name ruff -- uv run ruff check .
73
80
  ```
74
81
 
75
- Each name has its own markers. A command run under one name never skips because of a marker for another name.
82
+ With `--name`, the name alone identifies the gate, and the arguments of the command are not part of it. Give a name when two different command lines do the same check and must share markers, or when a gate depends on [inputs that git does not track](#inputs-that-git-does-not-track).
76
83
 
77
84
  gate-cache exits with the exit code of the command, or with 0 when it skips the command. It writes a marker only when the command passes, so a command that failed runs again the next time.
78
85
 
@@ -89,18 +96,18 @@ repos:
89
96
  hooks:
90
97
  - id: gate-cache
91
98
  name: pytest
92
- args: [--name, pytest, --, uv, run, pytest]
99
+ args: [--, uv, run, pytest]
93
100
  files: ^(src/|tests/)
94
101
  - id: gate-cache
95
102
  name: ruff
96
- args: [--name, ruff, --, uv, run, ruff, check, .]
103
+ args: [--, uv, run, ruff, check, .]
97
104
  ```
98
105
 
99
106
  The hook runs at commit time and at push time. At commit time, gate-cache matches the content that you commit.
100
107
 
101
108
  The hook runs in its own virtual environment, and the wrapped command inherits that environment. A command started with `uv run`, or a project command such as `just test`, runs in the project environment. `uv run` prints a warning that `VIRTUAL_ENV` does not match the project. The warning has no effect on the run.
102
109
 
103
- Hooks, agents, and your terminal all read and write the same markers. When one of them runs `gate-cache --name pytest -- uv run pytest` and it passes, the others skip that name on the same content.
110
+ Hooks, agents, and your terminal all read and write the same markers. When one of them runs `gate-cache -- uv run pytest` and it passes, the others skip `uv run pytest` on the same content.
104
111
 
105
112
  ## Examples
106
113
 
@@ -110,24 +117,26 @@ These examples show gate-cache inside the task runner of a project. When a recip
110
117
 
111
118
  ```just
112
119
  test:
113
- gate-cache --name test -- uv run pytest
120
+ gate-cache -- uv run pytest
114
121
 
115
122
  lint:
116
- gate-cache --name lint -- uv run ruff check .
123
+ gate-cache -- uv run ruff check .
117
124
  ```
118
125
 
119
126
  After `just test` passes, the next `just test` on the same content skips pytest.
120
127
 
121
128
  ### Recipes with arguments
122
129
 
123
- gate-cache identifies a command by its name alone, not by its arguments. If `just test -k unit` and `just test` ran under the same name, a pass of the unit tests would also skip the full suite. To keep them apart, put the arguments in the name:
130
+ A recipe that passes its arguments to the command needs no `--name`. Each set of arguments gives a different command line, so each set has its own markers:
124
131
 
125
132
  ```just
126
133
  test *args:
127
- gate-cache --name "{{ trim('test ' + args) }}" -- uv run pytest {{ args }}
134
+ gate-cache -- uv run pytest {{ args }}
128
135
  ```
129
136
 
130
- `just test` runs under the name `test`, and `just test -k unit` runs under the name `test -k unit`. Each set of arguments has its own markers. `trim` removes the trailing space when there are no arguments, so `gate-cache status --name test` finds the bare form.
137
+ `just test` runs under the name `uv run pytest`, and `just test -k unit` runs under the name `uv run pytest -k unit`. A pass of the unit tests never skips the full suite.
138
+
139
+ If the recipe gives `--name`, the arguments are not part of the name. Put them in the name, or a pass of `just test -k unit` also skips `just test`.
131
140
 
132
141
  ### Inputs that git does not track
133
142
 
@@ -143,7 +152,7 @@ integration:
143
152
  gate-cache --name "integration-$(git hash-object .env)" -- uv run pytest tests/integration
144
153
  ```
145
154
 
146
- `git hash-object` prints a hash of the file, so the name does not hold the secrets in `.env`. Do not put a secret value in a name, because the marker and `gate-cache status` record the name as plain text.
155
+ `git hash-object` prints a hash of the file, so the name does not hold the secrets in `.env`. Do not put a secret value in a name, because the marker and `gate-cache status` record the name as plain text. Without `--name`, the command line is the name, so give `--name` to a command that takes a secret as an argument.
147
156
 
148
157
  The shell of the recipe expands `$(...)` before gate-cache starts. A hook `entry` or `args` list does not go through a shell, so put the command in a recipe and call the recipe from the hook. To look up the gate with `gate-cache status --name`, give the expanded name, such as `test-uv0.13.0`.
149
158
 
@@ -151,10 +160,10 @@ The shell of the recipe expands `$(...)` before gate-cache starts. A hook `entry
151
160
 
152
161
  ```make
153
162
  test:
154
- gate-cache --name test -- uv run pytest
163
+ gate-cache -- uv run pytest
155
164
 
156
165
  lint:
157
- gate-cache --name lint -- uv run ruff check .
166
+ gate-cache -- uv run ruff check .
158
167
  ```
159
168
 
160
169
  ### A hook that calls the recipe
@@ -200,17 +209,17 @@ GATE_CACHE_DISABLE=1 just test
200
209
  ```console
201
210
  $ gate-cache status
202
211
  tree: aaff74984cccd156a469afa7d9ab10e4777beb24
203
- passed: pytest
204
- missed: ruff (exit code 1)
212
+ passed: uv run pytest
213
+ missed: uv run ruff check . (exit code 1)
205
214
  ```
206
215
 
207
216
  A name is missed when its command failed, or when it exited 0 but changed the files. A later pass of the same name removes it from the `missed` line.
208
217
 
209
- With `--name`, it reports on one name:
218
+ With `--name`, it reports on one name. For a gate that ran without `--name`, give the command line as the name:
210
219
 
211
220
  ```console
212
- $ gate-cache status --name ruff
213
- name: ruff
221
+ $ gate-cache status --name "uv run ruff check ."
222
+ name: uv run ruff check .
214
223
  tree: aaff74984cccd156a469afa7d9ab10e4777beb24
215
224
  marker: /path/to/repo/.git/gate-cache/7d94ca2f57ec0a77f60d328fb405d339fb0c2a645991b97c042ac54403104e9e
216
225
  result: miss
@@ -229,9 +238,9 @@ $ gate-cache status --json
229
238
  "enabled": true,
230
239
  "tree": "aaff74984cccd156a469afa7d9ab10e4777beb24",
231
240
  "reason": null,
232
- "passed": ["pytest"],
241
+ "passed": ["uv run pytest"],
233
242
  "missed": [
234
- {"name": "ruff", "exit_code": 1, "reason": "exit code 1", "timestamp": "2026-10-12T12:31:55Z"}
243
+ {"name": "uv run ruff check .", "exit_code": 1, "reason": "exit code 1", "timestamp": "2026-10-12T12:31:55Z"}
235
244
  ]
236
245
  }
237
246
  ```
@@ -4,14 +4,14 @@ Skip a command that already passed on the same code.
4
4
 
5
5
  A lint or test command often runs many times on code that did not change. It runs when you finish the work, in a pre-commit hook, after a rebase that changed nothing, and in a pre-push hook. When an agent works in the repository, it runs the same command again. If the command takes minutes, the repeat runs take most of the time between finished code and a pushed branch.
6
6
 
7
- gate-cache remembers which commands passed on which code. It wraps a command and gives it a name. When the command passes, gate-cache writes a marker for that name and for the exact content of the files in the working copy that git does not ignore. The next time the same name runs on the same content, gate-cache finds the marker, skips the command, and exits 0 at once.
7
+ gate-cache remembers which commands passed on which code. It wraps a command, and the command line is the name of the gate. When the command passes, gate-cache writes a marker for that name and for the exact content of the files in the working copy that git does not ignore. The next time the same name runs on the same content, gate-cache finds the marker, skips the command, and exits 0 at once.
8
8
 
9
9
  ```console
10
- $ gate-cache --name pytest -- uv run pytest
10
+ $ gate-cache -- uv run pytest
11
11
  ============================= 412 passed in 94.20s =============================
12
12
 
13
- $ gate-cache --name pytest -- uv run pytest
14
- gate-cache: pytest already passed on tree aaff74984ccc, skipping
13
+ $ gate-cache -- uv run pytest
14
+ gate-cache: uv run pytest already passed on tree aaff74984ccc, skipping
15
15
  ```
16
16
 
17
17
  A change to any file that git does not ignore changes the content, so no marker matches and the command runs again. A CI runner that starts from a fresh clone has no markers, so it runs every command.
@@ -28,14 +28,22 @@ The prek and pre-commit hooks install gate-cache themselves, so the hooks work w
28
28
 
29
29
  ## Run a command through gate-cache
30
30
 
31
- The name goes after `--name`, and the command goes after `--`:
31
+ The command goes after `--`:
32
+
33
+ ```sh
34
+ gate-cache -- uv run pytest
35
+ gate-cache -- uv run ruff check .
36
+ ```
37
+
38
+ The command and its arguments are the name of the gate. Each name has its own markers, so a pass of `uv run pytest -k unit` never skips `uv run pytest`.
39
+
40
+ To choose the name yourself, put `--name` before `--`:
32
41
 
33
42
  ```sh
34
43
  gate-cache --name pytest -- uv run pytest
35
- gate-cache --name ruff -- uv run ruff check .
36
44
  ```
37
45
 
38
- Each name has its own markers. A command run under one name never skips because of a marker for another name.
46
+ With `--name`, the name alone identifies the gate, and the arguments of the command are not part of it. Give a name when two different command lines do the same check and must share markers, or when a gate depends on [inputs that git does not track](#inputs-that-git-does-not-track).
39
47
 
40
48
  gate-cache exits with the exit code of the command, or with 0 when it skips the command. It writes a marker only when the command passes, so a command that failed runs again the next time.
41
49
 
@@ -52,18 +60,18 @@ repos:
52
60
  hooks:
53
61
  - id: gate-cache
54
62
  name: pytest
55
- args: [--name, pytest, --, uv, run, pytest]
63
+ args: [--, uv, run, pytest]
56
64
  files: ^(src/|tests/)
57
65
  - id: gate-cache
58
66
  name: ruff
59
- args: [--name, ruff, --, uv, run, ruff, check, .]
67
+ args: [--, uv, run, ruff, check, .]
60
68
  ```
61
69
 
62
70
  The hook runs at commit time and at push time. At commit time, gate-cache matches the content that you commit.
63
71
 
64
72
  The hook runs in its own virtual environment, and the wrapped command inherits that environment. A command started with `uv run`, or a project command such as `just test`, runs in the project environment. `uv run` prints a warning that `VIRTUAL_ENV` does not match the project. The warning has no effect on the run.
65
73
 
66
- Hooks, agents, and your terminal all read and write the same markers. When one of them runs `gate-cache --name pytest -- uv run pytest` and it passes, the others skip that name on the same content.
74
+ Hooks, agents, and your terminal all read and write the same markers. When one of them runs `gate-cache -- uv run pytest` and it passes, the others skip `uv run pytest` on the same content.
67
75
 
68
76
  ## Examples
69
77
 
@@ -73,24 +81,26 @@ These examples show gate-cache inside the task runner of a project. When a recip
73
81
 
74
82
  ```just
75
83
  test:
76
- gate-cache --name test -- uv run pytest
84
+ gate-cache -- uv run pytest
77
85
 
78
86
  lint:
79
- gate-cache --name lint -- uv run ruff check .
87
+ gate-cache -- uv run ruff check .
80
88
  ```
81
89
 
82
90
  After `just test` passes, the next `just test` on the same content skips pytest.
83
91
 
84
92
  ### Recipes with arguments
85
93
 
86
- gate-cache identifies a command by its name alone, not by its arguments. If `just test -k unit` and `just test` ran under the same name, a pass of the unit tests would also skip the full suite. To keep them apart, put the arguments in the name:
94
+ A recipe that passes its arguments to the command needs no `--name`. Each set of arguments gives a different command line, so each set has its own markers:
87
95
 
88
96
  ```just
89
97
  test *args:
90
- gate-cache --name "{{ trim('test ' + args) }}" -- uv run pytest {{ args }}
98
+ gate-cache -- uv run pytest {{ args }}
91
99
  ```
92
100
 
93
- `just test` runs under the name `test`, and `just test -k unit` runs under the name `test -k unit`. Each set of arguments has its own markers. `trim` removes the trailing space when there are no arguments, so `gate-cache status --name test` finds the bare form.
101
+ `just test` runs under the name `uv run pytest`, and `just test -k unit` runs under the name `uv run pytest -k unit`. A pass of the unit tests never skips the full suite.
102
+
103
+ If the recipe gives `--name`, the arguments are not part of the name. Put them in the name, or a pass of `just test -k unit` also skips `just test`.
94
104
 
95
105
  ### Inputs that git does not track
96
106
 
@@ -106,7 +116,7 @@ integration:
106
116
  gate-cache --name "integration-$(git hash-object .env)" -- uv run pytest tests/integration
107
117
  ```
108
118
 
109
- `git hash-object` prints a hash of the file, so the name does not hold the secrets in `.env`. Do not put a secret value in a name, because the marker and `gate-cache status` record the name as plain text.
119
+ `git hash-object` prints a hash of the file, so the name does not hold the secrets in `.env`. Do not put a secret value in a name, because the marker and `gate-cache status` record the name as plain text. Without `--name`, the command line is the name, so give `--name` to a command that takes a secret as an argument.
110
120
 
111
121
  The shell of the recipe expands `$(...)` before gate-cache starts. A hook `entry` or `args` list does not go through a shell, so put the command in a recipe and call the recipe from the hook. To look up the gate with `gate-cache status --name`, give the expanded name, such as `test-uv0.13.0`.
112
122
 
@@ -114,10 +124,10 @@ The shell of the recipe expands `$(...)` before gate-cache starts. A hook `entry
114
124
 
115
125
  ```make
116
126
  test:
117
- gate-cache --name test -- uv run pytest
127
+ gate-cache -- uv run pytest
118
128
 
119
129
  lint:
120
- gate-cache --name lint -- uv run ruff check .
130
+ gate-cache -- uv run ruff check .
121
131
  ```
122
132
 
123
133
  ### A hook that calls the recipe
@@ -163,17 +173,17 @@ GATE_CACHE_DISABLE=1 just test
163
173
  ```console
164
174
  $ gate-cache status
165
175
  tree: aaff74984cccd156a469afa7d9ab10e4777beb24
166
- passed: pytest
167
- missed: ruff (exit code 1)
176
+ passed: uv run pytest
177
+ missed: uv run ruff check . (exit code 1)
168
178
  ```
169
179
 
170
180
  A name is missed when its command failed, or when it exited 0 but changed the files. A later pass of the same name removes it from the `missed` line.
171
181
 
172
- With `--name`, it reports on one name:
182
+ With `--name`, it reports on one name. For a gate that ran without `--name`, give the command line as the name:
173
183
 
174
184
  ```console
175
- $ gate-cache status --name ruff
176
- name: ruff
185
+ $ gate-cache status --name "uv run ruff check ."
186
+ name: uv run ruff check .
177
187
  tree: aaff74984cccd156a469afa7d9ab10e4777beb24
178
188
  marker: /path/to/repo/.git/gate-cache/7d94ca2f57ec0a77f60d328fb405d339fb0c2a645991b97c042ac54403104e9e
179
189
  result: miss
@@ -192,9 +202,9 @@ $ gate-cache status --json
192
202
  "enabled": true,
193
203
  "tree": "aaff74984cccd156a469afa7d9ab10e4777beb24",
194
204
  "reason": null,
195
- "passed": ["pytest"],
205
+ "passed": ["uv run pytest"],
196
206
  "missed": [
197
- {"name": "ruff", "exit_code": 1, "reason": "exit code 1", "timestamp": "2026-10-12T12:31:55Z"}
207
+ {"name": "uv run ruff check .", "exit_code": 1, "reason": "exit code 1", "timestamp": "2026-10-12T12:31:55Z"}
198
208
  ]
199
209
  }
200
210
  ```
@@ -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.4.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.4.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.4.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,169 +0,0 @@
1
- """Command line interface for gate-cache."""
2
-
3
- from __future__ import annotations
4
-
5
- import sys
6
- from typing import Annotated
7
-
8
- from cyclopts import App, Parameter
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
- _USAGE_ERROR = 2
15
- _INTERRUPTED = 130
16
- _SUBCOMMANDS = {"status", "clear"}
17
- _INFO_FLAGS = {"-h", "--help", "--version"}
18
-
19
- _HELP = """\
20
- Skip a lint or test command that already passed on the same git tree. gate-cache
21
- runs the command, records a pass keyed on the git tree of the working copy
22
- (tracked and untracked, non-ignored files), and skips the command next time the
23
- tree is identical. A pass on uncommitted work therefore skips when the same
24
- content is committed.
25
-
26
- Both --name and -- are required, and only --name may come before --. Everything
27
- after -- is the command, verbatim.
28
-
29
- Exit codes:
30
- run the command's own exit code, or 0 when a recorded pass lets it skip.
31
- Usage errors exit 2.
32
- status 0 when the named gate, or any gate without --name, passed on the
33
- current tree. 1 otherwise. --json prints one JSON object instead.
34
- clear 0 on success, 1 on failure.
35
-
36
- GATE_CACHE_DISABLE and CI are on when the value is not empty, 0, or false (any
37
- case). If GATE_CACHE_DISABLE is on, the command always runs without the cache.
38
- If it is 0 or false, caching stays on. If it is empty or not set, CI decides the
39
- same way.
40
- Set GATE_CACHE_MAX_AGE_DAYS to a positive whole number to change how many days a
41
- recorded pass lasts (default 30).
42
-
43
- Only the name and the file content are keyed. To also key on recipe arguments,
44
- an ignored file, an environment variable, or a tool version, put its value in
45
- --name. A changed value gives a new name, so the command runs again. Never put a
46
- secret in a name, because markers and status store it as plain text. Never wrap
47
- checks that depend on the commit message or the branch name.
48
-
49
- Typical use:
50
- gate-cache --name pytest -- uv run pytest -x
51
- gate-cache --name "pytest -k unit" -- uv run pytest -k unit
52
- gate-cache --name "test-$(git hash-object .env)" -- uv run pytest
53
- """
54
-
55
- app = App(
56
- name="gate-cache",
57
- version=__version__,
58
- help=_HELP,
59
- help_format="plaintext",
60
- usage=(
61
- "Usage: gate-cache --name NAME -- COMMAND [ARGS...]\n"
62
- " gate-cache status [--name NAME] [--json]\n"
63
- " gate-cache clear"
64
- ),
65
- result_action="return_int_as_exit_code_else_zero",
66
- )
67
-
68
-
69
- @app.default
70
- def run(
71
- command: Annotated[
72
- list[str], Parameter(allow_leading_hyphen=True, negative_iterable="", show_default=False)
73
- ],
74
- /,
75
- *,
76
- name: str,
77
- ) -> int:
78
- """Run COMMAND unless it already passed on the current git tree.
79
-
80
- On a skip, prints one line to stderr and exits 0 without running anything. Otherwise
81
- runs the command and exits with its exit code. The command runs without caching
82
- (and nothing is recorded) when GATE_CACHE_DISABLE or CI turns caching off, the tree
83
- is unknown (for example an unmerged index), or this is not a git repository. A pass is recorded only
84
- when the command exits 0 and the run leaves the tree unchanged.
85
-
86
- Parameters
87
- ----------
88
- command
89
- The command and its arguments, given after `--`. Example: -- uv run pytest -x
90
- name
91
- Gate name. Records match only when the name and the tree both match.
92
- Include arguments or other inputs that the tree does not cover.
93
- """
94
- if not command:
95
- say("no command given after --")
96
- return _USAGE_ERROR
97
- return run_gate(name, command)
98
-
99
-
100
- @app.command
101
- def status(
102
- *,
103
- name: str | None = None,
104
- as_json: Annotated[bool, Parameter(name="--json", negative="")] = False,
105
- ) -> int:
106
- """Report whether gates would skip, without running anything.
107
-
108
- With --name, print the tree hash (or why it is unknown), the marker path,
109
- hit or miss, and how the last run on this tree failed when it did. Exit 0 on a
110
- hit, 1 otherwise.
111
-
112
- Without --name, list the gates that passed on the current tree and the gates
113
- that ran on it without passing. Exit 0 when at least one gate passed, 1 otherwise.
114
-
115
- Parameters
116
- ----------
117
- name
118
- Gate name to look up. Omit it to list every gate that ran on this tree.
119
- as_json
120
- Print one JSON object to stdout instead of text. Exit codes are unchanged.
121
- """
122
- return gate_status(name, as_json=as_json)
123
-
124
-
125
- @app.command
126
- def clear() -> int:
127
- """Delete every recorded pass for this repository.
128
-
129
- Use after something outside git changes, such as a tool upgrade. Exits 1 outside a
130
- git repository.
131
- """
132
- return clear_cache()
133
-
134
-
135
- def _usage_error(tokens: list[str]) -> str | None:
136
- """Return the usage problem with a run invocation, or None when it is well formed.
137
-
138
- Only `--name X` or `--name=X` may precede `--`, so a stray option such as `--version`
139
- or the first word of the command can never be mistaken for one of ours.
140
- """
141
- if not tokens or tokens[0] in _SUBCOMMANDS | _INFO_FLAGS:
142
- return None
143
- index = 0
144
- while index < len(tokens):
145
- arg = tokens[index]
146
- if arg == "--":
147
- return None
148
- if arg == "--name":
149
- if index + 1 == len(tokens) or tokens[index + 1] == "--":
150
- return "--name needs a value, e.g. gate-cache --name pytest -- uv run pytest"
151
- index += 2
152
- elif arg.startswith("--name="):
153
- index += 1
154
- else:
155
- break
156
- return "put -- before the command, e.g. gate-cache --name pytest -- uv run pytest"
157
-
158
-
159
- def main() -> None:
160
- """Run the gate-cache command line interface and exit with its code."""
161
- tokens = sys.argv[1:]
162
- if problem := _usage_error(tokens):
163
- say(problem)
164
- sys.exit(_USAGE_ERROR)
165
- try:
166
- code = app(tokens)
167
- except KeyboardInterrupt:
168
- sys.exit(_INTERRUPTED)
169
- 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