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 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
@@ -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