diff --git a/devservices/commands/down.py b/devservices/commands/down.py index b90b0ae..0a84b3e 100644 --- a/devservices/commands/down.py +++ b/devservices/commands/down.py @@ -72,7 +72,9 @@ def down(args: Namespace) -> None: console = Console() service_name = args.service_name try: - service = find_matching_service(service_name) + service = find_matching_service( + service_name, config_path=getattr(args, "config", None) + ) except ConfigNotFoundError as e: capture_exception(e, level="info") console.failure( diff --git a/devservices/commands/foreground.py b/devservices/commands/foreground.py index 37e7e0b..95a9279 100644 --- a/devservices/commands/foreground.py +++ b/devservices/commands/foreground.py @@ -40,7 +40,7 @@ def foreground(args: Namespace) -> None: console = Console() program_name = args.program_name try: - service = find_matching_service() + service = find_matching_service(config_path=getattr(args, "config", None)) except ConfigNotFoundError as e: capture_exception(e, level="info") console.failure( diff --git a/devservices/commands/list_dependencies.py b/devservices/commands/list_dependencies.py index 629f4a9..6849468 100644 --- a/devservices/commands/list_dependencies.py +++ b/devservices/commands/list_dependencies.py @@ -32,7 +32,9 @@ def list_dependencies(args: Namespace) -> None: service_name = args.service_name try: - service = find_matching_service(service_name) + service = find_matching_service( + service_name, config_path=getattr(args, "config", None) + ) except ConfigNotFoundError as e: capture_exception(e, level="info") console.failure( diff --git a/devservices/commands/logs.py b/devservices/commands/logs.py index f96395f..84ebf3d 100644 --- a/devservices/commands/logs.py +++ b/devservices/commands/logs.py @@ -52,7 +52,9 @@ def logs(args: Namespace) -> None: console = Console() service_name = args.service_name try: - service = find_matching_service(service_name) + service = find_matching_service( + service_name, config_path=getattr(args, "config", None) + ) except ConfigNotFoundError as e: capture_exception(e, level="info") console.failure( diff --git a/devservices/commands/serve.py b/devservices/commands/serve.py index 62cf7db..ff1eec9 100644 --- a/devservices/commands/serve.py +++ b/devservices/commands/serve.py @@ -35,7 +35,7 @@ def serve(args: Namespace) -> None: console = Console() try: - service = find_matching_service() + service = find_matching_service(config_path=getattr(args, "config", None)) except ConfigNotFoundError as e: console.failure( f"{str(e)}. Please run the command from a directory with a valid devservices configuration." diff --git a/devservices/commands/status.py b/devservices/commands/status.py index 1af80f1..e0da7be 100644 --- a/devservices/commands/status.py +++ b/devservices/commands/status.py @@ -81,7 +81,9 @@ def status(args: Namespace) -> None: console = Console() service_name = args.service_name try: - service = find_matching_service(service_name) + service = find_matching_service( + service_name, config_path=getattr(args, "config", None) + ) except ConfigNotFoundError as e: capture_exception(e) console.failure( diff --git a/devservices/commands/toggle.py b/devservices/commands/toggle.py index ce01f30..c2e595a 100644 --- a/devservices/commands/toggle.py +++ b/devservices/commands/toggle.py @@ -53,7 +53,9 @@ def toggle(args: Namespace) -> None: console = Console() service_name = args.service_name try: - service = find_matching_service(service_name) + service = find_matching_service( + service_name, config_path=getattr(args, "config", None) + ) except ConfigNotFoundError as e: capture_exception(e, level="info") console.failure( diff --git a/devservices/commands/up.py b/devservices/commands/up.py index 885c49d..90864e9 100644 --- a/devservices/commands/up.py +++ b/devservices/commands/up.py @@ -76,7 +76,9 @@ def up(args: Namespace, existing_status: Status | None = None) -> None: console = Console() service_name = args.service_name try: - service = find_matching_service(service_name) + service = find_matching_service( + service_name, config_path=getattr(args, "config", None) + ) except ConfigNotFoundError as e: capture_exception(e, level="info") console.failure( diff --git a/devservices/configs/service_config.py b/devservices/configs/service_config.py index 0932a9f..5c69e45 100644 --- a/devservices/configs/service_config.py +++ b/devservices/configs/service_config.py @@ -69,8 +69,11 @@ def _validate(self) -> None: ) -def load_service_config_from_file(repo_path: str) -> ServiceConfig: - config_path = os.path.join(repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME) +def load_service_config_from_file( + repo_path: str, config_path: str | None = None +) -> ServiceConfig: + if config_path is None: + config_path = os.path.join(repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME) if not os.path.exists(config_path): raise ConfigNotFoundError( f"No devservices configuration found in {config_path}" diff --git a/devservices/main.py b/devservices/main.py index b20faf1..7d5ceac 100644 --- a/devservices/main.py +++ b/devservices/main.py @@ -142,6 +142,12 @@ def main() -> None: usage="devservices [-h] [--version] COMMAND ...", ) parser.add_argument("--version", action="version", version=current_version) + parser.add_argument( + "-c", + "--config", + help="Path to a custom devservices config file", + default=None, + ) subparsers = parser.add_subparsers(dest="command", title="commands", metavar="") diff --git a/devservices/utils/services.py b/devservices/utils/services.py index 53fd8c4..4e5dc3e 100644 --- a/devservices/utils/services.py +++ b/devservices/utils/services.py @@ -6,6 +6,7 @@ from sentry_sdk import logger as sentry_logger from devservices.configs.service_config import ServiceConfig +from devservices.configs.service_config import load_service_config_from_file from devservices.exceptions import ConfigNotFoundError from devservices.exceptions import ConfigParseError from devservices.exceptions import ConfigValidationError @@ -25,8 +26,6 @@ class Service: 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 = [] @@ -51,32 +50,40 @@ def get_local_services(coderoot: str) -> list[Service]: return services -def find_matching_service(service_name: str | None = None) -> Service: +def find_matching_service( + service_name: str | None = None, config_path: str | None = None +) -> Service: """Find a service with the given name.""" - if service_name is None: - from devservices.configs.service_config import load_service_config_from_file - - repo_path = os.getcwd() - service_config = load_service_config_from_file(repo_path) - - return Service( - name=service_config.service_name, - repo_path=repo_path, - config=service_config, + if config_path is not None and service_name is not None: + raise ConfigValidationError( + "Cannot specify both a service name and a custom config path" ) - coderoot = get_coderoot() - services = get_local_services(coderoot) - for service in services: - if service.name.lower() == service_name.lower(): - return service - unique_service_names = sorted(set(service.name for service in services)) - error_message = f"Service '{service_name}' not found." - if len(unique_service_names) > 0: - service_bullet_points = "\n".join( - [f"- {service_name}" for service_name in unique_service_names] - ) - error_message += "\nSupported services:\n" + service_bullet_points - raise ServiceNotFoundError(error_message) + if config_path is not None: + config_path = os.path.abspath(config_path) + repo_path = os.getcwd() + elif service_name is None: + repo_path = os.getcwd() + else: + coderoot = get_coderoot() + services = get_local_services(coderoot) + for service in services: + if service.name.lower() == service_name.lower(): + return service + unique_service_names = sorted(set(service.name for service in services)) + error_message = f"Service '{service_name}' not found." + if len(unique_service_names) > 0: + service_bullet_points = "\n".join( + [f"- {service_name}" for service_name in unique_service_names] + ) + error_message += "\nSupported services:\n" + service_bullet_points + raise ServiceNotFoundError(error_message) + + service_config = load_service_config_from_file(repo_path, config_path=config_path) + return Service( + name=service_config.service_name, + repo_path=repo_path, + config=service_config, + ) def get_active_service_names(clean_stale_entries: bool = False) -> set[str]: diff --git a/tests/commands/test_down.py b/tests/commands/test_down.py index 529312d..8cd97ad 100644 --- a/tests/commands/test_down.py +++ b/tests/commands/test_down.py @@ -391,7 +391,9 @@ def test_down_config_error( with pytest.raises(SystemExit): down(args) - find_matching_service_mock.assert_called_once_with("example-service") + find_matching_service_mock.assert_called_once_with( + "example-service", config_path=None + ) captured = capsys.readouterr() assert "Config error" in captured.out.strip() @@ -406,7 +408,9 @@ def test_down_service_not_found_error( with pytest.raises(SystemExit): down(args) - find_matching_service_mock.assert_called_once_with("example-service") + find_matching_service_mock.assert_called_once_with( + "example-service", config_path=None + ) captured = capsys.readouterr() assert "Service not found" in captured.out.strip() diff --git a/tests/commands/test_list_dependencies.py b/tests/commands/test_list_dependencies.py index 84e247f..655dde2 100644 --- a/tests/commands/test_list_dependencies.py +++ b/tests/commands/test_list_dependencies.py @@ -51,7 +51,9 @@ def test_list_dependencies_service_not_found( assert exc_info.value.code == 1 - mock_find_matching_service.assert_called_once_with("nonexistent-service") + mock_find_matching_service.assert_called_once_with( + "nonexistent-service", config_path=None + ) captured = capsys.readouterr() assert "Service nonexistent-service not found" in captured.out @@ -71,7 +73,7 @@ def test_list_dependencies_config_error( assert exc_info.value.code == 1 - mock_find_matching_service.assert_called_once_with("test-service") + mock_find_matching_service.assert_called_once_with("test-service", config_path=None) captured = capsys.readouterr() assert "Version is required in service config" in captured.out @@ -97,7 +99,7 @@ def test_list_dependencies_no_dependencies( list_dependencies(args) - mock_find_matching_service.assert_called_once_with("test-service") + mock_find_matching_service.assert_called_once_with("test-service", config_path=None) captured = capsys.readouterr() assert "No dependencies found for test-service" in captured.out @@ -130,7 +132,7 @@ def test_list_dependencies_with_dependencies( list_dependencies(args) - mock_find_matching_service.assert_called_once_with("test-service") + mock_find_matching_service.assert_called_once_with("test-service", config_path=None) captured = capsys.readouterr() assert "Dependencies of test-service:" in captured.out assert "- redis: Redis" in captured.out diff --git a/tests/commands/test_status.py b/tests/commands/test_status.py index 9a0d907..df51a67 100644 --- a/tests/commands/test_status.py +++ b/tests/commands/test_status.py @@ -729,7 +729,9 @@ def test_status_service_not_found( assert exc_info.value.code == 1 - mock_find_matching_service.assert_called_once_with("nonexistent-service") + mock_find_matching_service.assert_called_once_with( + "nonexistent-service", config_path=None + ) mock_install_and_verify_dependencies.assert_not_called() mock_get_status_for_service.assert_not_called() @@ -860,7 +862,7 @@ def test_status_service_not_running( status(args) - mock_find_matching_service.assert_called_once_with("test-service") + mock_find_matching_service.assert_called_once_with("test-service", config_path=None) mock_get_status_for_service.assert_not_called() captured = capsys.readouterr() diff --git a/tests/commands/test_toggle.py b/tests/commands/test_toggle.py index 0a06ade..9509180 100644 --- a/tests/commands/test_toggle.py +++ b/tests/commands/test_toggle.py @@ -52,7 +52,7 @@ def test_toggle_config_not_found( ) ) - mock_find_matching_service.assert_called_once_with(None) + mock_find_matching_service.assert_called_once_with(None, config_path=None) captured = capsys.readouterr() assert "Config not found" in captured.out.strip() @@ -74,7 +74,7 @@ def test_toggle_config_error( ) ) - mock_find_matching_service.assert_called_once_with(None) + mock_find_matching_service.assert_called_once_with(None, config_path=None) captured = capsys.readouterr() assert "Config parse error" in captured.out.strip() @@ -93,7 +93,7 @@ def test_toggle_service_not_found( ) ) - mock_find_matching_service.assert_called_once_with(None) + mock_find_matching_service.assert_called_once_with(None, config_path=None) captured = capsys.readouterr() assert "Service not found" in captured.out.strip() diff --git a/tests/commands/test_up.py b/tests/commands/test_up.py index 3dc9139..8c5f870 100644 --- a/tests/commands/test_up.py +++ b/tests/commands/test_up.py @@ -1407,7 +1407,9 @@ def test_up_config_error( with pytest.raises(SystemExit): up(args) - find_matching_service_mock.assert_called_once_with("example-service") + find_matching_service_mock.assert_called_once_with( + "example-service", config_path=None + ) mock_check_all_containers_healthy.assert_not_called() captured = capsys.readouterr() assert "Config error" in captured.out.strip() @@ -1428,7 +1430,9 @@ def test_up_service_not_found_error( with pytest.raises(SystemExit): up(args) - find_matching_service_mock.assert_called_once_with("example-service") + find_matching_service_mock.assert_called_once_with( + "example-service", config_path=None + ) mock_check_all_containers_healthy.assert_not_called() captured = capsys.readouterr() assert "Service not found" in captured.out.strip() diff --git a/tests/utils/test_services.py b/tests/utils/test_services.py index 777d1b7..20a3b93 100644 --- a/tests/utils/test_services.py +++ b/tests/utils/test_services.py @@ -86,6 +86,32 @@ def test_get_local_services_skips_non_devservices_repos(tmp_path: Path) -> None: assert local_services[0].repo_path == str(mock_basic_repo_path) +def test_find_matching_service_with_config_path(tmp_path: Path) -> None: + """Test config_path loads from the specified file with repo_path as cwd.""" + devservices_dir = tmp_path / "devservices" + devservices_dir.mkdir() + config_file = devservices_dir / "config.yml" + config_file.write_text( + "x-sentry-service-config:\n" + " version: 0.1\n" + " service_name: my-service\n" + " dependencies: {}\n" + " modes:\n" + " default: []\n" + ) + service = find_matching_service(config_path=str(config_file)) + assert service.name == "my-service" + assert service.repo_path == os.getcwd() + + +def test_find_matching_service_with_config_path_not_found() -> None: + """Test config_path with a nonexistent file.""" + from devservices.exceptions import ConfigNotFoundError + + with pytest.raises(ConfigNotFoundError): + find_matching_service(config_path="/nonexistent/path/config.yml") + + @mock.patch( "devservices.utils.services.get_local_services", return_value=[],