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 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
+ [![CI](https://github.com/pietroagazzi/whaler/actions/workflows/ci.yml/badge.svg)](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
+ [![CI](https://github.com/pietroagazzi/whaler/actions/workflows/ci.yml/badge.svg)](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,8 @@
1
+ from . import builtin
2
+ from .logger import setup_logger
3
+ from .stack import Stack
4
+ from .tui import TUI, tui
5
+
6
+ __all__ = ["tui", "TUI", "builtin", "setup_logger", "Stack"]
7
+
8
+ __version__ = "0.1.0"
@@ -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,13 @@
1
+ """Custom exceptions for the Whaler library."""
2
+
3
+
4
+ class WhalerError(Exception):
5
+ """Base exception for all Whaler errors."""
6
+
7
+ pass
8
+
9
+
10
+ class DockerComposeError(WhalerError):
11
+ """Raised when docker-compose command fails."""
12
+
13
+ pass
@@ -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"]