orche 0.1.0__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.
- orche-0.1.0/PKG-INFO +164 -0
- orche-0.1.0/README.md +133 -0
- orche-0.1.0/pyproject.toml +63 -0
- orche-0.1.0/whaler/__init__.py +8 -0
- orche-0.1.0/whaler/builtin.py +78 -0
- orche-0.1.0/whaler/cli.py +162 -0
- orche-0.1.0/whaler/docker.py +130 -0
- orche-0.1.0/whaler/exceptions.py +13 -0
- orche-0.1.0/whaler/logger.py +79 -0
- orche-0.1.0/whaler/stack.py +231 -0
- orche-0.1.0/whaler/tui.py +141 -0
orche-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: orche
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python orchestrator for Docker Compose stacks
|
|
5
|
+
Author: Pietro Agazzi
|
|
6
|
+
Author-email: pietro.agazzi@prconsulting.eu
|
|
7
|
+
Requires-Python: >=3.10,<4
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: GitPython (>=3.1.0)
|
|
16
|
+
Requires-Dist: PyYAML (>=6.0)
|
|
17
|
+
Requires-Dist: mypy (>=1.8.0) ; extra == "dev"
|
|
18
|
+
Requires-Dist: pre-commit (>=4.0.0) ; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest (>=8.0.0) ; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-cov (>=5.0.0) ; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-mock (>=3.14.0) ; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-timeout (>=2.3.0) ; extra == "dev"
|
|
23
|
+
Requires-Dist: python-dotenv (>=1.0.0)
|
|
24
|
+
Requires-Dist: python-on-whales (>=0.80.0,<0.81.0)
|
|
25
|
+
Requires-Dist: rich (>=13.0.0)
|
|
26
|
+
Requires-Dist: ruff (>=0.2.0) ; extra == "dev"
|
|
27
|
+
Requires-Dist: types-PyYAML (>=6.0) ; extra == "dev"
|
|
28
|
+
Requires-Dist: types-pygments (>=2.19.0) ; extra == "dev"
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# Whaler
|
|
32
|
+
|
|
33
|
+
[](https://github.com/pietroagazzi/whaler/actions/workflows/ci.yml)
|
|
34
|
+
|
|
35
|
+
A simple, lightweight Python orchestrator for Docker Compose stacks.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install -e .
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## CLI Reference
|
|
44
|
+
|
|
45
|
+
The `whaler` command executes your `whaler.py` file with the specified command and services.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
whaler [command] [services...]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Commands
|
|
52
|
+
|
|
53
|
+
- `whaler up [services]` - Start services (executes whaler.py with 'up' command)
|
|
54
|
+
- `whaler build [services]` - Build services (executes whaler.py with 'build' command)
|
|
55
|
+
- `whaler down [services]` - Stop services (executes whaler.py with 'down' command)
|
|
56
|
+
|
|
57
|
+
### Options
|
|
58
|
+
|
|
59
|
+
- `-f, --file FILE` - Path to whaler file (default: whaler.py)
|
|
60
|
+
- `-v, --version` - Show version
|
|
61
|
+
- `-h, --help` - Show help
|
|
62
|
+
|
|
63
|
+
### Examples
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# Execute whaler.py with up command
|
|
67
|
+
whaler up
|
|
68
|
+
|
|
69
|
+
# Build specific services
|
|
70
|
+
whaler build api web
|
|
71
|
+
|
|
72
|
+
# Start specific services
|
|
73
|
+
whaler up postgres redis
|
|
74
|
+
|
|
75
|
+
# Use custom whaler file
|
|
76
|
+
whaler -f custom-whaler.py up
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Examples
|
|
80
|
+
|
|
81
|
+
### Basic Usage
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from whaler import Stack
|
|
85
|
+
|
|
86
|
+
stack = Stack("docker-compose.yml")
|
|
87
|
+
stack.build().up()
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### With Project Name
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from whaler import Stack
|
|
94
|
+
|
|
95
|
+
stack = Stack(
|
|
96
|
+
compose_file="docker-compose.yml",
|
|
97
|
+
project_name="myapp",
|
|
98
|
+
project_path="/path/to/project"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
stack.build().up(wait=True)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Specific Services
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from whaler import Stack
|
|
108
|
+
|
|
109
|
+
stack = Stack("docker-compose.yml")
|
|
110
|
+
|
|
111
|
+
# Build specific services
|
|
112
|
+
stack.build(["api", "web"])
|
|
113
|
+
|
|
114
|
+
# Start specific services
|
|
115
|
+
stack.up(["postgres", "redis"])
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Interactive Script
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from whaler import Stack, tui
|
|
122
|
+
|
|
123
|
+
project_name = tui.input("Project name: ", default="myproject")
|
|
124
|
+
environment = tui.input("Environment (dev/prod): ", default="dev")
|
|
125
|
+
|
|
126
|
+
stack = Stack(
|
|
127
|
+
compose_file=f"docker-compose.{environment}.yml",
|
|
128
|
+
project_name=project_name
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
stack.build().up()
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Requirements
|
|
135
|
+
|
|
136
|
+
- Python >= 3.14
|
|
137
|
+
- Docker and Docker Compose installed on the system
|
|
138
|
+
|
|
139
|
+
## Development
|
|
140
|
+
|
|
141
|
+
Install development dependencies:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
pip install -e ".[dev]"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Run tests:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
pytest
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Type checking:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
mypy whaler
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Linting:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
ruff check whaler
|
|
163
|
+
```
|
|
164
|
+
|
orche-0.1.0/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Whaler
|
|
2
|
+
|
|
3
|
+
[](https://github.com/pietroagazzi/whaler/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
A simple, lightweight Python orchestrator for Docker Compose stacks.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -e .
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## CLI Reference
|
|
14
|
+
|
|
15
|
+
The `whaler` command executes your `whaler.py` file with the specified command and services.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
whaler [command] [services...]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Commands
|
|
22
|
+
|
|
23
|
+
- `whaler up [services]` - Start services (executes whaler.py with 'up' command)
|
|
24
|
+
- `whaler build [services]` - Build services (executes whaler.py with 'build' command)
|
|
25
|
+
- `whaler down [services]` - Stop services (executes whaler.py with 'down' command)
|
|
26
|
+
|
|
27
|
+
### Options
|
|
28
|
+
|
|
29
|
+
- `-f, --file FILE` - Path to whaler file (default: whaler.py)
|
|
30
|
+
- `-v, --version` - Show version
|
|
31
|
+
- `-h, --help` - Show help
|
|
32
|
+
|
|
33
|
+
### Examples
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Execute whaler.py with up command
|
|
37
|
+
whaler up
|
|
38
|
+
|
|
39
|
+
# Build specific services
|
|
40
|
+
whaler build api web
|
|
41
|
+
|
|
42
|
+
# Start specific services
|
|
43
|
+
whaler up postgres redis
|
|
44
|
+
|
|
45
|
+
# Use custom whaler file
|
|
46
|
+
whaler -f custom-whaler.py up
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Examples
|
|
50
|
+
|
|
51
|
+
### Basic Usage
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from whaler import Stack
|
|
55
|
+
|
|
56
|
+
stack = Stack("docker-compose.yml")
|
|
57
|
+
stack.build().up()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### With Project Name
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from whaler import Stack
|
|
64
|
+
|
|
65
|
+
stack = Stack(
|
|
66
|
+
compose_file="docker-compose.yml",
|
|
67
|
+
project_name="myapp",
|
|
68
|
+
project_path="/path/to/project"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
stack.build().up(wait=True)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Specific Services
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from whaler import Stack
|
|
78
|
+
|
|
79
|
+
stack = Stack("docker-compose.yml")
|
|
80
|
+
|
|
81
|
+
# Build specific services
|
|
82
|
+
stack.build(["api", "web"])
|
|
83
|
+
|
|
84
|
+
# Start specific services
|
|
85
|
+
stack.up(["postgres", "redis"])
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Interactive Script
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from whaler import Stack, tui
|
|
92
|
+
|
|
93
|
+
project_name = tui.input("Project name: ", default="myproject")
|
|
94
|
+
environment = tui.input("Environment (dev/prod): ", default="dev")
|
|
95
|
+
|
|
96
|
+
stack = Stack(
|
|
97
|
+
compose_file=f"docker-compose.{environment}.yml",
|
|
98
|
+
project_name=project_name
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
stack.build().up()
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Requirements
|
|
105
|
+
|
|
106
|
+
- Python >= 3.14
|
|
107
|
+
- Docker and Docker Compose installed on the system
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
|
|
111
|
+
Install development dependencies:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
pip install -e ".[dev]"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Run tests:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
pytest
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Type checking:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
mypy whaler
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Linting:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
ruff check whaler
|
|
133
|
+
```
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "orche"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A Python orchestrator for Docker Compose stacks"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Pietro Agazzi", email = "pietro.agazzi@prconsulting.eu"}
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.10,<4"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"python-on-whales>=0.80.0,<0.81.0",
|
|
12
|
+
"rich>=13.0.0",
|
|
13
|
+
"python-dotenv>=1.0.0",
|
|
14
|
+
"PyYAML>=6.0",
|
|
15
|
+
"GitPython>=3.1.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
whaler = "whaler.cli:main"
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=8.0.0",
|
|
24
|
+
"pytest-timeout>=2.3.0",
|
|
25
|
+
"pytest-cov>=5.0.0",
|
|
26
|
+
"pytest-mock>=3.14.0",
|
|
27
|
+
"mypy>=1.8.0",
|
|
28
|
+
"ruff>=0.2.0",
|
|
29
|
+
"types-PyYAML>=6.0",
|
|
30
|
+
"types-pygments>=2.19.0",
|
|
31
|
+
"pre-commit>=4.0.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[tool.poetry]
|
|
35
|
+
packages = [{include = "whaler"}]
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
39
|
+
build-backend = "poetry.core.masonry.api"
|
|
40
|
+
|
|
41
|
+
# [tool.pytest.ini_options]
|
|
42
|
+
# minversion = "8.0"
|
|
43
|
+
# addopts = "-ra -q --timeout=300"
|
|
44
|
+
# testpaths = [
|
|
45
|
+
# "tests",
|
|
46
|
+
# ]
|
|
47
|
+
# pythonpath = ["src"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 88
|
|
51
|
+
target-version = "py310"
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
55
|
+
ignore = []
|
|
56
|
+
|
|
57
|
+
[tool.mypy]
|
|
58
|
+
python_version = "3.10"
|
|
59
|
+
warn_return_any = true
|
|
60
|
+
warn_unused_configs = true
|
|
61
|
+
disallow_untyped_defs = true
|
|
62
|
+
check_untyped_defs = true
|
|
63
|
+
files = ["whaler", "tests"]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Built-in utility functions for stack operations."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import git
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from .tui import tui
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def ensure_directory(path: str | Path) -> Path:
|
|
13
|
+
"""Ensure a directory exists, creating it if necessary.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
path: Path to the directory
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
The Path object of the directory
|
|
20
|
+
"""
|
|
21
|
+
p = Path(path)
|
|
22
|
+
if not p.exists():
|
|
23
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
tui.info(f"Created directory: {p}")
|
|
25
|
+
return p
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def git_clone(repo_url: str, dest: str | Path, branch: str | None = None) -> None:
|
|
29
|
+
"""Clone a git repository.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
repo_url: URL of the repository
|
|
33
|
+
dest: Destination path
|
|
34
|
+
branch: Optional specific branch/tag to checkout
|
|
35
|
+
"""
|
|
36
|
+
dest_path = Path(dest)
|
|
37
|
+
if dest_path.exists() and any(dest_path.iterdir()):
|
|
38
|
+
tui.warning(
|
|
39
|
+
f"Destination {dest} already exists and is not empty. Skipping clone."
|
|
40
|
+
)
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
tui.info(f"Cloning {repo_url} into {dest}...")
|
|
44
|
+
try:
|
|
45
|
+
if branch:
|
|
46
|
+
git.Repo.clone_from(repo_url, dest_path, branch=branch)
|
|
47
|
+
else:
|
|
48
|
+
git.Repo.clone_from(repo_url, dest_path)
|
|
49
|
+
tui.success(f"Repository cloned to {dest}")
|
|
50
|
+
except git.exc.GitCommandError as e:
|
|
51
|
+
tui.error(f"Failed to clone repository: {e.stderr}")
|
|
52
|
+
raise
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def read_yaml(path: str | Path) -> Any:
|
|
56
|
+
"""Read and parse a YAML file.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
path: Path to the YAML file
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Parsed YAML content (usually dict or list)
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
FileNotFoundError: If file does not exist
|
|
66
|
+
yaml.YAMLError: If file is not valid YAML
|
|
67
|
+
"""
|
|
68
|
+
p = Path(path)
|
|
69
|
+
if not p.exists():
|
|
70
|
+
tui.error(f"YAML file not found: {p}")
|
|
71
|
+
raise FileNotFoundError(f"File not found: {p}")
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
with open(p, encoding="utf-8") as f:
|
|
75
|
+
return yaml.safe_load(f)
|
|
76
|
+
except yaml.YAMLError as e:
|
|
77
|
+
tui.error(f"Error parsing YAML file {p}: {e}")
|
|
78
|
+
raise
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Command-line interface for Whaler."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal, NoReturn
|
|
8
|
+
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from whaler import __version__
|
|
13
|
+
from whaler.logger import setup_logger
|
|
14
|
+
|
|
15
|
+
CommandName = Literal["up", "build", "down", "stop"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_whaler_file() -> Path:
|
|
19
|
+
"""Find whaler.py in current directory.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Path to whaler.py file
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
FileNotFoundError: If whaler.py is not found
|
|
26
|
+
"""
|
|
27
|
+
whaler_file = Path.cwd() / "whaler.py"
|
|
28
|
+
if not whaler_file.exists():
|
|
29
|
+
raise FileNotFoundError(
|
|
30
|
+
f"whaler.py not found in {Path.cwd()}\n"
|
|
31
|
+
"Make sure you're in a directory with a whaler.py file."
|
|
32
|
+
)
|
|
33
|
+
return whaler_file
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def execute_whaler_file(
|
|
37
|
+
whaler_file: Path,
|
|
38
|
+
command: str,
|
|
39
|
+
services: list[str],
|
|
40
|
+
verbose: bool = False,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Execute whaler.py file with given command.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
whaler_file: Path to whaler.py file
|
|
46
|
+
command: Command to execute (up, build, down, stop)
|
|
47
|
+
services: List of service names
|
|
48
|
+
verbose: Enable verbose/debug logging
|
|
49
|
+
"""
|
|
50
|
+
# Load environment variables from .env file
|
|
51
|
+
load_dotenv()
|
|
52
|
+
|
|
53
|
+
# Setup logging based on verbosity
|
|
54
|
+
setup_logger(verbose=verbose)
|
|
55
|
+
|
|
56
|
+
# Add current working directory to sys.path to allow local imports
|
|
57
|
+
if os.getcwd() not in sys.path:
|
|
58
|
+
sys.path.insert(0, os.getcwd())
|
|
59
|
+
|
|
60
|
+
# Prepare sys.argv for the whaler.py script
|
|
61
|
+
# This allows the script to access command and services via sys.argv
|
|
62
|
+
original_argv = sys.argv.copy()
|
|
63
|
+
sys.argv = ["whaler.py", command] + services
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
# Read and execute the whaler.py file
|
|
67
|
+
with open(whaler_file, encoding="utf-8") as f:
|
|
68
|
+
code = compile(f.read(), str(whaler_file), "exec")
|
|
69
|
+
# Execute in global namespace so imports work correctly
|
|
70
|
+
exec(code, {"__name__": "__main__", "__file__": str(whaler_file)})
|
|
71
|
+
finally:
|
|
72
|
+
# Restore original sys.argv
|
|
73
|
+
sys.argv = original_argv
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main() -> NoReturn:
|
|
77
|
+
"""Main CLI entry point."""
|
|
78
|
+
parser = argparse.ArgumentParser(
|
|
79
|
+
description="Whaler - Docker Compose Stack Orchestrator",
|
|
80
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
81
|
+
epilog="""
|
|
82
|
+
Examples:
|
|
83
|
+
whaler up Execute whaler.py with 'up' command
|
|
84
|
+
whaler up api web Execute whaler.py with 'up' command for specific services
|
|
85
|
+
whaler build Execute whaler.py with 'build' command
|
|
86
|
+
whaler down Execute whaler.py with 'down' command
|
|
87
|
+
whaler stop Execute whaler.py with 'stop' command
|
|
88
|
+
whaler -v up Execute with verbose/debug logging
|
|
89
|
+
|
|
90
|
+
The whaler.py file in the current directory will be executed with the
|
|
91
|
+
specified command and services available via sys.argv.
|
|
92
|
+
""",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"command",
|
|
97
|
+
choices=["up", "build", "down", "stop"],
|
|
98
|
+
help="Command to execute (up, build, down, or stop)",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"services",
|
|
103
|
+
nargs="*",
|
|
104
|
+
default=[],
|
|
105
|
+
help="Optional service names to operate on",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"-f",
|
|
110
|
+
"--file",
|
|
111
|
+
default="whaler.py",
|
|
112
|
+
help="Path to whaler file (default: whaler.py)",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"-v",
|
|
117
|
+
"--verbose",
|
|
118
|
+
action="store_true",
|
|
119
|
+
help="Enable verbose/debug logging",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
parser.add_argument(
|
|
123
|
+
"--version",
|
|
124
|
+
action="version",
|
|
125
|
+
version=f"whaler {__version__}",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
args = parser.parse_args()
|
|
129
|
+
|
|
130
|
+
error_console = Console(stderr=True)
|
|
131
|
+
|
|
132
|
+
# Find whaler.py file
|
|
133
|
+
try:
|
|
134
|
+
if args.file == "whaler.py":
|
|
135
|
+
whaler_file = find_whaler_file()
|
|
136
|
+
else:
|
|
137
|
+
whaler_file = Path(args.file)
|
|
138
|
+
if not whaler_file.exists():
|
|
139
|
+
error_console.print(f"[red]Error: File not found: {whaler_file}[/red]")
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
except FileNotFoundError as e:
|
|
142
|
+
error_console.print(f"[red]Error: {e}[/red]")
|
|
143
|
+
sys.exit(1)
|
|
144
|
+
|
|
145
|
+
# Execute whaler.py with command
|
|
146
|
+
try:
|
|
147
|
+
execute_whaler_file(
|
|
148
|
+
whaler_file, args.command, args.services, verbose=args.verbose
|
|
149
|
+
)
|
|
150
|
+
sys.exit(0)
|
|
151
|
+
except KeyboardInterrupt:
|
|
152
|
+
error_console.print("\n[yellow]Interrupted by user[/yellow]")
|
|
153
|
+
sys.exit(130)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
error_console.print(f"[red]Error executing whaler.py: {e}[/red]")
|
|
156
|
+
if args.verbose:
|
|
157
|
+
error_console.print_exception()
|
|
158
|
+
sys.exit(1)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
main()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Docker abstraction layer for docker-compose operations."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from python_on_whales import DockerClient, DockerException
|
|
7
|
+
|
|
8
|
+
from .exceptions import DockerComposeError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DockerComposeWrapper:
|
|
12
|
+
"""Abstraction layer for docker-compose operations."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
compose_file: Path,
|
|
17
|
+
project_name: str | None = None,
|
|
18
|
+
project_path: Path | None = None,
|
|
19
|
+
):
|
|
20
|
+
"""Initialize Docker Compose wrapper.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
compose_file: Path to docker-compose.yml file
|
|
24
|
+
project_name: Optional project name (defaults to directory name)
|
|
25
|
+
project_path: Optional project path (defaults to compose_file directory)
|
|
26
|
+
"""
|
|
27
|
+
self.compose_file = Path(compose_file)
|
|
28
|
+
self.project_name = project_name
|
|
29
|
+
self.project_path = (
|
|
30
|
+
Path(project_path) if project_path else self.compose_file.parent
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if not shutil.which("docker"):
|
|
34
|
+
raise DockerComposeError(
|
|
35
|
+
"Docker executable not found. Please ensure Docker is "
|
|
36
|
+
"installed and in your PATH."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
self.compose = DockerClient(
|
|
40
|
+
compose_files=[str(self.compose_file)],
|
|
41
|
+
compose_project_name=project_name,
|
|
42
|
+
compose_project_directory=str(self.project_path),
|
|
43
|
+
).compose
|
|
44
|
+
|
|
45
|
+
def build(self, services: list[str] | None = None) -> None:
|
|
46
|
+
"""Build services defined in compose file.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
services: Optional list of specific services to build
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
DockerComposeError: If build command fails
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
self.compose.build(services=services)
|
|
56
|
+
except DockerException as e:
|
|
57
|
+
raise DockerComposeError(f"Build failed: {e}") from e
|
|
58
|
+
except Exception as e:
|
|
59
|
+
raise DockerComposeError(f"Unexpected error during build: {e}") from e
|
|
60
|
+
|
|
61
|
+
def up(
|
|
62
|
+
self,
|
|
63
|
+
services: list[str] | None = None,
|
|
64
|
+
detach: bool = True,
|
|
65
|
+
wait: bool = False,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Start services.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
services: Optional list of specific services to start
|
|
71
|
+
detach: Run containers in background (default: True)
|
|
72
|
+
wait: Wait for services to be running (default: False)
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
DockerComposeError: If up command fails
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
self.compose.up(
|
|
79
|
+
services=services,
|
|
80
|
+
quiet=True,
|
|
81
|
+
detach=detach,
|
|
82
|
+
wait=wait,
|
|
83
|
+
)
|
|
84
|
+
except DockerException as e:
|
|
85
|
+
raise DockerComposeError(f"Failed to start services: {e}") from e
|
|
86
|
+
except Exception as e:
|
|
87
|
+
raise DockerComposeError(f"Unexpected error during up: {e}") from e
|
|
88
|
+
|
|
89
|
+
def down(
|
|
90
|
+
self,
|
|
91
|
+
services: list[str] | None = None,
|
|
92
|
+
remove_orphans: bool = True,
|
|
93
|
+
volumes: bool = False,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Stop and remove services.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
services: Optional list of specific services to stop and remove
|
|
99
|
+
remove_orphans: Remove containers for services not in compose file
|
|
100
|
+
volumes: Remove named volumes declared in the volumes section
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
DockerComposeError: If down command fails
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
if services:
|
|
107
|
+
self.compose.stop(services)
|
|
108
|
+
self.compose.rm(services, stop=True, volumes=volumes)
|
|
109
|
+
else:
|
|
110
|
+
self.compose.down(remove_orphans=remove_orphans, volumes=volumes)
|
|
111
|
+
except DockerException as e:
|
|
112
|
+
raise DockerComposeError(f"Failed to stop services: {e}") from e
|
|
113
|
+
except Exception as e:
|
|
114
|
+
raise DockerComposeError(f"Unexpected error during down: {e}") from e
|
|
115
|
+
|
|
116
|
+
def stop(self, services: list[str] | None = None) -> None:
|
|
117
|
+
"""Stop services without removing them.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
services: Optional list of specific services to stop
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
DockerComposeError: If stop command fails
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
self.compose.stop(services=services)
|
|
127
|
+
except DockerException as e:
|
|
128
|
+
raise DockerComposeError(f"Failed to stop services: {e}") from e
|
|
129
|
+
except Exception as e:
|
|
130
|
+
raise DockerComposeError(f"Unexpected error during stop: {e}") from e
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Logging configuration for Whaler."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from logging.handlers import RotatingFileHandler
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from rich.logging import RichHandler
|
|
9
|
+
|
|
10
|
+
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def setup_logger(
|
|
14
|
+
name: str | None = None,
|
|
15
|
+
verbose: bool = False,
|
|
16
|
+
) -> logging.Logger:
|
|
17
|
+
"""Setup and configure logger.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
name: Logger name (None for root logger)
|
|
21
|
+
verbose: Whether to enable verbose logging to console
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Configured logger instance
|
|
25
|
+
"""
|
|
26
|
+
logger = logging.getLogger(name)
|
|
27
|
+
logger.setLevel(logging.DEBUG) # Capture everything at logger level
|
|
28
|
+
|
|
29
|
+
# Avoid adding handlers multiple times
|
|
30
|
+
if logger.handlers:
|
|
31
|
+
return logger
|
|
32
|
+
|
|
33
|
+
# Rotating file handler
|
|
34
|
+
# Ensure log directory exists
|
|
35
|
+
log_dir = Path.cwd() / ".whaler" / "logs"
|
|
36
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
log_file = log_dir / "whaler.log"
|
|
38
|
+
|
|
39
|
+
file_handler = RotatingFileHandler(
|
|
40
|
+
log_file,
|
|
41
|
+
maxBytes=5 * 1024 * 1024, # 5 MB
|
|
42
|
+
backupCount=3,
|
|
43
|
+
encoding="utf-8",
|
|
44
|
+
)
|
|
45
|
+
file_handler.setLevel(logging.DEBUG)
|
|
46
|
+
file_formatter = logging.Formatter(
|
|
47
|
+
"%(asctime)s | %(name)s | %(levelname)s | %(message)s",
|
|
48
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
49
|
+
)
|
|
50
|
+
file_handler.setFormatter(file_formatter)
|
|
51
|
+
logger.addHandler(file_handler)
|
|
52
|
+
|
|
53
|
+
# Console handler in verbose mode
|
|
54
|
+
if verbose:
|
|
55
|
+
console_handler = RichHandler(
|
|
56
|
+
rich_tracebacks=True,
|
|
57
|
+
show_time=True,
|
|
58
|
+
show_path=False,
|
|
59
|
+
markup=True,
|
|
60
|
+
)
|
|
61
|
+
console_handler.setLevel(logging.DEBUG)
|
|
62
|
+
logger.addHandler(console_handler)
|
|
63
|
+
else:
|
|
64
|
+
# If not verbose, don't log to console at all
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
return logger
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_logger(name: str = "whaler") -> logging.Logger:
|
|
71
|
+
"""Get logger instance.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
name: Logger name
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Logger instance
|
|
78
|
+
"""
|
|
79
|
+
return logging.getLogger(name)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Main Stack class for orchestrating Docker Compose stacks."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Generic, Literal, TypeVar
|
|
7
|
+
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
|
|
10
|
+
from .docker import DockerComposeWrapper
|
|
11
|
+
from .logger import get_logger
|
|
12
|
+
|
|
13
|
+
CommandType = Literal["up", "build", "down", "stop"]
|
|
14
|
+
T = TypeVar("T", bound=Callable[[], None])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CommandRegistry(Generic[T]):
|
|
18
|
+
"""Registry for stack commands."""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._commands: dict[str, Callable[[], None]] = {}
|
|
22
|
+
|
|
23
|
+
def register(self, name: str) -> Callable[[T], T]:
|
|
24
|
+
"""Decorator to register a command."""
|
|
25
|
+
|
|
26
|
+
def decorator(func: T) -> T:
|
|
27
|
+
self._commands[name] = func # type: ignore[assignment]
|
|
28
|
+
return func
|
|
29
|
+
|
|
30
|
+
return decorator
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def up(self) -> Callable[[T], T]:
|
|
34
|
+
"""Decorator for the 'up' command."""
|
|
35
|
+
return self.register("up")
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def down(self) -> Callable[[T], T]:
|
|
39
|
+
"""Decorator for the 'down' command."""
|
|
40
|
+
return self.register("down")
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def build(self) -> Callable[[T], T]:
|
|
44
|
+
"""Decorator for the 'build' command."""
|
|
45
|
+
return self.register("build")
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def stop(self) -> Callable[[T], T]:
|
|
49
|
+
"""Decorator for the 'stop' command."""
|
|
50
|
+
return self.register("stop")
|
|
51
|
+
|
|
52
|
+
def get(self, name: str) -> Callable[[], None] | None:
|
|
53
|
+
"""Get a registered command handler."""
|
|
54
|
+
return self._commands.get(name)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Stack:
|
|
58
|
+
"""Main orchestrator for Docker Compose stacks."""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
name: str | None = None,
|
|
63
|
+
path: str | Path = ".",
|
|
64
|
+
compose_file: str | Path = "docker-compose.yml",
|
|
65
|
+
load_env: bool = True,
|
|
66
|
+
):
|
|
67
|
+
"""Initialize a Docker Compose stack.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
name: Optional project name (defaults to directory name)
|
|
71
|
+
path: Project root path (defaults to current directory)
|
|
72
|
+
compose_file: Path to docker-compose.yml file (relative to path)
|
|
73
|
+
load_env: Whether to load .env file from project path
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
FileNotFoundError: If compose_file does not exist
|
|
77
|
+
"""
|
|
78
|
+
self.project_path = Path(path).resolve()
|
|
79
|
+
self.compose_file = self.project_path / compose_file
|
|
80
|
+
self.project_name = name
|
|
81
|
+
self.logger = get_logger()
|
|
82
|
+
|
|
83
|
+
# Load .env file if it exists
|
|
84
|
+
if load_env:
|
|
85
|
+
env_file = self.project_path / ".env"
|
|
86
|
+
if env_file.exists():
|
|
87
|
+
load_dotenv(env_file)
|
|
88
|
+
self.logger.debug(f"Loaded environment from {env_file}")
|
|
89
|
+
|
|
90
|
+
if not self.compose_file.exists():
|
|
91
|
+
raise FileNotFoundError(
|
|
92
|
+
f"Docker Compose file not found: {self.compose_file}\n"
|
|
93
|
+
f"Please ensure the file exists or provide the correct path."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Initialize Docker wrapper
|
|
97
|
+
self._docker = DockerComposeWrapper(
|
|
98
|
+
compose_file=self.compose_file,
|
|
99
|
+
project_name=self.project_name,
|
|
100
|
+
project_path=self.project_path,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Command registry
|
|
104
|
+
self.commands: CommandRegistry[Callable[[], None]] = CommandRegistry()
|
|
105
|
+
|
|
106
|
+
# Runtime context
|
|
107
|
+
self._active_services: list[str] = []
|
|
108
|
+
|
|
109
|
+
def active(self, service: str) -> bool:
|
|
110
|
+
"""Check if a service is active in the current execution context.
|
|
111
|
+
|
|
112
|
+
If no specific services were requested (empty list),
|
|
113
|
+
all services are considered active.
|
|
114
|
+
"""
|
|
115
|
+
if not self._active_services:
|
|
116
|
+
return True
|
|
117
|
+
return service in self._active_services
|
|
118
|
+
|
|
119
|
+
def build(self, services: list[str] | None = None) -> "Stack":
|
|
120
|
+
"""Build services in the stack.
|
|
121
|
+
|
|
122
|
+
If 'services' is not provided, uses the active services from CLI args.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
services: Optional list of specific services to build
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Self for method chaining
|
|
129
|
+
"""
|
|
130
|
+
target_services = services if services is not None else self._active_services
|
|
131
|
+
if target_services:
|
|
132
|
+
self.logger.info(f"Building services: {', '.join(target_services)}")
|
|
133
|
+
else:
|
|
134
|
+
self.logger.info("Building all services")
|
|
135
|
+
self._docker.build(services=target_services if target_services else None)
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
def up(self, services: list[str] | None = None, wait: bool = True) -> "Stack":
|
|
139
|
+
"""Start services in the stack.
|
|
140
|
+
|
|
141
|
+
If 'services' is not provided, uses the active services from CLI args.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
services: Optional list of specific services to start
|
|
145
|
+
wait: If True, wait for services to be running
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Self for method chaining
|
|
149
|
+
"""
|
|
150
|
+
target_services = services if services is not None else self._active_services
|
|
151
|
+
if target_services:
|
|
152
|
+
self.logger.info(f"Starting services: {', '.join(target_services)}")
|
|
153
|
+
else:
|
|
154
|
+
self.logger.info("Starting all services")
|
|
155
|
+
self._docker.up(
|
|
156
|
+
services=target_services if target_services else None, wait=wait
|
|
157
|
+
)
|
|
158
|
+
if wait:
|
|
159
|
+
self.logger.info("Services are ready")
|
|
160
|
+
return self
|
|
161
|
+
|
|
162
|
+
def down(self, services: list[str] | None = None, volumes: bool = False) -> "Stack":
|
|
163
|
+
"""Stop and remove services in the stack.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
services: Optional list of specific services to stop and remove
|
|
167
|
+
volumes: Whether to remove named volumes
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Self for method chaining
|
|
171
|
+
"""
|
|
172
|
+
target_services = services if services is not None else self._active_services
|
|
173
|
+
if target_services:
|
|
174
|
+
self.logger.info(
|
|
175
|
+
f"Stopping and removing services: {', '.join(target_services)}"
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
self.logger.info("Stopping and removing all services")
|
|
179
|
+
|
|
180
|
+
self._docker.down(
|
|
181
|
+
services=target_services if target_services else None, volumes=volumes
|
|
182
|
+
)
|
|
183
|
+
return self
|
|
184
|
+
|
|
185
|
+
def stop(self, services: list[str] | None = None) -> "Stack":
|
|
186
|
+
"""Stop services without removing them.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
services: Optional list of specific services to stop
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Self for method chaining
|
|
193
|
+
"""
|
|
194
|
+
target_services = services if services is not None else self._active_services
|
|
195
|
+
if target_services:
|
|
196
|
+
self.logger.info(f"Stopping services: {', '.join(target_services)}")
|
|
197
|
+
else:
|
|
198
|
+
self.logger.info("Stopping all services")
|
|
199
|
+
self._docker.stop(services=target_services if target_services else None)
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
def run(self) -> None:
|
|
203
|
+
"""Parse CLI arguments and execute the requested command."""
|
|
204
|
+
if len(sys.argv) < 2:
|
|
205
|
+
self.logger.info(f"Stack: {self.project_name or 'Whaler'}")
|
|
206
|
+
self.logger.info("\nUsage: python whaler.py <command> [services...]")
|
|
207
|
+
self.logger.info("\nAvailable commands:")
|
|
208
|
+
for cmd in self.commands._commands:
|
|
209
|
+
self.logger.info(f" - {cmd}")
|
|
210
|
+
sys.exit(1)
|
|
211
|
+
|
|
212
|
+
command_name = sys.argv[1]
|
|
213
|
+
self._active_services = sys.argv[2:]
|
|
214
|
+
|
|
215
|
+
handler = self.commands.get(command_name)
|
|
216
|
+
if not handler:
|
|
217
|
+
self.logger.error(f"Unknown command '{command_name}'")
|
|
218
|
+
self.logger.info(
|
|
219
|
+
f"Available commands: {', '.join(self.commands._commands.keys())}"
|
|
220
|
+
)
|
|
221
|
+
sys.exit(1)
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
handler()
|
|
225
|
+
except KeyboardInterrupt:
|
|
226
|
+
self.logger.warning("\nInterrupted by user")
|
|
227
|
+
sys.exit(130)
|
|
228
|
+
except Exception as e:
|
|
229
|
+
self.logger.error(f"Command failed: {e}")
|
|
230
|
+
self.logger.debug("Exception details:", exc_info=True)
|
|
231
|
+
sys.exit(1)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""User interface utilities for interactive input."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.prompt import Confirm, Prompt
|
|
9
|
+
from rich.status import Status
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TUI:
|
|
13
|
+
"""Terminal User Interface for whaler CLI.
|
|
14
|
+
|
|
15
|
+
Provides colored output, prompts, and interactive input with
|
|
16
|
+
shared console instances across the application.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
console: Console | None = None,
|
|
22
|
+
error_console: Console | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Initialize TUI with consoles.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
console: Rich Console for stdout (created if None)
|
|
28
|
+
error_console: Rich Console for stderr (created if None)
|
|
29
|
+
"""
|
|
30
|
+
self._console = console or Console()
|
|
31
|
+
self._error_console = error_console or Console(stderr=True)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def console(self) -> Console:
|
|
35
|
+
"""Get the stdout console instance.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Rich Console for stdout
|
|
39
|
+
"""
|
|
40
|
+
return self._console
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def error_console(self) -> Console:
|
|
44
|
+
"""Get the stderr console instance.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Rich Console for stderr
|
|
48
|
+
"""
|
|
49
|
+
return self._error_console
|
|
50
|
+
|
|
51
|
+
def status(self, message: str, spinner: str = "dots") -> Status:
|
|
52
|
+
"""Create a status spinner context manager.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
message: Message to display next to spinner
|
|
56
|
+
spinner: Name of spinner animation to use
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Rich Status context manager
|
|
60
|
+
"""
|
|
61
|
+
return self._console.status(message, spinner=spinner)
|
|
62
|
+
|
|
63
|
+
def input(self, prompt: str = "", default: str | None = None) -> str:
|
|
64
|
+
"""Interactive input function with optional default.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
prompt: Prompt message to display
|
|
68
|
+
default: Default value if user enters nothing
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
User input or default value
|
|
72
|
+
"""
|
|
73
|
+
return cast(
|
|
74
|
+
str,
|
|
75
|
+
Prompt.ask(
|
|
76
|
+
prompt,
|
|
77
|
+
default=default,
|
|
78
|
+
show_default=True if default is not None else False,
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def confirm(self, prompt: str, default: bool = False) -> bool:
|
|
83
|
+
"""Ask user for yes/no confirmation.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
prompt: Confirmation prompt message
|
|
87
|
+
default: Default value if user presses Enter
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
True if user confirms, False otherwise
|
|
91
|
+
"""
|
|
92
|
+
return cast(bool, Confirm.ask(prompt, default=default))
|
|
93
|
+
|
|
94
|
+
def secret_input(self, prompt: str = "Password") -> str:
|
|
95
|
+
"""Get secret input (password) without echoing to screen.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
prompt: Prompt message to display
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
User's secret input
|
|
102
|
+
"""
|
|
103
|
+
return cast(str, Prompt.ask(prompt, password=True))
|
|
104
|
+
|
|
105
|
+
def info(self, message: str) -> None:
|
|
106
|
+
"""Print info message with color.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
message: Message to display
|
|
110
|
+
"""
|
|
111
|
+
self._console.print(f"[cyan][>][/cyan] {message}")
|
|
112
|
+
|
|
113
|
+
def success(self, message: str) -> None:
|
|
114
|
+
"""Print success message with color.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
message: Success message to display
|
|
118
|
+
"""
|
|
119
|
+
self._console.print(f"\n[green][+][/green] {message}\n")
|
|
120
|
+
|
|
121
|
+
def error(self, message: str) -> None:
|
|
122
|
+
"""Print error message with color.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
message: Error message to display
|
|
126
|
+
"""
|
|
127
|
+
self._error_console.print(f"[red][X] {message}[/red]")
|
|
128
|
+
|
|
129
|
+
def warning(self, message: str) -> None:
|
|
130
|
+
"""Print warning message with color.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
message: Warning message to display
|
|
134
|
+
"""
|
|
135
|
+
self._console.print(f"[yellow][!][/yellow] {message}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# Module-level singleton instance
|
|
139
|
+
tui = TUI()
|
|
140
|
+
|
|
141
|
+
__all__ = ["TUI", "tui"]
|