cixstack 1.0.0rc1__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.
- cixstack/__main__.py +273 -0
- cixstack/args.py +106 -0
- cixstack/cli/alias.py +40 -0
- cixstack/cli/cache.py +54 -0
- cixstack/cli/cleanup.py +136 -0
- cixstack/cli/config.py +132 -0
- cixstack/cli/fix.py +130 -0
- cixstack/cli/hook.py +142 -0
- cixstack/cli/init.py +115 -0
- cixstack/cli/match_.py +139 -0
- cixstack/cli/run.py +370 -0
- cixstack/cli/start.py +65 -0
- cixstack/cli/status.py +152 -0
- cixstack/cli/watch.py +83 -0
- cixstack/context.py +111 -0
- cixstack/core.py +670 -0
- cixstack/errors.py +70 -0
- cixstack/models/compat.py +166 -0
- cixstack/models/v1.py +432 -0
- cixstack/models/v2.py +170 -0
- cixstack/utils.py +350 -0
- cixstack-1.0.0rc1.dist-info/METADATA +25 -0
- cixstack-1.0.0rc1.dist-info/RECORD +25 -0
- cixstack-1.0.0rc1.dist-info/WHEEL +4 -0
- cixstack-1.0.0rc1.dist-info/entry_points.txt +2 -0
cixstack/__main__.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import textwrap
|
|
11
|
+
import traceback
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Protocol
|
|
14
|
+
|
|
15
|
+
import argcomplete
|
|
16
|
+
import attrs
|
|
17
|
+
import diskcache
|
|
18
|
+
import dotenv
|
|
19
|
+
|
|
20
|
+
import cixstack.cli.alias
|
|
21
|
+
import cixstack.cli.cache
|
|
22
|
+
import cixstack.cli.cleanup
|
|
23
|
+
import cixstack.cli.config
|
|
24
|
+
import cixstack.cli.fix
|
|
25
|
+
import cixstack.cli.hook
|
|
26
|
+
import cixstack.cli.init
|
|
27
|
+
import cixstack.cli.match_
|
|
28
|
+
import cixstack.cli.run
|
|
29
|
+
import cixstack.cli.start
|
|
30
|
+
import cixstack.cli.status
|
|
31
|
+
import cixstack.cli.watch
|
|
32
|
+
import cixstack.errors
|
|
33
|
+
import cixstack.utils
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from collections.abc import Callable, MutableMapping, Sequence
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
ENV_FILE_PATH = Path.cwd() / ".env"
|
|
41
|
+
DEFAULT_COMMANDS = {
|
|
42
|
+
"run": "Run jobs",
|
|
43
|
+
"fix": "Run fixes for jobs with a `fix:` defined",
|
|
44
|
+
"cleanup": "Run cleanups for jobs with a `cleanup:` defined",
|
|
45
|
+
"status": "Display the status of all jobs",
|
|
46
|
+
"watch": "Continuously rerun status",
|
|
47
|
+
"start": "Start a service",
|
|
48
|
+
"match": "Show files matched by jobs",
|
|
49
|
+
"init": "Create a default configuration file",
|
|
50
|
+
"config": "Show configuration options",
|
|
51
|
+
"cache": "Manage the cache",
|
|
52
|
+
"hook": "Create git hooks",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
Context = (
|
|
57
|
+
cixstack.cli.run.Context
|
|
58
|
+
| cixstack.cli.match_.Context
|
|
59
|
+
| cixstack.cli.init.Context
|
|
60
|
+
| cixstack.cli.config.Context
|
|
61
|
+
| cixstack.cli.cache.Context
|
|
62
|
+
| cixstack.cli.hook.Context
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Module(Protocol):
|
|
67
|
+
@staticmethod
|
|
68
|
+
def parse_args(cache: MutableMapping[bytes, Any], args: Sequence[str]) -> Context: ...
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def main(cache: MutableMapping[bytes, Any], ctx: Context) -> int: ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Runnable(Protocol):
|
|
75
|
+
def run(self, cache: MutableMapping[bytes, Any]) -> int: ...
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Noop:
|
|
79
|
+
def run(self, cache: MutableMapping[bytes, Any]) -> int:
|
|
80
|
+
del cache # unused
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@attrs.define
|
|
85
|
+
class CommandProtocol:
|
|
86
|
+
ctx: Context
|
|
87
|
+
main: Callable[[MutableMapping[bytes, Any], Context], int]
|
|
88
|
+
|
|
89
|
+
def run(self, cache: MutableMapping[bytes, Any]) -> int:
|
|
90
|
+
return self.main(cache, self.ctx)
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_module(
|
|
94
|
+
cls,
|
|
95
|
+
cache: MutableMapping[bytes, Any],
|
|
96
|
+
module: Module,
|
|
97
|
+
args: Sequence[str],
|
|
98
|
+
) -> CommandProtocol:
|
|
99
|
+
return cls(ctx=module.parse_args(cache, args), main=module.main)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@attrs.define
|
|
103
|
+
class SubprocessProtocol:
|
|
104
|
+
argv: tuple[str, ...] = attrs.field(
|
|
105
|
+
converter=tuple,
|
|
106
|
+
validator=attrs.validators.deep_iterable(
|
|
107
|
+
member_validator=attrs.validators.instance_of(str)
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
check: bool
|
|
111
|
+
shell: bool
|
|
112
|
+
|
|
113
|
+
def run(self, cache: MutableMapping[bytes, Any]) -> int:
|
|
114
|
+
del cache # unused
|
|
115
|
+
result = subprocess.run(self.argv, check=self.check, shell=self.shell)
|
|
116
|
+
return result.returncode
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _parse_outer_args(argv: Sequence[str]) -> argparse.Namespace:
|
|
120
|
+
parser = argparse.ArgumentParser()
|
|
121
|
+
|
|
122
|
+
parser.add_argument(
|
|
123
|
+
"command",
|
|
124
|
+
type=str,
|
|
125
|
+
help="Command to run",
|
|
126
|
+
).completer = argcomplete.completers.ChoicesCompleter(DEFAULT_COMMANDS)
|
|
127
|
+
|
|
128
|
+
argcomplete.autocomplete(parser)
|
|
129
|
+
|
|
130
|
+
return parser.parse_args(argv[:1])
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_args(cache: MutableMapping[bytes, Any], argv: Sequence[str]) -> Runnable:
|
|
134
|
+
if "_ARGCOMPLETE" in os.environ:
|
|
135
|
+
complete_start_point = int(os.environ["_ARGCOMPLETE"])
|
|
136
|
+
complete_line = shlex.split(os.environ["COMP_LINE"])
|
|
137
|
+
|
|
138
|
+
if len(complete_line) <= complete_start_point:
|
|
139
|
+
_parse_outer_args(argv) # Trigger argcomplete for the outer parser
|
|
140
|
+
return Noop()
|
|
141
|
+
|
|
142
|
+
os.environ["COMP_LINE"] = shlex.join(complete_line[complete_start_point:])
|
|
143
|
+
argv = complete_line[complete_start_point:]
|
|
144
|
+
|
|
145
|
+
match argv:
|
|
146
|
+
case ["run", *args]:
|
|
147
|
+
return CommandProtocol.from_module(cache, cixstack.cli.run.Run, args)
|
|
148
|
+
case ["fix", *args]:
|
|
149
|
+
return CommandProtocol.from_module(cache, cixstack.cli.fix.Fix, args)
|
|
150
|
+
case ["cleanup", *args]:
|
|
151
|
+
return CommandProtocol.from_module(cache, cixstack.cli.cleanup.Cleanup, args)
|
|
152
|
+
case ["status", *args]:
|
|
153
|
+
return CommandProtocol.from_module(cache, cixstack.cli.status.Status, args)
|
|
154
|
+
case ["watch", *args]:
|
|
155
|
+
return CommandProtocol.from_module(cache, cixstack.cli.watch.Watch, args)
|
|
156
|
+
case ["start", *args]:
|
|
157
|
+
return CommandProtocol.from_module(cache, cixstack.cli.start.Start, args)
|
|
158
|
+
case ["match", *args]:
|
|
159
|
+
return CommandProtocol.from_module(cache, cixstack.cli.match_, args)
|
|
160
|
+
case ["init", *args]:
|
|
161
|
+
return CommandProtocol.from_module(cache, cixstack.cli.init, args)
|
|
162
|
+
case ["config", *args]:
|
|
163
|
+
return CommandProtocol.from_module(cache, cixstack.cli.config, args)
|
|
164
|
+
case ["cache", *args]:
|
|
165
|
+
return CommandProtocol.from_module(cache, cixstack.cli.cache, args)
|
|
166
|
+
case ["hook", *args]:
|
|
167
|
+
return CommandProtocol.from_module(cache, cixstack.cli.hook, args)
|
|
168
|
+
case [alias, *args] if alias.startswith("!"):
|
|
169
|
+
return SubprocessProtocol(argv=(alias[1:], *args), check=False, shell=False)
|
|
170
|
+
case []:
|
|
171
|
+
raise cixstack.errors.MissingCommandError()
|
|
172
|
+
case [alias, *args]:
|
|
173
|
+
ctx = cixstack.cli.alias.parse_args(cache, args)
|
|
174
|
+
|
|
175
|
+
if alias not in ctx.alias:
|
|
176
|
+
raise cixstack.errors.InvalidCommandError(alias)
|
|
177
|
+
|
|
178
|
+
cmd = ctx.alias[alias]
|
|
179
|
+
if cmd.startswith("!"):
|
|
180
|
+
return SubprocessProtocol(argv=(cmd[1:], *args), check=False, shell=True) # noqa: S604
|
|
181
|
+
|
|
182
|
+
return parse_args(cache, [*shlex.split(cmd), *args])
|
|
183
|
+
|
|
184
|
+
# If argv is a sequence (as it should always be), this is unreachable
|
|
185
|
+
raise ValueError(f"Invalid arguments: {argv!r}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _pretty_format_commands(commands: dict[str, str], *, quote: bool = False) -> str:
|
|
189
|
+
key_indent_level = min(
|
|
190
|
+
15,
|
|
191
|
+
max((len(key) for key in commands), default=10) + 2,
|
|
192
|
+
)
|
|
193
|
+
lines = []
|
|
194
|
+
for key, cmd in commands.items():
|
|
195
|
+
if quote:
|
|
196
|
+
cmd = shlex.quote(cmd)
|
|
197
|
+
is_quoted = {cmd[0], cmd[-1]} <= {"'", '"'}
|
|
198
|
+
else:
|
|
199
|
+
is_quoted = False
|
|
200
|
+
|
|
201
|
+
lines.append(f"{key + ':':<{key_indent_level + (0 if is_quoted else 1)}} {cmd}")
|
|
202
|
+
|
|
203
|
+
return "\n".join(lines)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def main(argv: Sequence[str] = ()) -> int:
|
|
207
|
+
try:
|
|
208
|
+
is_debug_trace = bool(json.loads(os.getenv("CIX_DEBUG_TRACE", "false").lower()))
|
|
209
|
+
except json.JSONDecodeError:
|
|
210
|
+
sys.stderr.write(
|
|
211
|
+
f"Invalid CIX_DEBUG_TRACE value will be ignored: {os.getenv('CIX_DEBUG_TRACE')!r}\n"
|
|
212
|
+
)
|
|
213
|
+
is_debug_trace = False
|
|
214
|
+
|
|
215
|
+
# Always set common termination signals to raise KeyboardInterrupt so finally blocks execute
|
|
216
|
+
# allowing graceful shutdown with cleanup execution. This is independent of debug_trace.
|
|
217
|
+
signal.signal(signal.SIGINT, signal.default_int_handler)
|
|
218
|
+
signal.signal(signal.SIGTERM, signal.default_int_handler)
|
|
219
|
+
signal.signal(signal.SIGHUP, signal.default_int_handler)
|
|
220
|
+
|
|
221
|
+
argv = argv or sys.argv[1:]
|
|
222
|
+
|
|
223
|
+
cixstack.utils.init_cache(cixstack.utils.CACHE_DIR, exist_ok=True)
|
|
224
|
+
|
|
225
|
+
if ENV_FILE_PATH.is_file():
|
|
226
|
+
dotenv.load_dotenv(dotenv_path=ENV_FILE_PATH)
|
|
227
|
+
|
|
228
|
+
if cwd := cixstack.utils.WORKING_DIRECTORY:
|
|
229
|
+
os.chdir(cwd)
|
|
230
|
+
|
|
231
|
+
with diskcache.Cache(cixstack.utils.CACHE_DIR_DISKCACHE) as cache:
|
|
232
|
+
if is_debug_trace:
|
|
233
|
+
protocol = parse_args(cache, argv)
|
|
234
|
+
try:
|
|
235
|
+
return protocol.run(cache)
|
|
236
|
+
except KeyboardInterrupt:
|
|
237
|
+
# Print traceback for debug_trace mode
|
|
238
|
+
print("".join(traceback.format_exc()), file=sys.stderr)
|
|
239
|
+
return 2
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
protocol = parse_args(cache, argv)
|
|
243
|
+
return protocol.run(cache)
|
|
244
|
+
except KeyboardInterrupt:
|
|
245
|
+
# Graceful shutdown without traceback in normal mode
|
|
246
|
+
return 2
|
|
247
|
+
except cixstack.errors.MissingCommandError:
|
|
248
|
+
print("No command provided. Available commands:", file=sys.stderr)
|
|
249
|
+
commands = _pretty_format_commands(DEFAULT_COMMANDS, quote=False)
|
|
250
|
+
print(textwrap.indent(commands, " "), file=sys.stderr)
|
|
251
|
+
return 1
|
|
252
|
+
except cixstack.errors.InvalidCommandError:
|
|
253
|
+
print("Invalid command provided. Available commands:", file=sys.stderr)
|
|
254
|
+
commands = _pretty_format_commands(DEFAULT_COMMANDS, quote=False)
|
|
255
|
+
print(textwrap.indent(commands, " "), file=sys.stderr)
|
|
256
|
+
return 1
|
|
257
|
+
except (cixstack.errors.ConfigError, cixstack.errors.ContextError) as exc:
|
|
258
|
+
for arg in exc.args:
|
|
259
|
+
print(arg, file=sys.stderr)
|
|
260
|
+
print("\nTo create a default config file, run `ci init`.", file=sys.stderr)
|
|
261
|
+
return 1
|
|
262
|
+
except cixstack.errors.ConfigFileNotFoundError as exc:
|
|
263
|
+
print("Config file not found:", *exc.args, file=sys.stderr)
|
|
264
|
+
return 1
|
|
265
|
+
finally:
|
|
266
|
+
# Run is the only command that modifies the cache, so we prune after it runs,
|
|
267
|
+
# and after it has printed output, so the user gets output as quickly as possible.
|
|
268
|
+
if len(argv) >= 2 and argv[1] == "run":
|
|
269
|
+
cixstack.utils.prune_cache_csv(keep_csv_lines=5000)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__": # pragma: no cover
|
|
273
|
+
sys.exit(main(sys.argv[1:]))
|
cixstack/args.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import cixstack.models.v1
|
|
6
|
+
import cixstack.utils
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def non_negative_int(value: str) -> int:
|
|
10
|
+
"""Convert string to non-negative integer for argparse validation."""
|
|
11
|
+
int_value = int(value)
|
|
12
|
+
if int_value < 0:
|
|
13
|
+
raise argparse.ArgumentTypeError(f"must be >= 0 (got {int_value})")
|
|
14
|
+
return int_value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Argument(Enum):
|
|
18
|
+
STAGES_OR_JOBS = "stages_or_jobs"
|
|
19
|
+
SERVICE = "service"
|
|
20
|
+
CONFIG_PATH = "--config-path"
|
|
21
|
+
REPO_PATH = "--repo-path"
|
|
22
|
+
QUIET = "--quiet"
|
|
23
|
+
MAX_JOBS = "--max-jobs"
|
|
24
|
+
NO_CACHE = "--no-cache"
|
|
25
|
+
FAIL_FAST = "--fail-fast"
|
|
26
|
+
RECURSIVE = "--recursive"
|
|
27
|
+
EXCLUDE = "--exclude"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def add_argument(parser: argparse.ArgumentParser, argument: Argument) -> argparse.Action:
|
|
31
|
+
match argument:
|
|
32
|
+
case Argument.STAGES_OR_JOBS:
|
|
33
|
+
return parser.add_argument(
|
|
34
|
+
"stages_or_jobs",
|
|
35
|
+
type=str,
|
|
36
|
+
help="Hook stage",
|
|
37
|
+
nargs="*",
|
|
38
|
+
default=(),
|
|
39
|
+
)
|
|
40
|
+
case Argument.SERVICE:
|
|
41
|
+
return parser.add_argument(
|
|
42
|
+
"service",
|
|
43
|
+
type=str,
|
|
44
|
+
help="Service to run",
|
|
45
|
+
)
|
|
46
|
+
case Argument.CONFIG_PATH:
|
|
47
|
+
return parser.add_argument(
|
|
48
|
+
"--config-path",
|
|
49
|
+
"-c",
|
|
50
|
+
type=Path,
|
|
51
|
+
default=cixstack.utils.DEFAULT_CONFIG_PATH,
|
|
52
|
+
help="Path to .ci.yaml file",
|
|
53
|
+
)
|
|
54
|
+
case Argument.REPO_PATH:
|
|
55
|
+
return parser.add_argument(
|
|
56
|
+
"--repo-path",
|
|
57
|
+
"-r",
|
|
58
|
+
type=lambda path: Path(path).resolve(),
|
|
59
|
+
default=cixstack.utils.WORKING_DIRECTORY,
|
|
60
|
+
help="Path to the repo",
|
|
61
|
+
)
|
|
62
|
+
case Argument.QUIET:
|
|
63
|
+
return parser.add_argument(
|
|
64
|
+
"--quiet",
|
|
65
|
+
"-q",
|
|
66
|
+
action="append_const",
|
|
67
|
+
help="If set, do not capture output and show full tracebacks",
|
|
68
|
+
)
|
|
69
|
+
case Argument.MAX_JOBS:
|
|
70
|
+
return parser.add_argument(
|
|
71
|
+
"-n",
|
|
72
|
+
"--max-jobs",
|
|
73
|
+
type=non_negative_int,
|
|
74
|
+
help="Maximum number of jobs to run in parallel. If 0, this is unlimited.",
|
|
75
|
+
default=0,
|
|
76
|
+
)
|
|
77
|
+
case Argument.NO_CACHE:
|
|
78
|
+
return parser.add_argument(
|
|
79
|
+
"--no-cache",
|
|
80
|
+
action="store_true",
|
|
81
|
+
help="If set, do not use cached results",
|
|
82
|
+
)
|
|
83
|
+
case Argument.FAIL_FAST:
|
|
84
|
+
return parser.add_argument(
|
|
85
|
+
"--fail-fast",
|
|
86
|
+
"--ff",
|
|
87
|
+
action="store_true",
|
|
88
|
+
help="If set, stop running jobs when one fails",
|
|
89
|
+
)
|
|
90
|
+
case Argument.RECURSIVE:
|
|
91
|
+
return parser.add_argument(
|
|
92
|
+
"--recursive",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="If set, rerun until all jobs cached",
|
|
95
|
+
)
|
|
96
|
+
case Argument.EXCLUDE:
|
|
97
|
+
return parser.add_argument(
|
|
98
|
+
"--exclude",
|
|
99
|
+
"-e",
|
|
100
|
+
type=cixstack.models.v1.JobState,
|
|
101
|
+
nargs="*",
|
|
102
|
+
default=(),
|
|
103
|
+
help="Exclude jobs with the given state from the output",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
raise ValueError(f"Unknown argument: {argument}")
|
cixstack/cli/alias.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from collections.abc import MutableMapping, Sequence
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import attrs
|
|
11
|
+
|
|
12
|
+
import cixstack.args
|
|
13
|
+
import cixstack.errors
|
|
14
|
+
import cixstack.models.compat
|
|
15
|
+
import cixstack.utils
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@attrs.frozen
|
|
19
|
+
class Context:
|
|
20
|
+
alias: Mapping[str, str] = attrs.field(
|
|
21
|
+
factory=dict,
|
|
22
|
+
validator=attrs.validators.instance_of(Mapping),
|
|
23
|
+
converter=dict,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_args(cache: MutableMapping[bytes, Any], argv: Sequence[str]) -> Context:
|
|
28
|
+
parser = argparse.ArgumentParser()
|
|
29
|
+
|
|
30
|
+
cixstack.args.add_argument(parser, cixstack.args.Argument.CONFIG_PATH)
|
|
31
|
+
|
|
32
|
+
args, _ = parser.parse_known_args(argv)
|
|
33
|
+
|
|
34
|
+
# Validate config path
|
|
35
|
+
config_path = Path(args.config_path).resolve()
|
|
36
|
+
if not config_path.is_file():
|
|
37
|
+
raise cixstack.errors.ConfigFileNotFoundError(config_path)
|
|
38
|
+
|
|
39
|
+
config = cixstack.models.compat.read_config(cache, *cixstack.utils.CONFIG_PATHS, config_path)
|
|
40
|
+
return Context(alias=config.alias)
|
cixstack/cli/cache.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
import argcomplete
|
|
5
|
+
import argcomplete.completers
|
|
6
|
+
import attrs
|
|
7
|
+
|
|
8
|
+
import cixstack.errors
|
|
9
|
+
import cixstack.utils
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import MutableMapping, Sequence
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
VALID_ACTIONS = ("clean", "dir", "prune")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@attrs.frozen
|
|
20
|
+
class Context:
|
|
21
|
+
action: str = attrs.field(validator=attrs.validators.in_(VALID_ACTIONS))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_args(cache: MutableMapping[bytes, Any], argv: Sequence[str]) -> Context:
|
|
25
|
+
del cache # unused
|
|
26
|
+
|
|
27
|
+
parser = argparse.ArgumentParser()
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"action", type=str, help="Action to perform"
|
|
30
|
+
).completer = argcomplete.completers.ChoicesCompleter(VALID_ACTIONS)
|
|
31
|
+
|
|
32
|
+
args = parser.parse_args(argv)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
ctx = Context(action=args.action)
|
|
36
|
+
except ValueError as exc:
|
|
37
|
+
raise cixstack.errors.ContextError(f"Invalid action: {args.action}") from exc
|
|
38
|
+
|
|
39
|
+
return ctx
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main(cache: MutableMapping[bytes, Any], ctx: Context) -> int:
|
|
43
|
+
del cache # unused
|
|
44
|
+
match ctx.action:
|
|
45
|
+
case "dir":
|
|
46
|
+
print(str(cixstack.utils.CACHE_DIR))
|
|
47
|
+
case "clean":
|
|
48
|
+
cixstack.utils.prune_cache(keep_csv_lines=0, keep_diskcache=False)
|
|
49
|
+
case "prune":
|
|
50
|
+
cixstack.utils.prune_cache(keep_csv_lines=1000, keep_diskcache=True)
|
|
51
|
+
case _:
|
|
52
|
+
raise cixstack.errors.ContextError(f"Invalid action: {ctx.action}")
|
|
53
|
+
|
|
54
|
+
return 0
|
cixstack/cli/cleanup.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import argcomplete
|
|
6
|
+
import argcomplete.completers
|
|
7
|
+
import attrs
|
|
8
|
+
|
|
9
|
+
import cixstack.args
|
|
10
|
+
import cixstack.errors
|
|
11
|
+
import cixstack.models.compat
|
|
12
|
+
import cixstack.models.v2
|
|
13
|
+
import cixstack.utils
|
|
14
|
+
from cixstack.cli.run import Context, Run
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import MutableMapping, Sequence
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_valid_jobs_and_stages(config: cixstack.models.v2.RepoConfig) -> tuple[set[str], set[str]]:
|
|
22
|
+
valid_jobs = {job_name for job_name, job_config in config.jobs.items() if job_config.cleanup}
|
|
23
|
+
for service_name, service_config in config.services.items():
|
|
24
|
+
if service_config.cleanup:
|
|
25
|
+
valid_jobs.add(service_name)
|
|
26
|
+
|
|
27
|
+
valid_stages = {
|
|
28
|
+
stage_name
|
|
29
|
+
for stage_name, jobs in config.groups.items()
|
|
30
|
+
if all(job in valid_jobs for job in jobs)
|
|
31
|
+
}
|
|
32
|
+
return valid_jobs, valid_stages
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Cleanup(Run):
|
|
36
|
+
@staticmethod
|
|
37
|
+
def evolve_context(ctx: Context) -> Context:
|
|
38
|
+
valid_jobs, valid_stages = get_valid_jobs_and_stages(ctx.config)
|
|
39
|
+
for job in ctx.jobs:
|
|
40
|
+
if job not in valid_jobs:
|
|
41
|
+
raise cixstack.errors.ContextError(f"Job has no cleanup command: {job!r}")
|
|
42
|
+
|
|
43
|
+
config = attrs.evolve(
|
|
44
|
+
ctx.config,
|
|
45
|
+
jobs={
|
|
46
|
+
**{
|
|
47
|
+
job_name: job_config.as_cleanup()
|
|
48
|
+
for job_name, job_config in ctx.config.jobs.items()
|
|
49
|
+
if job_config.cleanup
|
|
50
|
+
},
|
|
51
|
+
**{
|
|
52
|
+
service_name: service_config.as_cleanup()
|
|
53
|
+
for service_name, service_config in ctx.config.services.items()
|
|
54
|
+
if service_config.cleanup
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
groups={
|
|
58
|
+
stage_name: [f"{job}@cleanup" for job in jobs]
|
|
59
|
+
for stage_name, jobs in ctx.config.groups.items()
|
|
60
|
+
if stage_name in valid_stages
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
config = attrs.evolve(
|
|
64
|
+
config, jobs={name: job.resolve_env() for name, job in config.jobs.items()}
|
|
65
|
+
)
|
|
66
|
+
ctx = attrs.evolve(ctx, config=config)
|
|
67
|
+
if not ctx.jobs:
|
|
68
|
+
ctx = attrs.evolve(ctx, stages_or_jobs=ctx.config.jobs.keys())
|
|
69
|
+
|
|
70
|
+
file_hashes = cixstack.utils.hash_repo_worktree(ctx.repo_path)
|
|
71
|
+
|
|
72
|
+
return attrs.evolve(ctx, config=config, file_hashes=file_hashes)
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def parse_args(cache: MutableMapping[bytes, Any], argv: Sequence[str]) -> Context:
|
|
76
|
+
parser = argparse.ArgumentParser()
|
|
77
|
+
|
|
78
|
+
config = cixstack.models.compat.read_config(cache, *cixstack.utils.CONFIG_PATHS)
|
|
79
|
+
|
|
80
|
+
valid_jobs, valid_stages = get_valid_jobs_and_stages(config)
|
|
81
|
+
|
|
82
|
+
cixstack.args.add_argument(
|
|
83
|
+
parser, cixstack.args.Argument.STAGES_OR_JOBS
|
|
84
|
+
).completer = argcomplete.completers.ChoicesCompleter(sorted(valid_stages | valid_jobs))
|
|
85
|
+
|
|
86
|
+
cixstack.args.add_argument(parser, cixstack.args.Argument.CONFIG_PATH)
|
|
87
|
+
cixstack.args.add_argument(parser, cixstack.args.Argument.REPO_PATH)
|
|
88
|
+
cixstack.args.add_argument(parser, cixstack.args.Argument.QUIET)
|
|
89
|
+
cixstack.args.add_argument(parser, cixstack.args.Argument.MAX_JOBS)
|
|
90
|
+
|
|
91
|
+
argcomplete.autocomplete(parser)
|
|
92
|
+
|
|
93
|
+
args = parser.parse_args(argv)
|
|
94
|
+
|
|
95
|
+
config_path = Path(args.config_path).resolve()
|
|
96
|
+
repo_path = Path(args.repo_path).resolve()
|
|
97
|
+
|
|
98
|
+
config = config.update(cixstack.models.compat.read_config(cache, config_path))
|
|
99
|
+
|
|
100
|
+
config = attrs.evolve(
|
|
101
|
+
config,
|
|
102
|
+
jobs={
|
|
103
|
+
**config.jobs,
|
|
104
|
+
**{
|
|
105
|
+
# As this gets re-evolved later in evolve_context, we have to
|
|
106
|
+
# add the cleanup in here so it doesn't get lost.
|
|
107
|
+
# This is very much a workaround.
|
|
108
|
+
name: attrs.evolve(svc.as_cleanup(), cleanup=svc.cleanup)
|
|
109
|
+
for name, svc in config.services.items()
|
|
110
|
+
if svc.cleanup
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
# Clear services to avoid name conflict with jobs
|
|
114
|
+
# This is fine as we can't run services from here anyways
|
|
115
|
+
services={},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if not repo_path.is_dir():
|
|
119
|
+
raise cixstack.errors.ContextError(f"Repo path is not a directory: {repo_path}")
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
ctx = Context(
|
|
123
|
+
repo_path=repo_path,
|
|
124
|
+
config=config,
|
|
125
|
+
stages_or_jobs=args.stages_or_jobs,
|
|
126
|
+
quiet=len(args.quiet or ()),
|
|
127
|
+
max_jobs=args.max_jobs,
|
|
128
|
+
recursive_max_iter=0,
|
|
129
|
+
no_cache=True,
|
|
130
|
+
fail_fast=False,
|
|
131
|
+
file_hashes={},
|
|
132
|
+
)
|
|
133
|
+
except ValueError as exc:
|
|
134
|
+
raise cixstack.errors.ContextError(*exc.args) from exc
|
|
135
|
+
|
|
136
|
+
return ctx
|