diff --git a/devservices/commands/list_dependencies.py b/devservices/commands/list_dependencies.py index b6a1510..c995218 100644 --- a/devservices/commands/list_dependencies.py +++ b/devservices/commands/list_dependencies.py @@ -4,6 +4,7 @@ from argparse import ArgumentParser from argparse import Namespace +from devservices.utils.console import Console from devservices.utils.services import find_matching_service @@ -22,20 +23,21 @@ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None: def list_dependencies(args: Namespace) -> None: """List the dependencies of a service.""" + console = Console() service_name = args.service_name try: service = find_matching_service(service_name) except Exception as e: - print(e) + console.failure(str(e)) exit(1) dependencies = service.config.dependencies if not dependencies: - print(f"No dependencies found for {service.name}") + console.info(f"No dependencies found for {service.name}") return - print(f"Dependencies of {service.name}:") + console.info(f"Dependencies of {service.name}:") for dependency_key, dependency_info in dependencies.items(): - print("-", dependency_key, ":", dependency_info.description) + console.info("-" + dependency_key + ":" + dependency_info.description) diff --git a/devservices/commands/list_services.py b/devservices/commands/list_services.py index 823492c..43fe2b6 100644 --- a/devservices/commands/list_services.py +++ b/devservices/commands/list_services.py @@ -4,6 +4,7 @@ from argparse import ArgumentParser from argparse import Namespace +from devservices.utils.console import Console from devservices.utils.devenv import get_coderoot from devservices.utils.services import get_local_services from devservices.utils.state import State @@ -24,7 +25,7 @@ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None: def list_services(args: Namespace) -> None: """List the services installed locally.""" - + console = Console() # Get all of the services installed locally coderoot = get_coderoot() services = get_local_services(coderoot) @@ -32,7 +33,7 @@ def list_services(args: Namespace) -> None: running_services = state.get_started_services() if not services: - print("No services found") + console.warning("No services found") return services_to_show = ( @@ -40,19 +41,19 @@ def list_services(args: Namespace) -> None: ) if args.all: - print("Services installed locally:") + console.info("Services installed locally:") else: - print("Running services:") + console.info("Running services:") for service in services_to_show: status = "running" if service.name in running_services else "stopped" - print(f"- {service.name}") - print(f" status: {status}") - print(f" location: {service.repo_path}") + console.info(f"- {service.name}") + console.info(f" status: {status}") + console.info(f" location: {service.repo_path}") if not args.all: stopped_count = len(services) - len(services_to_show) if stopped_count > 0: - print( + console.info( f"\n{stopped_count} stopped service(s) not shown. Use --all/-a to see them." ) diff --git a/devservices/commands/logs.py b/devservices/commands/logs.py index 01aae88..bd9795b 100644 --- a/devservices/commands/logs.py +++ b/devservices/commands/logs.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys from argparse import _SubParsersAction from argparse import ArgumentParser from argparse import Namespace @@ -8,6 +7,7 @@ from devservices.constants import MAX_LOG_LINES from devservices.exceptions import DependencyError from devservices.exceptions import DockerComposeError +from devservices.utils.console import Console from devservices.utils.docker_compose import run_docker_compose_command from devservices.utils.services import find_matching_service from devservices.utils.state import State @@ -26,11 +26,12 @@ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None: def logs(args: Namespace) -> None: """View the logs for a specified service.""" + console = Console() service_name = args.service_name try: service = find_matching_service(service_name) except Exception as e: - print(e) + console.failure(str(e)) exit(1) modes = service.config.modes @@ -41,7 +42,7 @@ def logs(args: Namespace) -> None: state = State() running_services = state.get_started_services() if service_name not in running_services: - print(f"Service {service_name} is not running") + console.warning(f"Service {service_name} is not running") return try: @@ -49,11 +50,10 @@ def logs(args: Namespace) -> None: service, "logs", mode_dependencies, options=["-n", MAX_LOG_LINES] ) except DependencyError as de: - print(str(de)) + console.failure(str(de)) exit(1) except DockerComposeError as dce: - print(f"Failed to get logs for {service.name}: {dce.stderr}") + console.failure(f"Failed to get logs for {service.name}: {dce.stderr}") exit(1) for log in logs_output: - sys.stdout.write(log.stdout) - sys.stdout.flush() + console.info(log.stdout) diff --git a/devservices/commands/purge.py b/devservices/commands/purge.py index 6425070..8a5053d 100644 --- a/devservices/commands/purge.py +++ b/devservices/commands/purge.py @@ -7,6 +7,7 @@ from argparse import Namespace from devservices.constants import DEVSERVICES_CACHE_DIR +from devservices.utils.console import Console def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None: @@ -16,10 +17,11 @@ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None: def purge(args: Namespace) -> None: """Purge the local devservices cache.""" + console = Console() if os.path.exists(DEVSERVICES_CACHE_DIR): try: shutil.rmtree(DEVSERVICES_CACHE_DIR) except PermissionError as e: - print(f"Failed to purge cache: {e}") + console.failure(f"Failed to purge cache: {e}") exit(1) - print("The local devservices cache has been purged") + console.success("The local devservices cache has been purged") diff --git a/devservices/commands/start.py b/devservices/commands/start.py index 60f1914..d25ba60 100644 --- a/devservices/commands/start.py +++ b/devservices/commands/start.py @@ -6,6 +6,7 @@ from devservices.exceptions import DependencyError from devservices.exceptions import DockerComposeError +from devservices.utils.console import Console from devservices.utils.console import Status from devservices.utils.docker_compose import run_docker_compose_command from devservices.utils.services import find_matching_service @@ -22,11 +23,12 @@ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None: def start(args: Namespace) -> None: """Start a service and its dependencies.""" + console = Console() service_name = args.service_name try: service = find_matching_service(service_name) except Exception as e: - print(e) + console.failure(str(e)) exit(1) modes = service.config.modes @@ -34,7 +36,10 @@ def start(args: Namespace) -> None: mode_to_start = "default" mode_dependencies = modes[mode_to_start] - with Status(f"Starting {service.name}", f"{service.name} started") as status: + with Status( + lambda: console.warning(f"Starting {service.name}"), + lambda: console.success(f"{service.name} started"), + ) as status: try: run_docker_compose_command( service, @@ -44,10 +49,10 @@ def start(args: Namespace) -> None: force_update_dependencies=True, ) except DependencyError as de: - status.print(str(de)) + status.failure(str(de)) exit(1) except DockerComposeError as dce: - status.print(f"Failed to start {service.name}: {dce.stderr}") + status.failure(f"Failed to start {service.name}: {dce.stderr}") exit(1) # TODO: We should factor in healthchecks here before marking service as running state = State() diff --git a/devservices/commands/status.py b/devservices/commands/status.py index 850483a..9643005 100644 --- a/devservices/commands/status.py +++ b/devservices/commands/status.py @@ -1,13 +1,13 @@ from __future__ import annotations import json -import sys from argparse import _SubParsersAction from argparse import ArgumentParser from argparse import Namespace from devservices.exceptions import DependencyError from devservices.exceptions import DockerComposeError +from devservices.utils.console import Console from devservices.utils.docker_compose import run_docker_compose_command from devservices.utils.services import find_matching_service @@ -61,11 +61,12 @@ def format_status_output(status_json: str) -> str: def status(args: Namespace) -> None: """Start a service and its dependencies.""" + console = Console() service_name = args.service_name try: service = find_matching_service(service_name) except Exception as e: - print(e) + console.failure(str(e)) exit(1) modes = service.config.modes @@ -78,10 +79,10 @@ def status(args: Namespace) -> None: service, "ps", mode_dependencies, options=["--format", "json"] ) except DependencyError as de: - print(str(de)) + console.failure(str(de)) exit(1) except DockerComposeError as dce: - print(f"Failed to get status for {service.name}: {dce.stderr}") + console.failure(f"Failed to get status for {service.name}: {dce.stderr}") exit(1) # Filter out empty stdout to help us determine if the service is running @@ -89,11 +90,10 @@ def status(args: Namespace) -> None: status_json for status_json in status_json_results if status_json.stdout ] if len(status_json_results) == 0: - print(f"{service.name} is not running") + console.warning(f"{service.name} is not running") return output = f"Service: {service.name}\n\n" for status_json in status_json_results: output += format_status_output(status_json.stdout) output += "=" * LINE_LENGTH - sys.stdout.write(output + "\n") - sys.stdout.flush() + console.info(output + "\n") diff --git a/devservices/commands/stop.py b/devservices/commands/stop.py index 4c0fbdc..1e139c5 100644 --- a/devservices/commands/stop.py +++ b/devservices/commands/stop.py @@ -6,6 +6,7 @@ from devservices.exceptions import DependencyError from devservices.exceptions import DockerComposeError +from devservices.utils.console import Console from devservices.utils.console import Status from devservices.utils.docker_compose import run_docker_compose_command from devservices.utils.services import find_matching_service @@ -22,11 +23,12 @@ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None: def stop(args: Namespace) -> None: """Stop a service and its dependencies.""" + console = Console() service_name = args.service_name try: service = find_matching_service(service_name) except Exception as e: - print(e) + console.failure(str(e)) exit(1) modes = service.config.modes @@ -36,17 +38,20 @@ def stop(args: Namespace) -> None: state = State() started_services = state.get_started_services() if service.name not in started_services: - print(f"{service.name} is not running") + console.warning(f"{service.name} is not running") exit(0) - with Status(f"Stopping {service.name}", f"{service.name} stopped") as status: + with Status( + lambda: console.warning(f"Stopping {service.name}"), + lambda: console.success(f"{service.name} stopped"), + ) as status: try: run_docker_compose_command(service, "down", mode_dependencies) except DependencyError as de: - status.print(str(de)) + status.failure(str(de)) exit(1) except DockerComposeError as dce: - status.print(f"Failed to stop {service.name}: {dce.stderr}") + status.failure(f"Failed to stop {service.name}: {dce.stderr}") exit(1) # TODO: We should factor in healthchecks here before marking service as stopped diff --git a/devservices/commands/update.py b/devservices/commands/update.py index c866fd7..d7d2d2a 100644 --- a/devservices/commands/update.py +++ b/devservices/commands/update.py @@ -11,6 +11,7 @@ from devservices.constants import DEVSERVICES_DOWNLOAD_URL from devservices.exceptions import BinaryInstallError from devservices.exceptions import DevservicesUpdateError +from devservices.utils.console import Console from devservices.utils.install_binary import install_binary @@ -21,14 +22,16 @@ def is_in_virtualenv() -> bool: def update_version(exec_path: str, latest_version: str) -> None: + console = Console() system = platform.system().lower() url = f"{DEVSERVICES_DOWNLOAD_URL}/{latest_version}/devservices-{system}" try: install_binary("devservices", exec_path, latest_version, url) except BinaryInstallError as e: - raise DevservicesUpdateError(f"Failed to update devservices: {e}") + console.failure(f"Failed to update devservices: {e}") + exit(1) - print(f"Devservices {latest_version} updated successfully") + console.success(f"Devservices {latest_version} updated successfully") def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None: @@ -39,6 +42,7 @@ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None: def update(args: Namespace) -> None: + console = Console() current_version = metadata.version("devservices") latest_version = check_for_update(current_version) @@ -46,21 +50,21 @@ def update(args: Namespace) -> None: raise DevservicesUpdateError("Failed to check for updates.") if latest_version == current_version: - print("You're already on the latest version.") + console.warning("You're already on the latest version.") return - print(f"A new version of devservices is available: {latest_version}") + console.warning(f"A new version of devservices is available: {latest_version}") if is_in_virtualenv(): - print("You are running in a virtual environment.") - print( + console.warning("You are running in a virtual environment.") + console.warning( "To update, please update your requirements.txt or requirements-dev.txt file with the new version." ) - print( + console.warning( f"For example, update the line in requirements.txt to: devservices=={latest_version}" ) - print("Then, run: pip install --update -r requirements.txt") + console.warning("Then, run: pip install --update -r requirements.txt") return - print("Upgrading to the latest version...") + console.info("Upgrading to the latest version...") update_version(sys.executable, latest_version) diff --git a/devservices/main.py b/devservices/main.py index e62c275..6e33341 100644 --- a/devservices/main.py +++ b/devservices/main.py @@ -19,6 +19,7 @@ from devservices.commands.check_for_update import check_for_update from devservices.exceptions import DockerComposeInstallationError from devservices.exceptions import DockerDaemonNotRunningError +from devservices.utils.console import Console from devservices.utils.docker_compose import check_docker_compose_version sentry_environment = ( @@ -44,13 +45,14 @@ def cleanup() -> None: def main() -> None: + console = Console() try: check_docker_compose_version() except DockerDaemonNotRunningError as e: - print(e) + console.failure(str(e)) exit(1) except DockerComposeInstallationError: - print("Failed to ensure docker compose is installed and up-to-date") + console.failure("Failed to ensure docker compose is installed and up-to-date") exit(1) parser = argparse.ArgumentParser( prog="devservices", @@ -85,10 +87,10 @@ def main() -> None: if args.command != "update": newest_version = check_for_update(metadata.version("devservices")) if newest_version != metadata.version("devservices"): - print( - f"\n\033[93mWARNING: A new version of devservices is available: {newest_version}\033[0m" + console.warning( + f"WARNING: A new version of devservices is available: {newest_version}" ) - print("To update, run: \033[1mdevservices update\033[0m") + console.warning('To update, run: "devservices update"') if __name__ == "__main__": diff --git a/devservices/utils/console.py b/devservices/utils/console.py index 8bd4268..ba6ed87 100644 --- a/devservices/utils/console.py +++ b/devservices/utils/console.py @@ -3,31 +3,83 @@ import sys import threading import time +from collections.abc import Callable from types import TracebackType ANIMATION_FRAMES = ("⠟", "⠯", "⠷", "⠾", "⠽", "⠻") +class Color: + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[0;33m" + BOLD = "\033[1m" + UNDERLINE = "\033[4m" + NEGATIVE = "\033[7m" + RESET = "\033[0m" + + +class Console: + _instance: Console | None = None + + def __new__(cls) -> Console: + if cls._instance is None: + cls._instance = super(Console, cls).__new__(cls) + return cls._instance + + def print(self, message: str, color: str = "", bold: bool = False) -> None: + color = color + (Color.BOLD if bold else "") + end = Color.RESET if color != "" or bold else "" + sys.stdout.write(color + message + end + "\n") + sys.stdout.flush() + + def success(self, message: str, bold: bool = False) -> None: + self.print(message=message, color=Color.GREEN, bold=bold) + + def failure(self, message: str, bold: bool = False) -> None: + self.print(message=message, color=Color.RED, bold=bold) + + def warning(self, message: str, bold: bool = False) -> None: + self.print(message=message, color=Color.YELLOW, bold=bold) + + def info(self, message: str, bold: bool = False) -> None: + self.print(message=message, color="", bold=bold) + + class Status: """Shows loading status in the terminal.""" def __init__( - self, start_message: str | None = None, end_message: str | None = None + self, + on_start: Callable[[], None] | None = None, + on_success: Callable[[], None] | None = None, ) -> None: - self.start_message = start_message - self.end_message = end_message + self.on_start = on_start + self.on_success = on_success self._stop_loading = threading.Event() self._loading_thread = threading.Thread(target=self._loading_animation) - self._exception_occured = False + self._exception_occurred = False + self.console = Console() - def print(self, message: str) -> None: - sys.stdout.write("\r" + message + "\n") - sys.stdout.flush() + def print(self, message: str, color: str = "", bold: bool = False) -> None: + self.console.print("\r" + message, color=color, bold=bold) + + def success(self, message: str, bold: bool = False) -> None: + self.print(message=message, color=Color.GREEN, bold=bold) + + def failure(self, message: str, bold: bool = False) -> None: + self.print(message=message, color=Color.RED, bold=bold) + + def warning(self, message: str, bold: bool = False) -> None: + self.print(message=message, color=Color.YELLOW, bold=bold) + + def info(self, message: str, bold: bool = False) -> None: + self.print(message=message, color="", bold=bold) def start(self) -> None: - if self.start_message: - print(self.start_message) + if self.on_start: + self.on_start() self._loading_thread.start() def stop(self) -> None: @@ -35,8 +87,8 @@ def stop(self) -> None: self._loading_thread.join() sys.stdout.write("\r") sys.stdout.flush() - if self.end_message and not self._exception_occured: - print(self.end_message) + if self.on_success and not self._exception_occurred: + self.on_success() def _loading_animation(self) -> None: idx = 0 @@ -56,7 +108,7 @@ def __exit__( exc_inst: BaseException | None, exc_tb: TracebackType | None, ) -> bool: - self._exception_occured = exc_type is not None + self._exception_occurred = exc_type is not None self.stop() if exc_type: if exc_type in (KeyboardInterrupt,): diff --git a/devservices/utils/docker_compose.py b/devservices/utils/docker_compose.py index 6943202..d6b651c 100644 --- a/devservices/utils/docker_compose.py +++ b/devservices/utils/docker_compose.py @@ -21,6 +21,7 @@ from devservices.exceptions import BinaryInstallError from devservices.exceptions import DockerComposeError from devservices.exceptions import DockerComposeInstallationError +from devservices.utils.console import Console from devservices.utils.dependencies import get_installed_remote_dependencies from devservices.utils.dependencies import get_non_shared_remote_dependencies from devservices.utils.dependencies import install_dependencies @@ -32,6 +33,7 @@ def install_docker_compose() -> None: + console = Console() # Determine the platform system = platform.system() machine = platform.machine() @@ -73,7 +75,7 @@ def install_docker_compose() -> None: except BinaryInstallError as e: raise DockerComposeInstallationError(f"Failed to install Docker Compose: {e}") - print( + console.success( f"Docker Compose {MINIMUM_DOCKER_COMPOSE_VERSION} installed successfully to {destination}" ) @@ -87,10 +89,11 @@ def install_docker_compose() -> None: f"Failed to verify Docker Compose installation: {e}" ) - print(f"Verified Docker Compose installation: v{version}") + console.success(f"Verified Docker Compose installation: v{version}") def check_docker_compose_version() -> None: + console = Console() # Throw an error if docker daemon isn't running check_docker_daemon_running() try: @@ -103,7 +106,7 @@ def check_docker_compose_version() -> None: ) except subprocess.CalledProcessError: result = None - print( + console.warning( f"Docker Compose is not installed, attempting to install v{MINIMUM_DOCKER_COMPOSE_VERSION}" ) @@ -121,13 +124,13 @@ def check_docker_compose_version() -> None: docker_compose_version = None if docker_compose_version is None: - print( + console.warning( f"Unable to detect Docker Compose version, attempting to install v{MINIMUM_DOCKER_COMPOSE_VERSION}" ) elif version.parse(docker_compose_version) != version.parse( MINIMUM_DOCKER_COMPOSE_VERSION ): - print( + console.warning( f"Docker Compose version v{docker_compose_version} unsupported, attempting to install v{MINIMUM_DOCKER_COMPOSE_VERSION}" ) elif version.parse(docker_compose_version) == version.parse( diff --git a/devservices/utils/install_binary.py b/devservices/utils/install_binary.py index e63209f..ce15999 100644 --- a/devservices/utils/install_binary.py +++ b/devservices/utils/install_binary.py @@ -8,6 +8,7 @@ from devservices.constants import BINARY_PERMISSIONS from devservices.exceptions import BinaryInstallError +from devservices.utils.console import Console def install_binary( @@ -16,37 +17,40 @@ def install_binary( version: str, url: str, ) -> None: + console = Console() with tempfile.TemporaryDirectory() as temp_dir: temp_file = os.path.join(temp_dir, binary_name) # Download the binary with retries max_retries = 3 retry_delay_seconds = 1 - print(f"Downloading {binary_name} {version} from {url}...") + console.info(f"Downloading {binary_name} {version} from {url}...") for attempt in range(max_retries): try: urlretrieve(url, temp_file) break except Exception as e: if attempt < max_retries - 1: - print( + console.warning( f"Download failed. Retrying in {retry_delay_seconds} seconds... (Attempt {attempt + 1}/{max_retries - 1})" ) time.sleep(retry_delay_seconds) else: raise BinaryInstallError( f"Failed to download {binary_name} after {max_retries} attempts: {e}" - ) + ) from e # Make the binary executable try: os.chmod(temp_file, BINARY_PERMISSIONS) except (PermissionError, FileNotFoundError) as e: - raise BinaryInstallError(f"Failed to set executable permissions: {e}") + raise BinaryInstallError( + f"Failed to set executable permissions: {e}" + ) from e try: shutil.move(temp_file, exec_path) except (PermissionError, FileNotFoundError) as e: raise BinaryInstallError( f"Failed to move {binary_name} binary to {exec_path}: {e}" - ) + ) from e diff --git a/devservices/utils/services.py b/devservices/utils/services.py index 68bab5a..1b26820 100644 --- a/devservices/utils/services.py +++ b/devservices/utils/services.py @@ -8,6 +8,7 @@ from devservices.exceptions import ConfigParseError from devservices.exceptions import ConfigValidationError from devservices.exceptions import ServiceNotFoundError +from devservices.utils.console import Console from devservices.utils.devenv import get_coderoot @@ -22,13 +23,15 @@ def get_local_services(coderoot: str) -> list[Service]: """Get a list of services in the coderoot.""" from devservices.configs.service_config import load_service_config_from_file + console = Console() + services = [] for repo in os.listdir(coderoot): repo_path = os.path.join(coderoot, repo) try: service_config = load_service_config_from_file(repo_path) except (ConfigParseError, ConfigValidationError) as e: - print(f"{repo} was found with an invalid config: {e}") + console.warning(f"{repo} was found with an invalid config: {e}") continue except ConfigNotFoundError: # Ignore repos that don't have devservices configs diff --git a/tests/commands/test_update.py b/tests/commands/test_update.py index 1467301..b3271b2 100644 --- a/tests/commands/test_update.py +++ b/tests/commands/test_update.py @@ -95,7 +95,5 @@ def test_update_install_binary_error( mock_install_binary: mock.Mock, capsys: pytest.CaptureFixture[str], ) -> None: - with pytest.raises( - DevservicesUpdateError, match="Failed to update devservices: Installation error" - ): + with pytest.raises(SystemExit): update(Namespace())