diff --git a/invoke/__init__.py b/invoke/__init__.py index 92658cf42..ef91e0512 100644 --- a/invoke/__init__.py +++ b/invoke/__init__.py @@ -8,6 +8,7 @@ AmbiguousEnvVar, AuthFailure, CollectionNotFound, + ConfigFileNotFound, CommandTimedOut, Exit, ParseError, diff --git a/invoke/config.py b/invoke/config.py index 54962623a..6b67e3ae1 100644 --- a/invoke/config.py +++ b/invoke/config.py @@ -9,7 +9,11 @@ from typing import Any, Dict, Iterator, Optional, Tuple, Type, Union from .env import Environment -from .exceptions import UnknownFileType, UnpicklableConfigMember +from .exceptions import ( + ConfigFileNotFound, + UnknownFileType, + UnpicklableConfigMember, +) from .runners import Local from .terminals import WINDOWS from .util import debug, yaml @@ -899,6 +903,11 @@ def _load_file( # Typically means 'no such file', so just note & skip past. except IOError as e: if e.errno == 2: + # Absolute paths (runtime config) were explicitly given by + # the user; a missing file is an error, + # not "not configured". + if absolute: + raise ConfigFileNotFound(str(filepath)) from e err = "Didn't see any {}, skipping." debug(err.format(filepath)) else: diff --git a/invoke/exceptions.py b/invoke/exceptions.py index 19ca563bc..8e79e28f1 100644 --- a/invoke/exceptions.py +++ b/invoke/exceptions.py @@ -286,6 +286,28 @@ class UnknownFileType(Exception): pass +class ConfigFileNotFound(IOError): + """ + A runtime config file was explicitly specified but could not be found. + + Raised by `Config._load_file` when the user supplies an explicit path + (via ``--config`` on the CLI or ``runtime_path=`` kwarg) and that file + does not exist. Unlike the implicit search for system/user/project config + files — where a missing file simply means "not configured" — a missing + *explicit* runtime config is always a user error. + + .. versionadded:: 2.3 + """ + + def __init__(self, path: str) -> None: + self.path = path + msg = ( + "Runtime config file {!r} was not found." + " Check the path for typos." + ) + super().__init__(msg.format(path)) + + class UnpicklableConfigMember(Exception): """ A config file contained module objects, which can't be pickled/copied. diff --git a/tests/_support/configs/dedupe.yaml b/tests/_support/configs/dedupe.yaml new file mode 100644 index 000000000..e57d7e272 --- /dev/null +++ b/tests/_support/configs/dedupe.yaml @@ -0,0 +1,2 @@ +tasks: + dedupe: true diff --git a/tests/config.py b/tests/config.py index c62f252db..e7b776ddd 100644 --- a/tests/config.py +++ b/tests/config.py @@ -12,6 +12,7 @@ from invoke.exceptions import ( AmbiguousEnvVar, UncastableEnvVar, + ConfigFileNotFound, UnknownFileType, UnpicklableConfigMember, ) @@ -619,6 +620,24 @@ def unknown_suffix_in_runtime_path_raises_useful_error(self): c = Config(runtime_path=join(CONFIGS_PATH, "screw.ini")) c.load_runtime() + @raises(ConfigFileNotFound) + def missing_runtime_path_raises_config_file_not_found(self): + # Regression for #560: when the user provides an explicit runtime + # config path that doesn't exist, invoke should raise immediately + # instead of silently continuing with no config loaded. + c = Config( + runtime_path=join(CONFIGS_PATH, "nonexistent_typo.yaml") + ) + c.load_runtime() + + def missing_runtime_path_error_includes_path(self): + # ConfigFileNotFound.path must match what was supplied. + bad = join(CONFIGS_PATH, "nonexistent_typo.yaml") + c = Config(runtime_path=bad) + with pytest.raises(ConfigFileNotFound) as exc_info: + c.load_runtime() + assert exc_info.value.path == bad + def python_modules_dont_load_special_vars(self): "Python modules don't load special vars" # Borrow another test's Python module. @@ -639,13 +658,14 @@ def python_modules_except_usefully_on_unpicklable_modules(self): with pytest.raises(UnpicklableConfigMember, match=expected): c.load_runtime(merge=False) - @patch("invoke.config.debug") - def nonexistent_files_are_skipped_and_logged(self, mock_debug): + @raises(ConfigFileNotFound) + def missing_explicit_runtime_path_raises(self): + # Regression for #560: explicit runtime paths that don't exist must + # raise ConfigFileNotFound, not silently continue. c = Config() c._load_yml = Mock(side_effect=IOError(2, "aw nuts")) c.set_runtime_path("is-a.yml") # Triggers use of _load_yml c.load_runtime() - mock_debug.assert_any_call("Didn't see any is-a.yml, skipping.") @raises(IOError) def non_missing_file_IOErrors_are_raised(self):