bb-run 1.0.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.
@@ -0,0 +1,248 @@
1
+ Metadata-Version: 2.4
2
+ Name: bb-run
3
+ Version: 1.0.0
4
+ Summary: Run Bitbucket Pipelines locally
5
+ Author-email: Karl Hill <karlhillx@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/karlhillx/bb-run
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: PyYAML>=6.0
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest>=8.0; extra == "test"
14
+ Requires-Dist: pytest-cov>=4.0; extra == "test"
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8.0; extra == "dev"
17
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
18
+ Requires-Dist: ruff>=0.4; extra == "dev"
19
+ Dynamic: license-file
20
+
21
+ # bb-run
22
+
23
+ [![Version](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fkarlhillx%2Fbb-run%2Fmain%2Fpyproject.toml&query=project.version&label=version)](https://github.com/karlhillx/bb-run/blob/main/pyproject.toml)
24
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
25
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue.svg)](https://github.com/karlhillx/bb-run/blob/main/pyproject.toml)
26
+ [![Test](https://github.com/karlhillx/bb-run/actions/workflows/test.yml/badge.svg)](https://github.com/karlhillx/bb-run/actions/workflows/test.yml)
27
+
28
+ **Run Bitbucket Pipelines locally.** bb-run faithfully executes your `bitbucket-pipelines.yml` on your local machine using Docker or directly on your host.
29
+
30
+ ## Why bb-run?
31
+
32
+ - **Test before pushing** - Catch CI failures locally before committing
33
+ - **Fast iteration** - No waiting for Bitbucket's pipeline queue
34
+ - **Debug easily** - Run in verbose mode, inspect outputs directly
35
+ - **Two modes** - Docker for Bitbucket-accurate execution, Host for quick local testing
36
+ - **No dependencies** - Works with just Python and Docker (optional)
37
+
38
+ ## Installation
39
+
40
+ ### via pip
41
+
42
+ ```bash
43
+ pip install bb-run
44
+ ```
45
+
46
+ ### via Homebrew
47
+
48
+ ```bash
49
+ brew install karlhillx/tap/bb-run
50
+ ```
51
+
52
+ ### from source
53
+
54
+ ```bash
55
+ git clone https://github.com/karlhillx/bb-run.git
56
+ cd bb-run
57
+ pip install -e .
58
+ ```
59
+
60
+ ## Quick Start
61
+
62
+ ### Validate a pipeline (instant)
63
+
64
+ ```bash
65
+ bb-run --validate
66
+ ```
67
+
68
+ ### Run the default pipeline
69
+
70
+ ```bash
71
+ bb-run
72
+ ```
73
+
74
+ ### Run a specific branch
75
+
76
+ ```bash
77
+ bb-run --target branches.main
78
+ bb-run -t branches.main
79
+ ```
80
+
81
+ ### Simulate a feature branch
82
+
83
+ ```bash
84
+ bb-run --branch feature/my-work
85
+ ```
86
+
87
+ ### Run on your host (no Docker)
88
+
89
+ ```bash
90
+ bb-run --mode host
91
+ ```
92
+
93
+ ### Pass variables
94
+
95
+ ```bash
96
+ bb-run -v ENVIRONMENT=staging -v API_KEY=secret
97
+ ```
98
+
99
+ ### List available targets
100
+
101
+ ```bash
102
+ bb-run --list-targets
103
+ ```
104
+
105
+ ## Modes
106
+
107
+ ### Docker Mode (default)
108
+
109
+ Runs steps in Docker containers matching Bitbucket's build environment.
110
+
111
+ ```bash
112
+ bb-run --mode docker
113
+ ```
114
+
115
+ **Pros:** Faithful reproduction of Bitbucket's environment
116
+ **Cons:** Requires Docker, images may take time to download
117
+
118
+ ### Host Mode
119
+
120
+ Runs steps directly on your local machine.
121
+
122
+ ```bash
123
+ bb-run --mode host
124
+ ```
125
+
126
+ **Pros:** Fast, no image downloads
127
+ **Cons:** May differ from Bitbucket's environment (Python vs Python3, etc.)
128
+
129
+ ## Examples
130
+
131
+ ### Python project
132
+
133
+ ```bash
134
+ cd my-python-project
135
+ bb-run
136
+ ```
137
+
138
+ ### Node.js project
139
+
140
+ ```bash
141
+ cd my-node-project
142
+ bb-run --target branches.main
143
+ ```
144
+
145
+ ### Run with verbose output
146
+
147
+ ```bash
148
+ bb-run --verbose
149
+ ```
150
+
151
+ ## Configuration
152
+
153
+ bb-run automatically looks for `bitbucket-pipelines.yml` in your current directory. Use `--repo` to specify a different path:
154
+
155
+ ```bash
156
+ bb-run --repo /path/to/repo
157
+ ```
158
+
159
+ ## Requirements
160
+
161
+ - Python 3.12+
162
+ - PyYAML
163
+ - Docker (for Docker mode)
164
+
165
+ ### Local development (virtualenv)
166
+
167
+ Use Python 3.12 for the project venv so `python --version` matches `requires-python` in `pyproject.toml`:
168
+
169
+ ```bash
170
+ # macOS (Homebrew)
171
+ brew install python@3.12
172
+ "$(brew --prefix python@3.12)/bin/python3.12" -m venv .venv
173
+ source .venv/bin/activate
174
+ pip install -e ".[dev]"
175
+ # or tests + coverage only: pip install -e ".[test]"
176
+ python -m pytest
177
+ python -m pytest --cov=bbrun --cov-report=xml tests/
178
+ ruff check bbrun
179
+ ```
180
+
181
+ ## Environment Variables
182
+
183
+ bb-run sets these Bitbucket-specific environment variables:
184
+
185
+ | Variable | Description |
186
+ |----------|-------------|
187
+ | `BITBUCKET_BUILD_NUMBER` | Build number (set to "1") |
188
+ | `BITBUCKET_CLONE_DIR` | Repository path |
189
+ | `BITBUCKET_COMMIT` | Git commit SHA |
190
+ | `BITBUCKET_BRANCH` | Branch name |
191
+ | `BITBUCKET_REPO_SLUG` | Repository name |
192
+ | `BITBUCKET_REPO_UUID` | Unique run ID |
193
+ | `BITBUCKET_WORKSPACE` | Workspace (set to "local") |
194
+
195
+ ## Troubleshooting
196
+
197
+ ### "Docker is not available"
198
+
199
+ Use `--mode host` to run on your local machine instead of in Docker:
200
+
201
+ ```bash
202
+ bb-run --mode host
203
+ ```
204
+
205
+ ### "pip: command not found"
206
+
207
+ bb-run automatically translates `pip` to `pip3` and adds `--break-system-packages` for PEP 668 environments.
208
+
209
+ ### `pytest: error: unrecognized arguments: --cov=...`
210
+
211
+ Coverage flags come from the **pytest-cov** plugin. Install the `test` or `dev` extra, then use the same interpreter for pytest:
212
+
213
+ ```bash
214
+ pip install -e ".[dev]"
215
+ # or: pip install -e ".[test]"
216
+ python -m pytest --cov=bbrun --cov-report=xml tests/
217
+ ```
218
+
219
+ ### Image pull failures
220
+
221
+ Docker Hub rate limits may cause image downloads to fail. Try:
222
+ 1. Waiting and retrying later
223
+ 2. Using `--mode host` temporarily
224
+ 3. Configuring a Docker mirror
225
+
226
+ ## License
227
+
228
+ MIT License - see [LICENSE](LICENSE) for details.
229
+
230
+ ## Contributing
231
+
232
+ Contributions welcome! Please open an issue or submit a PR.
233
+
234
+ ## Publishing to PyPI
235
+
236
+ The package name **`bb-run`** must exist on PyPI (first upload is manual or via this workflow after [trusted publishing](https://docs.pypi.org/trusted-publishers/adding-a-publisher/) is configured).
237
+
238
+ 1. In PyPI, add a **pending publisher** for this GitHub repo and workflow `publish.yml`, environment **`pypi`**.
239
+ 2. In GitHub → **Settings → Environments**, create environment **`pypi`** (no secrets needed for trusted publishing).
240
+ 3. Bump `version` in `pyproject.toml`, merge, then **create a GitHub Release** (or run the workflow manually after a release).
241
+
242
+ Badges in this README use **GitHub** (version from `pyproject.toml` on `main`) so they stay valid before the first PyPI release. After publishing, you can add e.g. `https://img.shields.io/pypi/v/bb-run.svg`.
243
+
244
+ ## Links
245
+
246
+ - [PyPI project](https://pypi.org/project/bb-run/) (live after first successful upload)
247
+ - [GitHub Repository](https://github.com/karlhillx/bb-run)
248
+ - [Issue Tracker](https://github.com/karlhillx/bb-run/issues)
@@ -0,0 +1,11 @@
1
+ bb_run-1.0.0.dist-info/licenses/LICENSE,sha256=w7gLMqk8rKX33vCkMQJFyR-H8zvFG7BXDWeqjUbhNAQ,1065
2
+ bbrun/__init__.py,sha256=o8_eaSXjj9zhhn23Ya2dTS_84tz7VsKWIFKaO0HAjGY,249
3
+ bbrun/cli.py,sha256=lewHyEHX3b1Rxb2HhJ8i2Oq_w7jREkof8DOINhRMjJQ,4879
4
+ bbrun/docker.py,sha256=MP7JWO7UaoHtGDwhCMt3LNC6Zd6N1uP1a6MB7dZTS7E,7111
5
+ bbrun/host.py,sha256=Uf_UCNsSEmtPTvSHarUiguQhVAKuJzOwVVaRtdsdj2s,6048
6
+ bbrun/validator.py,sha256=FxdOt-MwNnWtoDT0_A5qS5PUjgrFJLUkmCPIsQu_KxY,3389
7
+ bb_run-1.0.0.dist-info/METADATA,sha256=jnxgLWOJEmpCvdGQc_yO9Ij9cvL8MyBe8vfrJR-3sug,6338
8
+ bb_run-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ bb_run-1.0.0.dist-info/entry_points.txt,sha256=M4-RuBKtKFsdQBGHvloxQNAzSzcgEjCUefJU4eX0YfM,42
10
+ bb_run-1.0.0.dist-info/top_level.txt,sha256=x1SUojzkaTzH4xk5aFHKUrjL3nbgJGaK3fIxv8QdSY4,6
11
+ bb_run-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bb-run = bbrun.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Karl Hill
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 @@
1
+ bbrun
bbrun/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """
2
+ bb-run - Bitbucket Pipelines Local Runner
3
+
4
+ Faithfully runs bitbucket-pipelines.yml locally using Docker or your host environment.
5
+ """
6
+
7
+ from .cli import main
8
+
9
+ __version__ = "0.1.0"
10
+ __author__ = "Karl Hill"
11
+ __license__ = "MIT"
12
+
13
+ __all__ = ['main']
bbrun/cli.py ADDED
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ bb-run CLI - Bitbucket Pipelines Local Runner
4
+ """
5
+
6
+ import sys
7
+ import argparse
8
+ from pathlib import Path
9
+
10
+ from .validator import PipelineValidator
11
+ from .docker import DockerRunner
12
+ from .host import HostRunner
13
+
14
+
15
+ def list_targets(repo_path: Path) -> int:
16
+ """List available pipeline targets."""
17
+ validator = PipelineValidator(repo_path)
18
+ config = validator.load()
19
+
20
+ if not config:
21
+ return 1
22
+
23
+ print("Available pipeline targets:")
24
+ print("\n default")
25
+
26
+ pipelines = config.get('pipelines', {})
27
+
28
+ branches = pipelines.get('branches', {})
29
+ for branch in sorted(branches.keys()):
30
+ print(f" branches.{branch}")
31
+
32
+ tags = pipelines.get('tags', {})
33
+ for tag in sorted(tags.keys()):
34
+ print(f" tags.{tag}")
35
+
36
+ for name in pipelines:
37
+ if name not in ['default', 'branches', 'tags']:
38
+ print(f" {name}")
39
+
40
+ image = config.get('image', 'atlassian/default-image:latest')
41
+ print(f"\nDefault image: {image}")
42
+
43
+ return 0
44
+
45
+
46
+ def run_pipeline(
47
+ repo_path: Path,
48
+ target: str,
49
+ branch: str,
50
+ variables: dict,
51
+ mode: str,
52
+ verbose: bool
53
+ ) -> int:
54
+ """Run a pipeline in the specified mode."""
55
+
56
+ if mode == 'docker':
57
+ runner = DockerRunner(repo_path)
58
+ else:
59
+ runner = HostRunner(repo_path)
60
+
61
+ success = runner.run(
62
+ target=target,
63
+ branch=branch,
64
+ variables=variables,
65
+ verbose=verbose
66
+ )
67
+
68
+ return 0 if success else 1
69
+
70
+
71
+ def validate(repo_path: Path) -> int:
72
+ """Validate a pipeline YAML file."""
73
+ validator = PipelineValidator(repo_path)
74
+
75
+ if validator.validate():
76
+ print("āœ… Valid bitbucket-pipelines.yml")
77
+ validator.show_summary()
78
+ return 0
79
+ else:
80
+ print("āŒ Invalid or missing bitbucket-pipelines.yml")
81
+ return 1
82
+
83
+
84
+ def main():
85
+ parser = argparse.ArgumentParser(
86
+ prog='bb-run',
87
+ description='Run Bitbucket Pipelines locally',
88
+ formatter_class=argparse.RawDescriptionHelpFormatter,
89
+ epilog="""
90
+ Examples:
91
+ bb-run # Run default pipeline
92
+ bb-run --target master # Run master branch pipeline
93
+ bb-run --repo /path/to/repo # Run in specific repo
94
+ bb-run --branch feature-x # Simulate running on a branch
95
+ bb-run --mode host # Run on host (no Docker)
96
+ bb-run --mode docker # Run in Docker (default)
97
+ bb-run -v KEY=VALUE # Pass variables
98
+ bb-run --list-targets # List available targets
99
+ bb-run --validate # Validate YAML only
100
+ """
101
+ )
102
+
103
+ parser.add_argument(
104
+ '--repo', '-r',
105
+ default='.',
106
+ help='Path to repository (default: current directory)'
107
+ )
108
+ parser.add_argument(
109
+ '--target', '-t',
110
+ default='default',
111
+ help='Pipeline target (default: default)'
112
+ )
113
+ parser.add_argument(
114
+ '--branch', '-b',
115
+ default='LOCAL',
116
+ help='Branch name to simulate (default: LOCAL)'
117
+ )
118
+ parser.add_argument(
119
+ '--mode', '-m',
120
+ choices=['docker', 'host'],
121
+ default='docker',
122
+ help='Execution mode (default: docker)'
123
+ )
124
+ parser.add_argument(
125
+ '--variables', '-v',
126
+ action='append',
127
+ help='Variables in KEY=VALUE format'
128
+ )
129
+ parser.add_argument(
130
+ '--list-targets',
131
+ action='store_true',
132
+ help='List available pipeline targets and exit'
133
+ )
134
+ parser.add_argument(
135
+ '--validate',
136
+ action='store_true',
137
+ help='Validate YAML only, do not run'
138
+ )
139
+ parser.add_argument(
140
+ '--verbose',
141
+ action='store_true',
142
+ help='Verbose output'
143
+ )
144
+ parser.add_argument(
145
+ '--version',
146
+ action='version',
147
+ version='%(prog)s 0.1.0'
148
+ )
149
+
150
+ args = parser.parse_args()
151
+
152
+ # Parse variables
153
+ variables = {}
154
+ if args.variables:
155
+ for var in args.variables:
156
+ if '=' in var:
157
+ key, value = var.split('=', 1)
158
+ variables[key] = value
159
+
160
+ repo_path = Path(args.repo).resolve()
161
+
162
+ # Validate repo has a pipeline file
163
+ pipeline_file = repo_path / 'bitbucket-pipelines.yml'
164
+ if not pipeline_file.exists() and not args.list_targets:
165
+ print(f"Error: bitbucket-pipelines.yml not found in {repo_path}")
166
+ return 1
167
+
168
+ # Execute
169
+ if args.list_targets:
170
+ return list_targets(repo_path)
171
+
172
+ if args.validate:
173
+ return validate(repo_path)
174
+
175
+ return run_pipeline(
176
+ repo_path=repo_path,
177
+ target=args.target,
178
+ branch=args.branch,
179
+ variables=variables,
180
+ mode=args.mode,
181
+ verbose=args.verbose
182
+ )
183
+
184
+
185
+ if __name__ == '__main__':
186
+ sys.exit(main())
bbrun/docker.py ADDED
@@ -0,0 +1,224 @@
1
+ """
2
+ Docker Runner - Executes pipeline steps in Docker containers
3
+ """
4
+
5
+ import os
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Dict, List, Optional
9
+
10
+ from .validator import PipelineValidator
11
+
12
+
13
+ class DockerRunner:
14
+ """Runs pipeline steps in Docker containers."""
15
+
16
+ def __init__(self, repo_path: Path):
17
+ self.repo_path = Path(repo_path)
18
+ self.pipeline_file = self.repo_path / "bitbucket-pipelines.yml"
19
+ self.variables = {}
20
+ self.validator = PipelineValidator(repo_path)
21
+
22
+ def _docker_available(self) -> bool:
23
+ """Check if Docker is available."""
24
+ try:
25
+ result = subprocess.run(
26
+ ['docker', 'info'],
27
+ capture_output=True,
28
+ timeout=10
29
+ )
30
+ return result.returncode == 0
31
+ except (subprocess.TimeoutExpired, FileNotFoundError):
32
+ return False
33
+
34
+ def _image_exists(self, image: str) -> bool:
35
+ """Check if Docker image exists locally."""
36
+ result = subprocess.run(
37
+ ['docker', 'image', 'inspect', image],
38
+ capture_output=True
39
+ )
40
+ return result.returncode == 0
41
+
42
+ def _pull_image(self, image: str) -> bool:
43
+ """Pull a Docker image."""
44
+ print(f"Pulling Docker image: {image}")
45
+ result = subprocess.run(
46
+ ['docker', 'pull', image],
47
+ capture_output=True,
48
+ text=True
49
+ )
50
+ if result.returncode != 0:
51
+ print(f"Warning: Could not pull {image}: {result.stderr}")
52
+ return result.returncode == 0
53
+
54
+ def _build_env(self, branch: str) -> Dict[str, str]:
55
+ """Build environment variables for the container."""
56
+ env = dict(os.environ)
57
+
58
+ # Get git commit
59
+ try:
60
+ commit = subprocess.run(
61
+ ['git', 'rev-parse', 'HEAD'],
62
+ capture_output=True,
63
+ text=True,
64
+ cwd=self.repo_path
65
+ ).stdout.strip()
66
+ except Exception:
67
+ commit = 'local'
68
+
69
+ env.update({
70
+ 'BITBUCKET_BUILD_NUMBER': '1',
71
+ 'BITBUCKET_CLONE_DIR': '/opt/atlassian/pipelines/agent/build',
72
+ 'BITBUCKET_COMMIT': commit,
73
+ 'BITBUCKET_BRANCH': branch,
74
+ 'BITBUCKET_REPO_SLUG': self.repo_path.name,
75
+ 'BITBUCKET_REPO_UUID': f'bb-run-{os.getpid()}',
76
+ 'BITBUCKET_WORKSPACE': 'local',
77
+ 'HOME': '/root',
78
+ })
79
+
80
+ # Add user variables
81
+ env.update(self.variables)
82
+
83
+ return env
84
+
85
+ def _run_step(self, step: Dict, step_name: str, default_image: str, env: Dict) -> bool:
86
+ """Execute a single pipeline step in Docker."""
87
+ print(f"\n{'='*60}")
88
+ print(f"Step: {step_name}")
89
+ print(f"{'='*60}")
90
+
91
+ # Resolve image
92
+ image = step.get('image', default_image)
93
+
94
+ # Check/pull image
95
+ if not self._image_exists(image):
96
+ print(f"Image not found locally: {image}")
97
+ if not self._pull_image(image):
98
+ print(f"Failed to pull image {image}")
99
+ return False
100
+
101
+ # Build docker command
102
+ docker_cmd = [
103
+ 'docker', 'run', '--rm',
104
+ '-w', '/opt/atlassian/pipelines/agent/build',
105
+ '-v', f'{self.repo_path}:/opt/atlassian/pipelines/agent/build:rw'
106
+ ]
107
+
108
+ # Add environment variables
109
+ for key, value in env.items():
110
+ docker_cmd.extend(['-e', f'{key}={value}'])
111
+
112
+ docker_cmd.append(image)
113
+
114
+ # Handle script vs pipe
115
+ if 'script' in step:
116
+ script = step['script']
117
+ if isinstance(script, list):
118
+ bash_cmd = ' && '.join(script)
119
+ else:
120
+ bash_cmd = script
121
+
122
+ docker_cmd.extend(['/bin/bash', '-c', bash_cmd])
123
+ print(f"Executing: {bash_cmd[:60]}...")
124
+ elif 'pipe' in step:
125
+ pipe = step['pipe']
126
+ print(f"Pipe: {pipe}")
127
+ print("Note: Pipes are not executed in Docker mode (simplified)")
128
+ return True
129
+ else:
130
+ print("Warning: Step has no script or pipe")
131
+ return True
132
+
133
+ # Run
134
+ result = subprocess.run(
135
+ docker_cmd,
136
+ cwd=self.repo_path,
137
+ env=env
138
+ )
139
+
140
+ if result.returncode != 0:
141
+ print(f"āŒ Failed with exit code {result.returncode}")
142
+ return False
143
+
144
+ return True
145
+
146
+ def run(
147
+ self,
148
+ target: str = 'default',
149
+ branch: str = 'LOCAL',
150
+ variables: Optional[Dict] = None,
151
+ verbose: bool = False
152
+ ) -> bool:
153
+ """Run the pipeline for a given target."""
154
+ if variables:
155
+ self.variables.update(variables)
156
+
157
+ # Check Docker
158
+ if not self._docker_available():
159
+ print("Error: Docker is not available")
160
+ print("Use --mode host to run on your host machine instead")
161
+ return False
162
+
163
+ # Load pipeline
164
+ config = self.validator.load()
165
+ if not config:
166
+ print("Error: Could not load pipeline")
167
+ return False
168
+
169
+ default_image = config.get('image', 'atlassian/default-image:latest')
170
+
171
+ print(f"Repository: {self.repo_path}")
172
+ print(f"Target: {target}")
173
+ print(f"Branch: {branch}")
174
+ print("Mode: DOCKER")
175
+ print(f"Image: {default_image}")
176
+
177
+ # Get steps
178
+ steps = self._get_steps(config, target)
179
+ if not steps:
180
+ print(f"No steps found for target: {target}")
181
+ return False
182
+
183
+ # Run steps
184
+ env = self._build_env(branch)
185
+ all_passed = True
186
+
187
+ for i, item in enumerate(steps):
188
+ step = item.get('step', item)
189
+ step_name = step.get('name', f'Step {i+1}')
190
+
191
+ if not self._run_step(step, step_name, default_image, env):
192
+ all_passed = False
193
+ break
194
+
195
+ if all_passed:
196
+ print(f"\n{'='*60}")
197
+ print("āœ… All steps completed successfully!")
198
+ print(f"{'='*60}")
199
+ else:
200
+ print(f"\n{'='*60}")
201
+ print("āŒ Pipeline failed!")
202
+ print(f"{'='*60}")
203
+
204
+ return all_passed
205
+
206
+ def _get_steps(self, config: Dict, target: str) -> List:
207
+ """Get steps for a given target."""
208
+ pipelines = config.get('pipelines', {})
209
+
210
+ if target == 'default':
211
+ return pipelines.get('default', [])
212
+
213
+ if target.startswith('branches.'):
214
+ branch_name = target.split('.', 1)[1]
215
+ return pipelines.get('branches', {}).get(branch_name, [])
216
+
217
+ if target.startswith('tags.'):
218
+ tag_name = target.split('.', 1)[1]
219
+ return pipelines.get('tags', {}).get(tag_name, [])
220
+
221
+ if target in pipelines:
222
+ return pipelines[target]
223
+
224
+ return []
bbrun/host.py ADDED
@@ -0,0 +1,186 @@
1
+ """
2
+ Host Runner - Executes pipeline steps directly on the host machine
3
+ """
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional
10
+
11
+ from .validator import PipelineValidator
12
+
13
+
14
+ class HostRunner:
15
+ """Runs pipeline steps directly on the host machine."""
16
+
17
+ def __init__(self, repo_path: Path):
18
+ self.repo_path = Path(repo_path)
19
+ self.pipeline_file = self.repo_path / "bitbucket-pipelines.yml"
20
+ self.variables = {}
21
+ self.validator = PipelineValidator(repo_path)
22
+
23
+ def _build_env(self, branch: str) -> Dict[str, str]:
24
+ """Build environment variables."""
25
+ env = dict(os.environ)
26
+
27
+ # Get git commit
28
+ try:
29
+ commit = subprocess.run(
30
+ ['git', 'rev-parse', 'HEAD'],
31
+ capture_output=True,
32
+ text=True,
33
+ cwd=self.repo_path
34
+ ).stdout.strip()
35
+ except Exception:
36
+ commit = 'local'
37
+
38
+ env.update({
39
+ 'BITBUCKET_BUILD_NUMBER': '1',
40
+ 'BITBUCKET_CLONE_DIR': str(self.repo_path),
41
+ 'BITBUCKET_COMMIT': commit,
42
+ 'BITBUCKET_BRANCH': branch,
43
+ 'BITBUCKET_REPO_SLUG': self.repo_path.name,
44
+ 'BITBUCKET_REPO_UUID': f'bb-run-{os.getpid()}',
45
+ 'BITBUCKET_WORKSPACE': 'local',
46
+ })
47
+
48
+ env.update(self.variables)
49
+
50
+ return env
51
+
52
+ def _translate_command(self, cmd: str) -> str:
53
+ """Translate commands for host compatibility."""
54
+ # Translate 'python' to 'python3' if python isn't available
55
+ if not shutil.which('python') and cmd.startswith('python '):
56
+ cmd = 'python3' + cmd[6:]
57
+
58
+ # Translate 'pip ' to 'pip3 ' if pip isn't available
59
+ if not shutil.which('pip') and cmd.startswith('pip ') and not cmd.startswith('pip3 '):
60
+ cmd = 'pip3 ' + cmd[4:]
61
+
62
+ # Add --break-system-packages for PEP 668
63
+ if 'pip3 install' in cmd and '--break-system-packages' not in cmd:
64
+ cmd = cmd.replace('pip3 install', 'pip3 install --break-system-packages')
65
+ print(" (added --break-system-packages for PEP 668)")
66
+
67
+ return cmd
68
+
69
+ def _run_step(self, step: Dict, step_name: str, env: Dict) -> bool:
70
+ """Execute a single pipeline step on the host."""
71
+ print(f"\n{'='*60}")
72
+ print(f"Step: {step_name}")
73
+ print(f"{'='*60}")
74
+
75
+ if 'script' in step:
76
+ return self._run_script(step['script'], env)
77
+ elif 'pipe' in step:
78
+ return self._run_pipe(step)
79
+ else:
80
+ print("Warning: Step has no script or pipe")
81
+ return True
82
+
83
+ def _run_script(self, script: List[str], env: Dict) -> bool:
84
+ """Run a script step."""
85
+ if isinstance(script, list):
86
+ commands = script
87
+ else:
88
+ commands = [script]
89
+
90
+ for cmd in commands:
91
+ translated = self._translate_command(cmd)
92
+ print(f"$ {translated}")
93
+
94
+ result = subprocess.run(
95
+ translated,
96
+ shell=True,
97
+ cwd=self.repo_path,
98
+ env=env
99
+ )
100
+
101
+ if result.returncode != 0:
102
+ print(f"āŒ Failed with exit code {result.returncode}")
103
+ return False
104
+
105
+ return True
106
+
107
+ def _run_pipe(self, step: Dict) -> bool:
108
+ """Handle a pipe step (not executed in host mode)."""
109
+ pipe = step.get('pipe', '')
110
+ print(f"āš ļø Pipe: {pipe}")
111
+ print(" (pipes not executed in host mode)")
112
+ return True
113
+
114
+ def run(
115
+ self,
116
+ target: str = 'default',
117
+ branch: str = 'LOCAL',
118
+ variables: Optional[Dict] = None,
119
+ verbose: bool = False
120
+ ) -> bool:
121
+ """Run the pipeline for a given target."""
122
+ if variables:
123
+ self.variables.update(variables)
124
+
125
+ # Load pipeline
126
+ config = self.validator.load()
127
+ if not config:
128
+ print("Error: Could not load pipeline")
129
+ return False
130
+
131
+ image = config.get('image', 'atlassian/default-image:latest')
132
+
133
+ print(f"Repository: {self.repo_path}")
134
+ print(f"Target: {target}")
135
+ print(f"Branch: {branch}")
136
+ print("Mode: HOST (runs on your machine)")
137
+ print(f"Note: Uses '{image}' as reference for command mapping")
138
+
139
+ # Get steps
140
+ steps = self._get_steps(config, target)
141
+ if not steps:
142
+ print(f"No steps found for target: {target}")
143
+ return False
144
+
145
+ # Run steps
146
+ env = self._build_env(branch)
147
+ all_passed = True
148
+
149
+ for i, item in enumerate(steps):
150
+ step = item.get('step', item)
151
+ step_name = step.get('name', f'Step {i+1}')
152
+
153
+ if not self._run_step(step, step_name, env):
154
+ all_passed = False
155
+ break
156
+
157
+ if all_passed:
158
+ print(f"\n{'='*60}")
159
+ print("āœ… All steps completed successfully!")
160
+ print(f"{'='*60}")
161
+ else:
162
+ print(f"\n{'='*60}")
163
+ print("āŒ Pipeline failed!")
164
+ print(f"{'='*60}")
165
+
166
+ return all_passed
167
+
168
+ def _get_steps(self, config: Dict, target: str) -> List:
169
+ """Get steps for a given target."""
170
+ pipelines = config.get('pipelines', {})
171
+
172
+ if target == 'default':
173
+ return pipelines.get('default', [])
174
+
175
+ if target.startswith('branches.'):
176
+ branch_name = target.split('.', 1)[1]
177
+ return pipelines.get('branches', {}).get(branch_name, [])
178
+
179
+ if target.startswith('tags.'):
180
+ tag_name = target.split('.', 1)[1]
181
+ return pipelines.get('tags', {}).get(tag_name, [])
182
+
183
+ if target in pipelines:
184
+ return pipelines[target]
185
+
186
+ return []
bbrun/validator.py ADDED
@@ -0,0 +1,107 @@
1
+ """
2
+ Pipeline YAML Validator
3
+ """
4
+
5
+ import yaml
6
+ from pathlib import Path
7
+ from typing import Dict, Optional
8
+
9
+
10
+ class PipelineValidator:
11
+ """Validates and parses bitbucket-pipelines.yml"""
12
+
13
+ def __init__(self, repo_path: Path):
14
+ self.repo_path = Path(repo_path)
15
+ self.pipeline_file = self.repo_path / "bitbucket-pipelines.yml"
16
+ self._config: Optional[Dict] = None
17
+
18
+ def load(self) -> Optional[Dict]:
19
+ """Load and parse the pipeline YAML."""
20
+ if not self.pipeline_file.exists():
21
+ return None
22
+
23
+ try:
24
+ with open(self.pipeline_file, 'r') as f:
25
+ self._config = yaml.safe_load(f)
26
+ return self._config
27
+ except yaml.YAMLError as e:
28
+ print(f"YAML parse error: {e}")
29
+ return None
30
+
31
+ def validate(self) -> bool:
32
+ """Validate the pipeline configuration."""
33
+ config = self.load()
34
+
35
+ if not config:
36
+ return False
37
+
38
+ # Check for required 'pipelines' key
39
+ if 'pipelines' not in config:
40
+ print("Error: Missing 'pipelines' key")
41
+ return False
42
+
43
+ return True
44
+
45
+ def show_summary(self) -> None:
46
+ """Print a summary of the pipeline."""
47
+ if not self._config:
48
+ return
49
+
50
+ image = self._config.get('image', 'atlassian/default-image:latest')
51
+ print(f"\nImage: {image}")
52
+
53
+ pipelines = self._config.get('pipelines', {})
54
+
55
+ # Default pipeline
56
+ if 'default' in pipelines:
57
+ print("\nšŸ“¦ default:")
58
+ for item in pipelines['default']:
59
+ self._show_step(item)
60
+
61
+ # Branches
62
+ branches = pipelines.get('branches', {})
63
+ if branches:
64
+ print("\n🌿 branches:")
65
+ for branch, items in branches.items():
66
+ print(f" {branch}:")
67
+ for item in items:
68
+ self._show_step(item, indent=4)
69
+
70
+ # Tags
71
+ tags = pipelines.get('tags', {})
72
+ if tags:
73
+ print("\nšŸ·ļø tags:")
74
+ for tag, items in tags.items():
75
+ print(f" {tag}:")
76
+ for item in items:
77
+ self._show_step(item, indent=4)
78
+
79
+ def _show_step(self, item: Dict, indent: int = 2) -> None:
80
+ """Show details of a single step."""
81
+ step = item.get('step', item)
82
+ name = step.get('name', 'unnamed')
83
+ prefix = " " * indent
84
+
85
+ suffix = ""
86
+ if step.get('deployment'):
87
+ suffix += f" [{step['deployment']}]"
88
+ if step.get('trigger'):
89
+ suffix += f" ({step['trigger']})"
90
+
91
+ print(f"{prefix}• {name}{suffix}")
92
+
93
+ for cmd in step.get('script', []):
94
+ if isinstance(cmd, str):
95
+ display = cmd[:70] + "..." if len(cmd) > 70 else cmd
96
+ print(f"{prefix} → {display}")
97
+ elif isinstance(cmd, dict) and 'pipe' in cmd:
98
+ pipe_name = cmd['pipe']
99
+ vars_str = ""
100
+ if 'variables' in cmd:
101
+ vars_str = f" ({cmd['variables']})"
102
+ print(f"{prefix} → pipe: {pipe_name}{vars_str}")
103
+
104
+ @property
105
+ def config(self) -> Optional[Dict]:
106
+ """Get the loaded configuration."""
107
+ return self._config