diff --git a/leverage/modules/credentials.py b/leverage/modules/credentials.py index e40739d..d2e71e2 100644 --- a/leverage/modules/credentials.py +++ b/leverage/modules/credentials.py @@ -1,28 +1,27 @@ """ Credentials managing module. """ -import re import csv import json -from pathlib import Path +import re from functools import wraps +from pathlib import Path import click -from click.exceptions import Exit import questionary +from click.exceptions import Exit from questionary import Choice from ruamel.yaml import YAML from leverage import __toolbox_version__ from leverage import logger -from leverage._utils import ExitError -from leverage.path import get_root_path -from leverage.path import get_global_config_path -from leverage.path import NotARepositoryError from leverage._internals import pass_state -from leverage.container import get_docker_client +from leverage._utils import ExitError from leverage.container import AWSCLIContainer - +from leverage.container import get_docker_client +from leverage.path import NotARepositoryError +from leverage.path import get_global_config_path +from leverage.path import get_project_root_or_current_dir_path # Regexes for general validation PROJECT_SHORT = r"[a-z]{2,4}" @@ -38,11 +37,11 @@ MFA_SERIAL = rf"arn:aws:iam::{ACCOUNT_ID}:mfa/{USERNAME}" # TODO: Remove these and get them into the global app state +PROJECT_ROOT = get_project_root_or_current_dir_path() try: PROJECT_COMMON_TFVARS = Path(get_global_config_path()) - PROJECT_ROOT = Path(get_root_path()) except NotARepositoryError: - PROJECT_COMMON_TFVARS = PROJECT_ROOT = Path.cwd() + PROJECT_COMMON_TFVARS = Path.cwd() PROJECT_COMMON_TFVARS_FILE = "common.tfvars" PROJECT_COMMON_TFVARS = PROJECT_COMMON_TFVARS / PROJECT_COMMON_TFVARS_FILE diff --git a/leverage/modules/project.py b/leverage/modules/project.py index d8bf9d1..de00043 100644 --- a/leverage/modules/project.py +++ b/leverage/modules/project.py @@ -16,7 +16,7 @@ from leverage import __toolbox_version__ from leverage import logger from leverage.logger import console -from leverage.path import get_root_path +from leverage.path import get_root_path, get_project_root_or_current_dir_path from leverage.path import NotARepositoryError from leverage._utils import git, ExitError from leverage.container import get_docker_client @@ -33,10 +33,7 @@ IGNORE_PATTERNS = ignore_patterns(TEMPLATE_PATTERN, ".gitkeep") # Useful project related definitions -try: - PROJECT_ROOT = Path(get_root_path()) -except NotARepositoryError: - PROJECT_ROOT = Path.cwd() +PROJECT_ROOT = get_project_root_or_current_dir_path() PROJECT_CONFIG = PROJECT_ROOT / PROJECT_CONFIG_FILE CONFIG_DIRECTORY = "config" diff --git a/leverage/path.py b/leverage/path.py index a7004a8..a6564d8 100644 --- a/leverage/path.py +++ b/leverage/path.py @@ -3,9 +3,9 @@ """ import os from pathlib import Path -from subprocess import run -from subprocess import PIPE from subprocess import CalledProcessError +from subprocess import PIPE +from subprocess import run import hcl2 @@ -13,7 +13,7 @@ class NotARepositoryError(RuntimeError): - pass + """When you are not running inside a git repository directory""" def get_working_path(): @@ -51,8 +51,10 @@ def get_root_path(): except CalledProcessError as exc: if "fatal: not a git repository" in exc.stderr: raise NotARepositoryError("Not running in a git repository.") - - return root.strip() + except FileNotFoundError as exc: + raise NotARepositoryError("Not running in a git repository.") + else: + return root.strip() def get_account_path(): @@ -274,3 +276,13 @@ def check_for_cluster_layer(self): # assuming the "cluster" layer will contain the expected EKS outputs if self.cwd.parts[-1] != "cluster": raise ExitError(1, "This command can only run at the [bold]cluster layer[/bold].") + + +def get_project_root_or_current_dir_path() -> Path: + """Returns the project root if detected, otherwise the current path""" + try: + root = Path(get_root_path()) + except (NotARepositoryError, TypeError): + root = Path.cwd() + + return root diff --git a/tests/test_path.py b/tests/test_path.py index d1f030c..c90a4c8 100644 --- a/tests/test_path.py +++ b/tests/test_path.py @@ -13,6 +13,8 @@ get_global_config_path, get_build_script_path, get_account_path, + get_project_root_or_current_dir_path, + NotARepositoryError, ) @@ -118,3 +120,37 @@ def test_check_for_cluster_layer(muted_click_context, propagate_logs, caplog): paths.check_for_cluster_layer() assert caplog.messages[0] == "This command can only run at the [bold]cluster layer[/bold]." + + +class TestGetProjectPathOrCurrentDir: + @patch("leverage.path.get_root_path") + @patch("pathlib.Path.cwd") + def test_in_valid_git_repository(self, mock_cwd, mock_get_root_path): + """Test returns project root in a valid Git repository.""" + expected_path = Path("/path/to/project/root") + mock_get_root_path.return_value = str(expected_path) + assert get_project_root_or_current_dir_path() == expected_path + mock_get_root_path.assert_called_once() + mock_cwd.assert_not_called() + + @patch("leverage.path.get_root_path") + @patch("pathlib.Path.cwd") + def test_not_in_git_repository(self, mock_cwd, mock_get_root_path): + """Test falls back to current directory if NotARepositoryError is raised.""" + mock_get_root_path.side_effect = NotARepositoryError("Not running in a git repository.") + expected_cwd = Path("/current/working/directory") + mock_cwd.return_value = expected_cwd + assert get_project_root_or_current_dir_path() == expected_cwd + mock_get_root_path.assert_called_once() + mock_cwd.assert_called_once() + + @patch("leverage.path.get_root_path") + @patch("pathlib.Path.cwd") + def test_get_root_path_returns_unexpected_type(self, mock_cwd, mock_get_root_path): + """Test falls back to current directory if TypeError is encountered.""" + mock_get_root_path.side_effect = TypeError("Unexpected return type.") + expected_cwd = Path("/current/working/directory") + mock_cwd.return_value = expected_cwd + assert get_project_root_or_current_dir_path() == expected_cwd + mock_get_root_path.assert_called_once() + mock_cwd.assert_called_once() diff --git a/tests/test_root_path.py b/tests/test_root_path.py new file mode 100644 index 0000000..2d85158 --- /dev/null +++ b/tests/test_root_path.py @@ -0,0 +1,68 @@ +from subprocess import CalledProcessError +from unittest.mock import patch, MagicMock + +import pytest + +from leverage.path import get_root_path, NotARepositoryError + + +class TestGetRootPath: + @pytest.fixture(autouse=True) + def setup_method(self): + """Setup common test resources and mock patches.""" + self.mock_run_patcher = patch("leverage.path.run") + self.mock_run = self.mock_run_patcher.start() + yield + self.mock_run_patcher.stop() + + def test_in_valid_git_repository(self): + """Test get_root_path returns the correct path in a valid Git repository.""" + self.mock_run.return_value = MagicMock(stdout="path/to/repo\n") + assert get_root_path() == "path/to/repo" + + def test_outside_of_git_repository(self): + """Test get_root_path raises NotARepositoryError when outside a Git repository.""" + self.mock_run.side_effect = CalledProcessError( + returncode=1, cmd=["git", "rev-parse", "--show-toplevel"], stderr="fatal: not a git repository" + ) + with pytest.raises(NotARepositoryError): + get_root_path() + + def test_with_git_not_installed(self): + """Test get_root_path raises NotARepositoryError if git is not installed (FileNotFoundError).""" + self.mock_run.side_effect = FileNotFoundError() + with pytest.raises(NotARepositoryError): + get_root_path() + + def test_in_a_git_submodule(self): + """ + Test get_root_path correctly identifies the root of the main Git repository when called from within a submodule. + """ + # Assuming submodules will have a different path structure + self.mock_run.return_value = MagicMock(stdout="path/to/main/repo\n") + assert get_root_path() == "path/to/main/repo" + + def test_in_newly_initialized_git_repo_without_commits(self): + """Test get_root_path in a new Git repo that has no commits yet.""" + # In practice, this should succeed as the command works in empty repos as well + self.mock_run.return_value = MagicMock(stdout="path/to/new/repo\n") + assert get_root_path() == "path/to/new/repo" + + def test_with_permissions_issue_on_git_directory(self): + """Test get_root_path behavior when there's a permissions issue on the .git directory.""" + self.mock_run.side_effect = PermissionError() + with pytest.raises(PermissionError): + get_root_path() + + def test_with_large_output_from_git_rev_parse(self): + """Test get_root_path correctly handles and processes large outputs from the git rev-parse command.""" + # Simulating a large output scenario + large_path = "path/to/repo" * 1000 + "\n" + self.mock_run.return_value = MagicMock(stdout=large_path) + assert get_root_path() == large_path.strip() + + def test_in_git_repo_with_unusual_characters_in_path(self): + """Test get_root_path handles paths with spaces, special, or non-ASCII characters.""" + unusual_path = "path/to/🚀 project with spaces\n" + self.mock_run.return_value = MagicMock(stdout=unusual_path) + assert get_root_path() == unusual_path.strip()