cici-tools 0.2.0__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.
cici/__init__.py ADDED
File without changes
cici/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
cici/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
cici/cli/__init__.py ADDED
File without changes
cici/cli/build.py ADDED
@@ -0,0 +1,55 @@
1
+ import logging
2
+ import sys
3
+ from importlib import import_module
4
+ from pathlib import Path
5
+
6
+ from ..constants import DEFAULT_PROVIDER, PROVIDERS
7
+
8
+
9
+ def build_command(parser, args):
10
+ logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s")
11
+
12
+ input_provider = import_module(f".{args.input_format}", "cici.providers")
13
+ output_provider = import_module(f".{args.output_format}", "cici.providers")
14
+
15
+ if not args.filename:
16
+ args.filename = input_provider.CI_FILE
17
+
18
+ if not Path(args.filename).exists():
19
+ parser.error(f"file not found: {args.filename}")
20
+
21
+ file = input_provider.load(args.filename)
22
+ output_provider.dump(file, sys.stdout)
23
+
24
+
25
+ def build_parser(subparsers):
26
+ parser = subparsers.add_parser(
27
+ "build", help="build BrettOps CI file into target format"
28
+ )
29
+ parser.add_argument("filename", nargs="?")
30
+ parser.add_argument(
31
+ "-f",
32
+ "--from",
33
+ dest="input_format",
34
+ choices=PROVIDERS,
35
+ default=DEFAULT_PROVIDER,
36
+ help=f"input format [{DEFAULT_PROVIDER}]",
37
+ )
38
+ parser.add_argument(
39
+ "-t",
40
+ "--to",
41
+ dest="output_format",
42
+ choices=PROVIDERS,
43
+ default=DEFAULT_PROVIDER,
44
+ help=f"output format [{DEFAULT_PROVIDER}]",
45
+ )
46
+ parser.add_argument(
47
+ "-o",
48
+ "--output",
49
+ metavar="DIR",
50
+ dest="output_path",
51
+ type=Path,
52
+ default=Path.cwd().absolute(),
53
+ )
54
+ parser.set_defaults(func=build_command)
55
+ return parser
cici/cli/bundle.py ADDED
@@ -0,0 +1,104 @@
1
+ import copy
2
+ import logging
3
+ from importlib import import_module
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from termcolor import colored
8
+
9
+ from ..constants import CONFIG_DIR_NAME, DEFAULT_PROVIDER
10
+
11
+
12
+ def get_job_prefix(pipeline_name: str, job_name: str) -> Optional[str]:
13
+ if job_name.startswith("."):
14
+ return None
15
+ return f"{pipeline_name}-"
16
+
17
+
18
+ def parse_groups(name, file, groups):
19
+ if groups:
20
+ groups = [group.strip() for group in groups.split(",")]
21
+
22
+ if not groups:
23
+ group_names = set()
24
+ for job_name in file.jobs.keys():
25
+ if job_name.startswith("."):
26
+ continue
27
+ group_name = job_name[len(f"{name}-") :]
28
+ group_name = group_name.split("-")[0]
29
+ group_names.add(group_name)
30
+ groups = sorted(list(group_names))
31
+ return groups
32
+
33
+
34
+ def bundle_command(parser, args):
35
+ logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s")
36
+ print(colored("pipeline name:", color="yellow"), args.pipeline_name)
37
+
38
+ provider = import_module(f".{DEFAULT_PROVIDER}", "cici.providers")
39
+
40
+ ci_file_path = args.config_path / provider.CI_FILE
41
+
42
+ if not Path(ci_file_path).exists():
43
+ parser.error(f"file not found: {ci_file_path}")
44
+
45
+ file = provider.load(ci_file_path)
46
+
47
+ args.output_path = Path(args.output_path)
48
+ args.groups = parse_groups(args.pipeline_name, file, args.groups)
49
+ print(colored("bundle names:", color="yellow"), args.groups)
50
+
51
+ for bundle_name in args.groups:
52
+ pattern = f"{args.pipeline_name}-{bundle_name}"
53
+
54
+ bundle = copy.deepcopy(file)
55
+ bundle.jobs = {}
56
+
57
+ for job_name, job in file.jobs.items():
58
+ job_prefix = get_job_prefix(
59
+ pipeline_name=args.pipeline_name,
60
+ job_name=job_name,
61
+ )
62
+ if not job_prefix:
63
+ continue
64
+ assert job_name.startswith(job_prefix)
65
+ if job_name.startswith(pattern):
66
+ bundle.jobs[job_name] = job
67
+
68
+ bundle_filename = args.output_path / f"{bundle_name}.yml"
69
+
70
+ with open(bundle_filename, "w") as stream:
71
+ provider.dump(bundle, stream)
72
+ print(colored("created", "magenta"), bundle_filename.name)
73
+
74
+
75
+ def bundle_parser(subparsers):
76
+ parser = subparsers.add_parser(
77
+ "bundle", help="distill a CI file to a job and its dependencies"
78
+ )
79
+ parser.add_argument(
80
+ "config_path",
81
+ metavar="DIR",
82
+ nargs="?",
83
+ type=Path,
84
+ default=(Path.cwd() / CONFIG_DIR_NAME).absolute(),
85
+ )
86
+ parser.add_argument("-g", "--groups", help="job group patterns")
87
+ parser.add_argument(
88
+ "-o",
89
+ "--output",
90
+ metavar="DIR",
91
+ dest="output_path",
92
+ type=Path,
93
+ default=Path.cwd().absolute(),
94
+ )
95
+ parser.add_argument(
96
+ "-n",
97
+ "--name",
98
+ metavar="DIR",
99
+ dest="pipeline_name",
100
+ default=str(Path.cwd().name),
101
+ help="the name of the pipeline",
102
+ )
103
+ parser.set_defaults(func=bundle_command)
104
+ return parser
cici/cli/fmt.py ADDED
@@ -0,0 +1,30 @@
1
+ from importlib import import_module
2
+ from pathlib import Path
3
+
4
+ from termcolor import colored
5
+
6
+ from ..constants import DEFAULT_PROVIDER
7
+
8
+
9
+ def fmt_command(parser, args):
10
+ provider = import_module(f".{DEFAULT_PROVIDER}", "cici.providers")
11
+
12
+ if not args.filenames:
13
+ args.filenames = [provider.CI_FILE]
14
+
15
+ for filename in args.filenames:
16
+ if not Path(filename).exists():
17
+ parser.error(f"file not found: {filename}")
18
+
19
+ for filename in args.filenames:
20
+ file = provider.load(filename)
21
+ with open(filename, "w") as stream:
22
+ provider.dump(file, stream)
23
+ print(colored(filename, "magenta"), "formatted")
24
+
25
+
26
+ def fmt_parser(subparsers):
27
+ parser = subparsers.add_parser("fmt", help="Reformat CI file")
28
+ parser.add_argument("filenames", metavar="FILE", nargs="*")
29
+ parser.set_defaults(func=fmt_command)
30
+ return parser
cici/cli/update.py ADDED
@@ -0,0 +1,112 @@
1
+ import json
2
+ import os
3
+ import urllib.error
4
+ import urllib.parse
5
+ import urllib.request
6
+
7
+ import ruamel.yaml
8
+ from termcolor import colored
9
+
10
+ GITLAB_URL = "https://gitlab.com"
11
+ API_V4_URL = f"{GITLAB_URL}/api/v4"
12
+
13
+ HEADERS: dict[str, str] = {}
14
+ private_token = os.environ.get("GITLAB_PRIVATE_TOKEN")
15
+ if private_token:
16
+ HEADERS.update({"PRIVATE-TOKEN": private_token})
17
+
18
+
19
+ def to_fragment(text):
20
+ if not isinstance(text, str):
21
+ return text
22
+ return urllib.parse.quote(text, safe="")
23
+
24
+
25
+ def get_apiv4_url(url):
26
+ try:
27
+ url = f"{API_V4_URL}{url}"
28
+ request = urllib.request.Request(url=url, headers=HEADERS)
29
+ response = urllib.request.urlopen(request)
30
+ content = response.read()
31
+ return json.loads(content)
32
+ except urllib.error.HTTPError:
33
+ return None
34
+
35
+
36
+ def get_project(project_id):
37
+ return get_apiv4_url(f"/projects/{to_fragment(project_id)}")
38
+
39
+
40
+ def get_latest_release(project_id):
41
+ return get_apiv4_url(
42
+ f"/projects/{to_fragment(project_id)}/releases/permalink/latest"
43
+ )
44
+
45
+
46
+ def cproject(project_name):
47
+ return colored(project_name, "magenta")
48
+
49
+
50
+ def ctag(tag_name):
51
+ return colored(tag_name, "yellow", attrs=["bold"])
52
+
53
+
54
+ def update_include(include):
55
+ if any(key not in include for key in ("project", "file")):
56
+ return include
57
+
58
+ project = get_project(include["project"])
59
+ project_name = project["path_with_namespace"]
60
+
61
+ latest_release = get_latest_release(project["id"])
62
+ if latest_release:
63
+ latest_tag = latest_release["tag_name"]
64
+ current_tag = include.get("ref", None)
65
+ if current_tag:
66
+ if current_tag != latest_tag:
67
+ print(
68
+ colored(project_name, "red", attrs=["bold"]),
69
+ "updated from {current} to {latest}".format(
70
+ current=ctag(current_tag),
71
+ latest=ctag(latest_tag),
72
+ ),
73
+ )
74
+ else:
75
+ print(cproject(project_name), "is the latest at", ctag(current_tag))
76
+ elif not current_tag:
77
+ print(cproject(project_name), "pinned to", ctag(latest_tag))
78
+ include["ref"] = latest_tag
79
+ else:
80
+ print(cproject(project_name), "has no releases")
81
+ newinclude = {}
82
+ newinclude["project"] = include["project"]
83
+ if "ref" in include:
84
+ newinclude["ref"] = include["ref"]
85
+ newinclude["file"] = include["file"]
86
+ return newinclude
87
+
88
+
89
+ def update_includes(includes):
90
+ if not isinstance(includes, list):
91
+ return includes
92
+
93
+ return [update_include(include) for include in includes]
94
+
95
+
96
+ def update_command(parser, args):
97
+ yaml = ruamel.yaml.YAML()
98
+ data = yaml.load(open(args.filename))
99
+
100
+ if "include" in data:
101
+ data["include"] = update_includes(data["include"])
102
+
103
+ with open(args.filename, "w") as handle:
104
+ yaml.indent(mapping=2, sequence=4, offset=2)
105
+ yaml.dump(data, handle)
106
+
107
+
108
+ def update_parser(subparsers):
109
+ parser = subparsers.add_parser("update")
110
+ parser.add_argument("filename", nargs="?", default=".gitlab-ci.yml")
111
+ parser.set_defaults(func=update_command)
112
+ return parser
cici/constants.py ADDED
@@ -0,0 +1,26 @@
1
+ from pathlib import Path
2
+
3
+ BASE_DIR = Path(__file__).parent.absolute()
4
+
5
+ SCHEMA_DIR = BASE_DIR / "schema"
6
+
7
+ COMMAND_DIR = BASE_DIR / "cli"
8
+
9
+ COMMANDS = sorted(
10
+ [path.stem for path in COMMAND_DIR.glob("*.py") if not path.stem.startswith("_")]
11
+ )
12
+
13
+ PROVIDER_DIR = BASE_DIR / "providers"
14
+
15
+ PROVIDERS = sorted(
16
+ [path.stem for path in PROVIDER_DIR.glob("*.py") if not path.stem.startswith("_")]
17
+ + [
18
+ path.stem
19
+ for path in PROVIDER_DIR.glob("*/")
20
+ if path.is_dir() and not path.stem.startswith("_")
21
+ ]
22
+ )
23
+
24
+ DEFAULT_PROVIDER = PROVIDERS[0]
25
+
26
+ CONFIG_DIR_NAME = ".cici"
cici/main.py ADDED
@@ -0,0 +1,17 @@
1
+ import argparse
2
+ from importlib import import_module
3
+
4
+ from .constants import COMMANDS
5
+
6
+
7
+ def main():
8
+ parser = argparse.ArgumentParser()
9
+
10
+ subparsers = parser.add_subparsers(required=True)
11
+
12
+ for command_name in COMMANDS:
13
+ command = import_module(f".{command_name}", "cici.cli")
14
+ getattr(command, f"{command_name}_parser")(subparsers=subparsers)
15
+
16
+ args = parser.parse_args()
17
+ args.func(parser=parser, args=args)
File without changes
@@ -0,0 +1,3 @@
1
+ from .constants import CI_FILE
2
+ from .models import File
3
+ from .serializers import dump, dumps, load, loads
@@ -0,0 +1,77 @@
1
+ ACCESS = "access"
2
+ ALWAYS = "always"
3
+ DEVELOPMENT = "development"
4
+ IF_NOT_PRESENT = "if-not-present"
5
+ NEVER = "never"
6
+ ON_SUCCESS = "on_success"
7
+ ON_FAILURE = "on_failure"
8
+ OTHER = "other"
9
+ PREPARE = "prepare"
10
+ PRODUCTION = "production"
11
+ PULL = "pull"
12
+ PULL_PUSH = "pull-push"
13
+ PUSH = "push"
14
+ STAGING = "staging"
15
+ START = "start"
16
+ STOP = "stop"
17
+ TESTING = "testing"
18
+ VERIFY = "verify"
19
+
20
+ CACHE_POLICIES = (PULL, PUSH, PULL_PUSH)
21
+
22
+ DEPLOYMENT_TIERS = (
23
+ PRODUCTION,
24
+ STAGING,
25
+ TESTING,
26
+ DEVELOPMENT,
27
+ OTHER,
28
+ )
29
+
30
+ ENVIRONMENT_ACTIONS = (
31
+ START,
32
+ PREPARE,
33
+ STOP,
34
+ VERIFY,
35
+ ACCESS,
36
+ )
37
+
38
+ PULL_POLICIES = (
39
+ ALWAYS,
40
+ IF_NOT_PRESENT,
41
+ NEVER,
42
+ )
43
+
44
+ WHEN_CHOICES = (ON_SUCCESS, ON_FAILURE, ALWAYS)
45
+
46
+ RETRY_MAX_CHOICES = (0, 1, 2)
47
+
48
+ UNKNOWN_FAILURE = "unknown_failure"
49
+ SCRIPT_FAILURE = "script_failure"
50
+ API_FAILURE = "api_failure"
51
+ STUCK_OR_TIMEOUT_FAILURE = "stuck_or_timeout_failure"
52
+ RUNNER_SYSTEM_FAILURE = "runner_system_failure"
53
+ RUNNER_UNSUPPORTED = "runner_unsupported"
54
+ STALE_SCHEDULE = "stale_schedule"
55
+ JOB_EXECUTION_TIMEOUT = "job_execution_timeout"
56
+ ARCHIVED_FAILURE = "archived_failure"
57
+ UNMET_PREREQUISITES = "unmet_prerequisites"
58
+ SCHEDULER_FAILURE = "scheduler_failure"
59
+ DATA_INTEGRITY_FAILURE = "data_integrity_failure"
60
+
61
+ RETRY_WHEN_CHOICES = (
62
+ ALWAYS,
63
+ UNKNOWN_FAILURE,
64
+ SCRIPT_FAILURE,
65
+ API_FAILURE,
66
+ STUCK_OR_TIMEOUT_FAILURE,
67
+ RUNNER_SYSTEM_FAILURE,
68
+ RUNNER_UNSUPPORTED,
69
+ STALE_SCHEDULE,
70
+ JOB_EXECUTION_TIMEOUT,
71
+ ARCHIVED_FAILURE,
72
+ UNMET_PREREQUISITES,
73
+ SCHEDULER_FAILURE,
74
+ DATA_INTEGRITY_FAILURE,
75
+ )
76
+
77
+ CI_FILE = ".gitlab-ci.yml"
@@ -0,0 +1,203 @@
1
+ import functools
2
+ from typing import Union
3
+
4
+ import cattrs
5
+ from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn, override
6
+
7
+ from ...utils import (
8
+ make_quoted_dict,
9
+ make_quoted_list_or_string,
10
+ make_quoted_string,
11
+ make_scalar_list,
12
+ )
13
+ from . import models
14
+
15
+ CONVERTER = cattrs.Converter(omit_if_default=True, forbid_extra_keys=True)
16
+
17
+
18
+ make_dict_struct = functools.partial(
19
+ make_dict_structure_fn,
20
+ _cattrs_forbid_extra_keys=True,
21
+ _cattrs_omit_if_default=True,
22
+ )
23
+
24
+ make_dict_unstruct = functools.partial(
25
+ make_dict_unstructure_fn,
26
+ _cattrs_forbid_extra_keys=True,
27
+ _cattrs_omit_if_default=True,
28
+ )
29
+
30
+
31
+ def quoted_string_struct_hook(object, __):
32
+ return make_quoted_string(object)
33
+
34
+
35
+ def quoted_list_or_string_struct_hook(object, __):
36
+ return make_quoted_list_or_string(object)
37
+
38
+
39
+ def quoted_dict_struct_hook(object, __):
40
+ return make_quoted_dict(object)
41
+
42
+
43
+ def structure_image(object, __):
44
+ if object is None:
45
+ return None
46
+ elif isinstance(object, str):
47
+ return make_quoted_string(CONVERTER.structure(object, str))
48
+ return CONVERTER.structure(object, models.Image)
49
+
50
+
51
+ def structure_include(object, __):
52
+ if isinstance(object, str):
53
+ return CONVERTER.structure(object, str)
54
+ items = []
55
+ for item in object:
56
+ if "local" in item:
57
+ items.append(CONVERTER.structure(item, models.IncludeLocal))
58
+ elif "project" in item:
59
+ items.append(CONVERTER.structure(item, models.IncludeProject))
60
+ elif "remote" in item:
61
+ items.append(CONVERTER.structure(item, models.IncludeRemote))
62
+ elif "template" in item:
63
+ items.append(CONVERTER.structure(item, models.IncludeTemplate))
64
+ return items
65
+
66
+
67
+ def structure_script(object, __):
68
+ if isinstance(object, str):
69
+ return CONVERTER.structure(object, str)
70
+ items = []
71
+ for item in object:
72
+ if isinstance(item, str):
73
+ items.append(CONVERTER.structure(item, str))
74
+ else:
75
+ for entry in item:
76
+ items.append(CONVERTER.structure(entry, str))
77
+ return make_scalar_list(items)
78
+
79
+
80
+ def structure_service(object, __):
81
+ if isinstance(object, str):
82
+ return make_quoted_string(CONVERTER.structure(object, str))
83
+ return CONVERTER.structure(object, models.Service)
84
+
85
+
86
+ def structure_variables(object, __):
87
+ return make_quoted_dict(object)
88
+
89
+
90
+ CONVERTER.register_structure_hook(
91
+ Union[
92
+ str,
93
+ list[
94
+ Union[
95
+ models.IncludeLocal,
96
+ models.IncludeProject,
97
+ models.IncludeRemote,
98
+ models.IncludeTemplate,
99
+ ]
100
+ ],
101
+ ],
102
+ structure_include,
103
+ )
104
+
105
+
106
+ CONVERTER.register_structure_hook(
107
+ Union[str, list[str]], quoted_list_or_string_struct_hook
108
+ )
109
+
110
+ CONVERTER.register_structure_hook(
111
+ Union[str, models.Service],
112
+ lambda object, _: CONVERTER.structure(
113
+ object, str if isinstance(object, str) else models.Service
114
+ ),
115
+ )
116
+
117
+ CONVERTER.register_structure_hook(
118
+ Union[int, models.Retry],
119
+ lambda object, _: CONVERTER.structure(
120
+ object, int if isinstance(object, int) else models.Retry
121
+ ),
122
+ )
123
+
124
+
125
+ CONVERTER.register_structure_hook(
126
+ Union[str, list[Union[str, list[str]]]],
127
+ structure_script,
128
+ )
129
+
130
+ CONVERTER.register_structure_hook(
131
+ models.Image,
132
+ make_dict_struct(
133
+ models.Image,
134
+ CONVERTER,
135
+ name=override(struct_hook=quoted_string_struct_hook),
136
+ entrypoint=override(struct_hook=quoted_list_or_string_struct_hook),
137
+ ),
138
+ )
139
+
140
+ CONVERTER.register_structure_hook(
141
+ models.Service,
142
+ make_dict_struct(
143
+ models.Service,
144
+ CONVERTER,
145
+ name=override(struct_hook=quoted_string_struct_hook),
146
+ command=override(struct_hook=quoted_list_or_string_struct_hook),
147
+ entrypoint=override(struct_hook=quoted_list_or_string_struct_hook),
148
+ ),
149
+ )
150
+
151
+
152
+ def unstructure_script(cls):
153
+ object = CONVERTER.unstructure(cls, str if isinstance(cls, str) else list[str])
154
+ if isinstance(object, str):
155
+ return object # make_quoted_scalar_string(object)
156
+ return make_scalar_list(object)
157
+
158
+
159
+ CONVERTER.register_unstructure_hook(
160
+ Union[str, list[str]],
161
+ unstructure_script,
162
+ )
163
+
164
+ CONVERTER.register_structure_hook(
165
+ Union[str, models.Image, None],
166
+ structure_image,
167
+ )
168
+
169
+ CONVERTER.register_structure_hook(
170
+ models.Rule,
171
+ make_dict_struct(
172
+ models.Rule,
173
+ CONVERTER,
174
+ if_=override(rename="if"),
175
+ ),
176
+ )
177
+ CONVERTER.register_unstructure_hook(
178
+ models.Rule,
179
+ make_dict_unstruct(
180
+ models.Rule,
181
+ CONVERTER,
182
+ if_=override(rename="if"),
183
+ ),
184
+ )
185
+
186
+
187
+ CONVERTER.register_structure_hook(
188
+ models.Job,
189
+ make_dict_struct(
190
+ models.Job,
191
+ CONVERTER,
192
+ variables=override(struct_hook=quoted_dict_struct_hook),
193
+ ),
194
+ )
195
+
196
+ CONVERTER.register_structure_hook(
197
+ models.File,
198
+ make_dict_struct(
199
+ models.File,
200
+ CONVERTER,
201
+ variables=override(struct_hook=quoted_dict_struct_hook),
202
+ ),
203
+ )