bitbucket-pipeline-runner 0.0.1__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.

Potentially problematic release.


This version of bitbucket-pipeline-runner might be problematic. Click here for more details.

Files changed (23) hide show
  1. bitbucket_pipeline_runner-0.0.1/LICENSE +21 -0
  2. bitbucket_pipeline_runner-0.0.1/PKG-INFO +80 -0
  3. bitbucket_pipeline_runner-0.0.1/README.md +47 -0
  4. bitbucket_pipeline_runner-0.0.1/pipeline_runner/__init__.py +1 -0
  5. bitbucket_pipeline_runner-0.0.1/pipeline_runner/__main__.py +4 -0
  6. bitbucket_pipeline_runner-0.0.1/pipeline_runner/artifacts.py +89 -0
  7. bitbucket_pipeline_runner-0.0.1/pipeline_runner/cache.py +224 -0
  8. bitbucket_pipeline_runner-0.0.1/pipeline_runner/cli.py +207 -0
  9. bitbucket_pipeline_runner-0.0.1/pipeline_runner/config.py +92 -0
  10. bitbucket_pipeline_runner-0.0.1/pipeline_runner/container.py +507 -0
  11. bitbucket_pipeline_runner-0.0.1/pipeline_runner/context.py +172 -0
  12. bitbucket_pipeline_runner-0.0.1/pipeline_runner/errors.py +13 -0
  13. bitbucket_pipeline_runner-0.0.1/pipeline_runner/models.py +443 -0
  14. bitbucket_pipeline_runner-0.0.1/pipeline_runner/parse.py +15 -0
  15. bitbucket_pipeline_runner-0.0.1/pipeline_runner/py.typed +0 -0
  16. bitbucket_pipeline_runner-0.0.1/pipeline_runner/repository.py +133 -0
  17. bitbucket_pipeline_runner-0.0.1/pipeline_runner/runner.py +436 -0
  18. bitbucket_pipeline_runner-0.0.1/pipeline_runner/service.py +284 -0
  19. bitbucket_pipeline_runner-0.0.1/pipeline_runner/static/__init__.py +0 -0
  20. bitbucket_pipeline_runner-0.0.1/pipeline_runner/static/known_hosts +6 -0
  21. bitbucket_pipeline_runner-0.0.1/pipeline_runner/static/runit.sh +15 -0
  22. bitbucket_pipeline_runner-0.0.1/pipeline_runner/utils.py +166 -0
  23. bitbucket_pipeline_runner-0.0.1/pyproject.toml +112 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Mathieu Lemay
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.1
2
+ Name: bitbucket-pipeline-runner
3
+ Version: 0.0.1
4
+ Summary: Run a bitbucket pipeline locally
5
+ Home-page: https://github.com/mathieu-lemay/pipeline-runner
6
+ License: MIT
7
+ Author: Mathieu Lemay
8
+ Author-email: acidrain1@gmail.com
9
+ Requires-Python: >=3.10,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Dist: GitPython (>=3.1.12,<4.0.0)
16
+ Requires-Dist: PyYAML (>=6.0,<7.0)
17
+ Requires-Dist: appdirs (>=1.4.4,<2.0.0)
18
+ Requires-Dist: boto3 (>=1.28.65,<2.0.0)
19
+ Requires-Dist: click (>=8.0.1,<9.0.0)
20
+ Requires-Dist: coloredlogs (>=15.0,<16.0)
21
+ Requires-Dist: cryptography (>=42.0.2,<43.0.0)
22
+ Requires-Dist: docker (>=7.0.0,<8.0.0)
23
+ Requires-Dist: pydantic (>=2.0,<3.0)
24
+ Requires-Dist: pydantic-settings (>=2.0.3,<3.0.0)
25
+ Requires-Dist: pyfzf (>=0.3.0,<0.4.0)
26
+ Requires-Dist: python-dotenv (>=1.0.0,<2.0.0)
27
+ Requires-Dist: python-slugify (>=8.0.0,<9.0.0)
28
+ Requires-Dist: requests (>=2.31.0,<3.0.0)
29
+ Requires-Dist: tenacity (>=8.0.1,<9.0.0)
30
+ Project-URL: Repository, https://github.com/mathieu-lemay/pipeline-runner
31
+ Description-Content-Type: text/markdown
32
+
33
+ # Bitbucket Pipeline Runner
34
+
35
+ Tool to run Bitbucket Pipelines locally.
36
+
37
+ ## Installation
38
+ ```shell
39
+ pip install bitbucket-pipeline-runner
40
+ ```
41
+
42
+ ## Basic usage
43
+ To run a pipeline
44
+ ```shell
45
+ cd <project-directory>
46
+ pipeline-runner run <pipeline-name>
47
+ ```
48
+
49
+ To list available pipelines
50
+ ```shell
51
+ cd <project-directory>
52
+ pipeline-runner list
53
+ ```
54
+
55
+ ## Environment variables
56
+ bitbucket pipeline runner already sets all `BITBUCKET_*` enviromnent variables in the step's run enviromnent.
57
+ It will also source any `.env` file in the current directory, for all project specific enviromnent variables.
58
+
59
+ ## Artifacts and logs
60
+ Persistent data like artifacts generated from your pipelines and execution logs can be found in your user's data directory.
61
+
62
+ On Linux:
63
+
64
+ ${XDG_DATA_HOME:-~/.local/share}/pipeline-runner
65
+
66
+ On macOS:
67
+
68
+ ~/Library/Application Support/pipeline-runner
69
+
70
+ ## Caches
71
+ Caches defined in your pipelines are stored in your user's cache directory.
72
+
73
+ On Linux:
74
+
75
+ ${XDG_CACHE_HOME:-~/.cache}/pipeline-runner
76
+
77
+ On macOS:
78
+
79
+ ~/Library/Caches/pipeline-runner
80
+
@@ -0,0 +1,47 @@
1
+ # Bitbucket Pipeline Runner
2
+
3
+ Tool to run Bitbucket Pipelines locally.
4
+
5
+ ## Installation
6
+ ```shell
7
+ pip install bitbucket-pipeline-runner
8
+ ```
9
+
10
+ ## Basic usage
11
+ To run a pipeline
12
+ ```shell
13
+ cd <project-directory>
14
+ pipeline-runner run <pipeline-name>
15
+ ```
16
+
17
+ To list available pipelines
18
+ ```shell
19
+ cd <project-directory>
20
+ pipeline-runner list
21
+ ```
22
+
23
+ ## Environment variables
24
+ bitbucket pipeline runner already sets all `BITBUCKET_*` enviromnent variables in the step's run enviromnent.
25
+ It will also source any `.env` file in the current directory, for all project specific enviromnent variables.
26
+
27
+ ## Artifacts and logs
28
+ Persistent data like artifacts generated from your pipelines and execution logs can be found in your user's data directory.
29
+
30
+ On Linux:
31
+
32
+ ${XDG_DATA_HOME:-~/.local/share}/pipeline-runner
33
+
34
+ On macOS:
35
+
36
+ ~/Library/Application Support/pipeline-runner
37
+
38
+ ## Caches
39
+ Caches defined in your pipelines are stored in your user's cache directory.
40
+
41
+ On Linux:
42
+
43
+ ${XDG_CACHE_HOME:-~/.cache}/pipeline-runner
44
+
45
+ On macOS:
46
+
47
+ ~/Library/Caches/pipeline-runner
@@ -0,0 +1 @@
1
+ APP_NAME = "pipeline-runner"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,89 @@
1
+ import io
2
+ import logging
3
+ import os.path
4
+ import tarfile
5
+ from tarfile import TarInfo
6
+ from time import time as ts
7
+ from uuid import UUID
8
+
9
+ from .config import config
10
+ from .container import ContainerRunner
11
+ from .utils import FileStreamer, get_human_readable_size, safe_extract_tar
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class ArtifactManager:
17
+ def __init__(self, container: ContainerRunner, artifact_directory: str, step_uuid: UUID) -> None:
18
+ self._container = container
19
+ self._artifact_directory = artifact_directory
20
+ self._step_uuid = step_uuid
21
+
22
+ def upload(self) -> None:
23
+ logger.info("Loading artifacts")
24
+
25
+ t = ts()
26
+
27
+ tar_data = io.BytesIO()
28
+
29
+ with tarfile.open(fileobj=tar_data, mode="w|") as tar:
30
+ for root, _, files in os.walk(self._artifact_directory):
31
+ for af in files:
32
+ full_path = os.path.join(root, af)
33
+
34
+ relpath = os.path.relpath(full_path, self._artifact_directory)
35
+ ti = TarInfo(relpath)
36
+
37
+ stat = os.stat(full_path)
38
+ ti.size = stat.st_size
39
+ ti.mode = stat.st_mode
40
+
41
+ with open(full_path, "rb") as f:
42
+ tar.addfile(ti, f)
43
+
44
+ res = self._container.put_archive(config.build_dir, tar_data.getvalue())
45
+ if not res:
46
+ raise Exception(f"Error loading artifact: {af}")
47
+
48
+ t = ts() - t
49
+
50
+ logger.info("Artifacts loaded in %.3fs", t)
51
+
52
+ def download(self, artifacts: list[str]) -> None:
53
+ if not artifacts:
54
+ return
55
+
56
+ artifact_file = f"artifacts-{self._step_uuid}.tar"
57
+ artifact_remote_path = os.path.join(config.build_dir, artifact_file)
58
+ artifact_local_directory = self._artifact_directory
59
+
60
+ logger.info("Collecting artifacts")
61
+
62
+ t = ts()
63
+
64
+ path_filters = " -o ".join(f"-path './{a}'" for a in artifacts)
65
+ prepare_artifacts_cmd = ["find", "-type", "f", r"\(", path_filters, r"\)"]
66
+ prepare_artifacts_cmd += ["|", "tar", "cf", artifact_file, "-C", config.build_dir, "-T", "-"]
67
+
68
+ self._container.run_command(prepare_artifacts_cmd)
69
+ if not self._container.path_exists(artifact_remote_path):
70
+ logger.info("No artifacts found. Skipping")
71
+ return
72
+
73
+ data, stats = self._container.get_archive(artifact_remote_path, encode_stream=True)
74
+ logger.debug("artifacts stats: %s", stats)
75
+
76
+ # FileStreamer only implements `read` which is all that is needed.
77
+ with tarfile.open(fileobj=FileStreamer(data), mode="r|") as wrapper_tar: # type: ignore[abstract]
78
+ for entry in wrapper_tar:
79
+ with tarfile.open(fileobj=wrapper_tar.extractfile(entry), mode="r|") as tar:
80
+ safe_extract_tar(tar, artifact_local_directory)
81
+
82
+ t = ts() - t
83
+
84
+ logger.info(
85
+ "Artifacts saved %s to %s in %.3fs",
86
+ get_human_readable_size(stats["size"]),
87
+ artifact_local_directory,
88
+ t,
89
+ )
@@ -0,0 +1,224 @@
1
+ import logging
2
+ import os.path
3
+ from tempfile import NamedTemporaryFile
4
+ from time import time as ts
5
+
6
+ from . import utils
7
+ from .config import config
8
+ from .container import ContainerRunner
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ DOCKER_IMAGES_ARCHIVE_FILE_NAME = "images.tar"
13
+
14
+
15
+ class CacheManager:
16
+ def __init__(self, container: ContainerRunner, cache_directory: str, cache_definitions: dict[str, str]) -> None:
17
+ self._container = container
18
+ self._cache_directory = cache_directory
19
+ self._cache_definitions = cache_definitions
20
+
21
+ self._ignored_caches = {"docker"}
22
+
23
+ def upload(self, cache_names: list[str]) -> None:
24
+ for name in cache_names:
25
+ cu = CacheRestoreFactory.get(self._container, self._cache_directory, self._cache_definitions, name)
26
+ cu.restore()
27
+
28
+ def download(self, cache_names: list[str]) -> None:
29
+ for name in cache_names:
30
+ cd = CacheSaveFactory.get(self._container, self._cache_directory, self._cache_definitions, name)
31
+ cd.save()
32
+
33
+
34
+ class CacheRestore:
35
+ def __init__(
36
+ self, container: ContainerRunner, cache_directory: str, cache_definitions: dict[str, str], cache_name: str
37
+ ) -> None:
38
+ self._container = container
39
+ self._cache_directory = cache_directory
40
+ self._cache_definitions = cache_definitions
41
+ self._cache_name = cache_name
42
+
43
+ def restore(self) -> None:
44
+ cache_file = self._get_local_cache_file()
45
+
46
+ if not cache_file:
47
+ logger.info("Cache '%s': Not found: Skipping", self._cache_name)
48
+ return
49
+
50
+ self._upload_cache(cache_file)
51
+ self._restore_cache()
52
+
53
+ def _get_local_cache_file(self) -> str | None:
54
+ local_cache_archive_path = get_local_cache_archive_path(self._cache_directory, self._cache_name)
55
+ if not os.path.exists(local_cache_archive_path):
56
+ return None
57
+
58
+ return local_cache_archive_path
59
+
60
+ def _upload_cache(self, cache_file: str) -> None:
61
+ remote_cache_directory = get_remote_temp_directory(self._cache_name)
62
+ remote_cache_parent_directory = os.path.dirname(remote_cache_directory)
63
+
64
+ cache_archive_size = os.path.getsize(cache_file)
65
+
66
+ logger.info("Cache '%s': Uploading", self._cache_name)
67
+
68
+ t = ts()
69
+
70
+ prepare_cache_dir_cmd = (
71
+ f'[ -d "{remote_cache_directory}" ] && rm -rf "{remote_cache_directory}"; '
72
+ f'mkdir -p "{remote_cache_parent_directory}"'
73
+ )
74
+ res, output = self._container.run_command(prepare_cache_dir_cmd)
75
+ if res != 0:
76
+ logger.error("Remote command failed: %s", output.decode())
77
+ raise Exception(f"Error uploading cache: {self._cache_name}")
78
+
79
+ with open(cache_file, "rb") as f:
80
+ success = self._container.put_archive(remote_cache_parent_directory, f)
81
+ if not success:
82
+ raise Exception(f"Error uploading cache: {self._cache_name}")
83
+
84
+ t = ts() - t
85
+
86
+ logger.info(
87
+ "Cache '%s': Uploaded %s in %.3fs", self._cache_name, utils.get_human_readable_size(cache_archive_size), t
88
+ )
89
+
90
+ def _restore_cache(self) -> None:
91
+ temp_dir = get_remote_temp_directory(self._cache_name)
92
+ target_dir = sanitize_remote_path(self._cache_definitions[self._cache_name])
93
+
94
+ logger.info("Cache '%s': Restoring", self._cache_name)
95
+
96
+ t = ts()
97
+
98
+ restore_cache_script = [
99
+ f'if [ -e "{target_dir}" ]; then rm -rf "{target_dir}"; fi',
100
+ f'mkdir -p "$(dirname "{target_dir}")"',
101
+ f'mv "{temp_dir}" "{target_dir}"',
102
+ ]
103
+
104
+ exit_code, output = self._container.run_command("\n".join(restore_cache_script))
105
+ if exit_code != 0:
106
+ raise Exception(f"Error restoring cache: {self._cache_name}: {output.decode()}")
107
+
108
+ t = ts() - t
109
+
110
+ logger.info("Cache '%s': Restored in %.3fs", self._cache_name, t)
111
+
112
+
113
+ class NullCacheRestore(CacheRestore):
114
+ def restore(self) -> None:
115
+ logger.info("Cache '%s': Ignoring", self._cache_name)
116
+
117
+
118
+ class CacheRestoreFactory:
119
+ @staticmethod
120
+ def get(
121
+ container: ContainerRunner, cache_directory: str, cache_definitions: dict[str, str], cache_name: str
122
+ ) -> CacheRestore:
123
+ cls: type[CacheRestore | NullCacheRestore]
124
+
125
+ cls = NullCacheRestore if cache_name == "docker" else CacheRestore
126
+
127
+ return cls(container, cache_directory, cache_definitions, cache_name)
128
+
129
+
130
+ class CacheSave:
131
+ def __init__(
132
+ self, container: ContainerRunner, cache_directory: str, cache_definitions: dict[str, str], cache_name: str
133
+ ) -> None:
134
+ self._container = container
135
+ self._cache_directory = cache_directory
136
+ self._cache_definitions = cache_definitions
137
+ self._cache_name = cache_name
138
+
139
+ def save(self) -> None:
140
+ remote_cache_directory = self._prepare()
141
+
142
+ local_cache_archive_path = get_local_cache_archive_path(self._cache_directory, self._cache_name)
143
+ self._download(remote_cache_directory, local_cache_archive_path)
144
+
145
+ def _prepare(self) -> str:
146
+ remote_dir = sanitize_remote_path(self._cache_definitions[self._cache_name])
147
+ target_dir = get_remote_temp_directory(self._cache_name)
148
+
149
+ logger.info("Cache '%s': Preparing", self._cache_name)
150
+
151
+ t = ts()
152
+
153
+ prepare_cache_cmd = f'if [ -e "{remote_dir}" ]; then mv "{remote_dir}" "{target_dir}"; fi'
154
+
155
+ exit_code, output = self._container.run_command(prepare_cache_cmd)
156
+ if exit_code != 0:
157
+ raise Exception(f"Error preparing cache: {self._cache_name}: {output.decode()}")
158
+
159
+ t = ts() - t
160
+
161
+ logger.info("Cache '%s': Prepared in %.3fs", self._cache_name, t)
162
+
163
+ return target_dir
164
+
165
+ def _download(self, src: str, dst: str) -> None:
166
+ if not self._container.path_exists(src):
167
+ logger.info("Cache '%s': Not found", self._cache_name)
168
+ return
169
+
170
+ logger.info("Cache '%s': Downloading", self._cache_name)
171
+
172
+ t = ts()
173
+
174
+ with NamedTemporaryFile(dir=self._cache_directory, delete=False) as f:
175
+ try:
176
+ logger.debug("Downloading cache folder '%s' to '%s'", src, f.name)
177
+ data, _ = self._container.get_archive(src)
178
+ size = 0
179
+ for chunk in data:
180
+ size += len(chunk)
181
+ f.write(chunk)
182
+ except Exception:
183
+ logger.exception("Error getting cache from container: %s", self._cache_name)
184
+ os.unlink(f.name)
185
+ return
186
+ else:
187
+ logger.debug("Moving temp cache archive %s to %s", f.name, dst)
188
+ os.rename(f.name, dst)
189
+
190
+ t = ts() - t
191
+
192
+ logger.info("Cache '%s': Downloaded %s in %.3fs", self._cache_name, utils.get_human_readable_size(size), t)
193
+
194
+
195
+ class NullCacheSave(CacheSave):
196
+ def save(self) -> None:
197
+ logger.info("Cache '%s': Ignoring", self._cache_name)
198
+
199
+
200
+ class CacheSaveFactory:
201
+ @staticmethod
202
+ def get(
203
+ container: ContainerRunner, cache_directory: str, cache_definitions: dict[str, str], cache_name: str
204
+ ) -> CacheSave:
205
+ cls: type[CacheSave | NullCacheSave]
206
+
207
+ cls = NullCacheSave if cache_name == "docker" else CacheSave
208
+
209
+ return cls(container, cache_directory, cache_definitions, cache_name)
210
+
211
+
212
+ def get_local_cache_archive_path(cache_directory: str, cache_name: str) -> str:
213
+ return os.path.join(cache_directory, f"{cache_name}.tar")
214
+
215
+
216
+ def get_remote_temp_directory(cache_name: str) -> str:
217
+ return os.path.join(config.caches_dir, cache_name)
218
+
219
+
220
+ def sanitize_remote_path(path: str) -> str:
221
+ if path.startswith("~"):
222
+ path = path.replace("~", "$HOME", 1)
223
+
224
+ return path
@@ -0,0 +1,207 @@
1
+ import logging
2
+ import logging.config
3
+ import os
4
+ import shutil
5
+ import sys
6
+ from importlib.metadata import version
7
+
8
+ import click
9
+ from pyfzf import FzfPrompt # type: ignore[import-untyped]
10
+
11
+ from pipeline_runner.errors import InvalidPipelineError
12
+
13
+ from . import utils
14
+ from .config import config
15
+ from .parse import parse_pipeline_file
16
+ from .runner import PipelineRunner, PipelineRunRequest
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def _init_logger() -> None:
22
+ logging.config.dictConfig(config.log_config)
23
+
24
+
25
+ def _get_pipelines_list(pipeline_file: str) -> list[str]:
26
+ pipelines_definition = parse_pipeline_file(pipeline_file)
27
+
28
+ return pipelines_definition.get_available_pipelines()
29
+
30
+
31
+ def _prompt_for_pipeline(pipeline_file: str) -> str | None:
32
+ pipeline = None
33
+ pipelines = _get_pipelines_list(pipeline_file)
34
+
35
+ try:
36
+ fzf = FzfPrompt()
37
+ pipeline = next(iter(fzf.prompt(pipelines)), None)
38
+ if not pipeline:
39
+ logger.warning("No pipeline selected")
40
+ except SystemError:
41
+ logger.warning("fzf executable not found, disabling interactive pipeline selection.")
42
+
43
+ return pipeline
44
+
45
+
46
+ @click.group("Pipeline Runner", invoke_without_command=True)
47
+ @click.option(
48
+ "-V",
49
+ "--version",
50
+ "show_version",
51
+ is_flag=True,
52
+ help="Print project version and exit.",
53
+ )
54
+ @click.pass_context
55
+ def main(ctx: click.Context, *, show_version: bool) -> None:
56
+ if show_version:
57
+ click.echo(f"Pipeline Runner {version('bitbucket_pipeline_runner')}")
58
+ ctx.exit()
59
+
60
+ if not ctx.invoked_subcommand:
61
+ click.echo(ctx.get_help())
62
+ ctx.exit(1)
63
+
64
+
65
+ @main.command()
66
+ @click.argument("pipeline", required=False)
67
+ @click.option(
68
+ "-r",
69
+ "--repository-path",
70
+ help="Path to the git repository. Defaults to current directory.",
71
+ )
72
+ @click.option(
73
+ "-s",
74
+ "--step",
75
+ "steps",
76
+ multiple=True,
77
+ help="Steps to run. If none are specified, they will all be run. Can be specified multiple times.",
78
+ )
79
+ @click.option(
80
+ "-e",
81
+ "--env-file",
82
+ "env_files",
83
+ multiple=True,
84
+ help="Read in a file of environment variables. Can be specified multiple times.",
85
+ )
86
+ @click.option(
87
+ "-c",
88
+ "--color/--no-color",
89
+ default=True,
90
+ help="Enable colored output. Default: True",
91
+ )
92
+ @click.option(
93
+ "--cpu-limits/--no-cpu-limits",
94
+ default=False,
95
+ help="Enable to enforce cpu limits for the runner. Default: False",
96
+ )
97
+ @click.option(
98
+ "--ssh/--no-ssh",
99
+ "expose_ssh_agent",
100
+ default=False,
101
+ help="Expose the local ssh agent to the container. Default: False",
102
+ )
103
+ def run(
104
+ pipeline: str | None,
105
+ repository_path: str,
106
+ steps: list[str],
107
+ env_files: list[str],
108
+ *,
109
+ color: bool,
110
+ cpu_limits: bool,
111
+ expose_ssh_agent: bool,
112
+ ) -> None:
113
+ """
114
+ Runs the pipeline PIPELINE.
115
+
116
+ PIPELINE is the full path to the pipeline to run. Ex: branches.master
117
+ """
118
+ config.color = color
119
+ config.cpu_limits = cpu_limits
120
+ config.expose_ssh_agent = expose_ssh_agent
121
+
122
+ _init_logger()
123
+
124
+ if not pipeline:
125
+ pipeline = _prompt_for_pipeline(os.path.join(repository_path or ".", "bitbucket-pipelines.yml"))
126
+
127
+ if not pipeline:
128
+ logger.error("pipeline not specified")
129
+ sys.exit(2)
130
+
131
+ req = PipelineRunRequest(pipeline, repository_path, steps, env_files)
132
+
133
+ runner = PipelineRunner(req)
134
+ try:
135
+ runner.run()
136
+ except Exception:
137
+ logger.exception("Error running pipeline")
138
+ sys.exit(1)
139
+
140
+
141
+ @main.command("list")
142
+ @click.option(
143
+ "-r",
144
+ "--repository-path",
145
+ help="Path to the git repository. Defaults to current directory.",
146
+ )
147
+ @click.option(
148
+ "-c",
149
+ "--color/--no-color",
150
+ default=True,
151
+ help="Enable colored output",
152
+ )
153
+ def list_(repository_path: str, *, color: bool) -> None:
154
+ """
155
+ List the available pipelines.
156
+ """
157
+ config.color = color
158
+
159
+ _init_logger()
160
+
161
+ pipelines = _get_pipelines_list(os.path.join(repository_path or ".", "bitbucket-pipelines.yml"))
162
+
163
+ logger.info("Available pipelines:\n\t%s", "\n\t".join(sorted(pipelines)))
164
+
165
+
166
+ @main.command()
167
+ @click.argument("pipeline", required=False)
168
+ @click.option(
169
+ "-r",
170
+ "--repository-path",
171
+ help="Path to the git repository. Defaults to current directory.",
172
+ )
173
+ def parse(pipeline: str | None, repository_path: str) -> None:
174
+ """
175
+ Parse the pipeline file.
176
+ """
177
+ pipeline_file = os.path.join(repository_path or ".", "bitbucket-pipelines.yml")
178
+
179
+ pipelines_definition = parse_pipeline_file(pipeline_file)
180
+
181
+ if pipeline:
182
+ parsed = pipelines_definition.get_pipeline(pipeline)
183
+ if not parsed:
184
+ raise InvalidPipelineError(pipeline)
185
+ click.echo(parsed.model_dump_json(indent=2))
186
+ else:
187
+ click.echo(pipelines_definition.model_dump_json(indent=2))
188
+
189
+
190
+ @main.command()
191
+ @click.argument("action", type=click.Choice(["clear", "list"]))
192
+ def cache(action: str) -> None:
193
+ cache_dir = utils.get_cache_directory()
194
+ if not os.path.isdir(cache_dir):
195
+ return
196
+
197
+ projects = sorted(os.listdir(cache_dir))
198
+ if action == "list":
199
+ click.echo("Caches:")
200
+ click.echo("\n".join(f"\t{p}" for p in projects))
201
+ elif action == "clear":
202
+ for p in projects:
203
+ shutil.rmtree(os.path.join(cache_dir, p))
204
+
205
+
206
+ if __name__ == "__main__":
207
+ main()