From a4c2acba28cdf2c0da682f5c2bced59da975b66e Mon Sep 17 00:00:00 2001 From: Anton Petnitsky <168552591+Mukller@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:56:32 +0300 Subject: [PATCH 01/13] feat(exceptions): add ConfigFileNotFound for missing runtime config (#560) --- invoke/exceptions.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/invoke/exceptions.py b/invoke/exceptions.py index 19ca563bc..a8db89bf1 100644 --- a/invoke/exceptions.py +++ b/invoke/exceptions.py @@ -286,6 +286,26 @@ 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 + super().__init__( + "Runtime config file {!r} was not found. Check the path for typos.".format(path) + ) + + class UnpicklableConfigMember(Exception): """ A config file contained module objects, which can't be pickled/copied. From cab67787e475fc8d717c740b15a390bd8dc0e513 Mon Sep 17 00:00:00 2001 From: Anton Petnitsky <168552591+Mukller@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:56:34 +0300 Subject: [PATCH 02/13] fix(config): raise ConfigFileNotFound when runtime config path is missing (#560) When the user specifies an explicit runtime config path (via --config on the CLI or runtime_path= kwarg) and the file does not exist, _load_file previously caught the IOError and silently continued. The result: invoke runs with no config loaded and no error message, even though the user explicitly provided a path. Fix: re-raise as ConfigFileNotFound when absolute=True (runtime path). The new exception is a subclass of IOError so existing except-IOError handlers are unaffected. All non-runtime config sources keep their silent-skip behaviour. --- invoke/config.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/invoke/config.py b/invoke/config.py index 54962623a..c472980ec 100644 --- a/invoke/config.py +++ b/invoke/config.py @@ -899,6 +899,10 @@ 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: From 71946fe42ca29ae3a25e9d3a8a84eb4d07b1cc69 Mon Sep 17 00:00:00 2001 From: Anton Petnitsky <168552591+Mukller@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:56:49 +0300 Subject: [PATCH 03/13] fix(config): import ConfigFileNotFound in config.py --- invoke/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invoke/config.py b/invoke/config.py index c472980ec..4d1fddb1e 100644 --- a/invoke/config.py +++ b/invoke/config.py @@ -9,7 +9,7 @@ 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 From 1a2ca924c644d099b3faae8790fe25a3b195b056 Mon Sep 17 00:00:00 2001 From: Anton Petnitsky <168552591+Mukller@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:57:30 +0300 Subject: [PATCH 04/13] test: regression for #560 (missing runtime config raises ConfigFileNotFound) Add two tests: 1. missing_runtime_path_raises_config_file_not_found: confirms ConfigFileNotFound is raised (not silently ignored) for a non-existent explicit runtime path. 2. missing_runtime_path_error_includes_path: confirms .path attribute matches. --- tests/config.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/config.py b/tests/config.py index c62f252db..25f9c1dea 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,22 @@ 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. From dcdae2578d4e8d8021df2d1990a630020b893969 Mon Sep 17 00:00:00 2001 From: Anton Petnitsky <168552591+Mukller@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:30:04 +0300 Subject: [PATCH 05/13] fix: wrap long lines to satisfy flake8 E501 (<= 79 chars) --- invoke/config.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/invoke/config.py b/invoke/config.py index 4d1fddb1e..a2f406ce2 100644 --- a/invoke/config.py +++ b/invoke/config.py @@ -9,7 +9,9 @@ from typing import Any, Dict, Iterator, Optional, Tuple, Type, Union from .env import Environment -from .exceptions import ConfigFileNotFound, UnknownFileType, UnpicklableConfigMember +from .exceptions import ( + ConfigFileNotFound, UnknownFileType, UnpicklableConfigMember +) from .runners import Local from .terminals import WINDOWS from .util import debug, yaml @@ -900,7 +902,8 @@ def _load_file( 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". + # 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." From 672501ee1081092a9f346ae1b540c3ceb0e36607 Mon Sep 17 00:00:00 2001 From: Anton Petnitsky <168552591+Mukller@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:30:05 +0300 Subject: [PATCH 06/13] fix: wrap long lines to satisfy flake8 E501 (<= 79 chars) --- invoke/exceptions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/invoke/exceptions.py b/invoke/exceptions.py index a8db89bf1..56d167f04 100644 --- a/invoke/exceptions.py +++ b/invoke/exceptions.py @@ -302,7 +302,8 @@ class ConfigFileNotFound(IOError): def __init__(self, path: str) -> None: self.path = path super().__init__( - "Runtime config file {!r} was not found. Check the path for typos.".format(path) + ("Runtime config file {!r} was not found." + " Check the path for typos.").format(path) ) From 8663e84a9c197557559dbb7ee771e95e1280799f Mon Sep 17 00:00:00 2001 From: Anton Petnitsky <168552591+Mukller@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:30:07 +0300 Subject: [PATCH 07/13] fix: wrap long lines to satisfy flake8 E501 (<= 79 chars) --- tests/config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/config.py b/tests/config.py index 25f9c1dea..dbf364b90 100644 --- a/tests/config.py +++ b/tests/config.py @@ -625,7 +625,9 @@ 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 = Config( + runtime_path=join(CONFIGS_PATH, "nonexistent_typo.yaml") + ) c.load_runtime() def missing_runtime_path_error_includes_path(self): From 965318cff2b08241d32a0b053cc7c7ec2e9db468 Mon Sep 17 00:00:00 2001 From: Anton Petnitsky <168552591+Mukller@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:30:58 +0300 Subject: [PATCH 08/13] fix: apply Black-compatible formatting for flake8 E501 --- invoke/config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/invoke/config.py b/invoke/config.py index a2f406ce2..6b67e3ae1 100644 --- a/invoke/config.py +++ b/invoke/config.py @@ -10,7 +10,9 @@ from .env import Environment from .exceptions import ( - ConfigFileNotFound, UnknownFileType, UnpicklableConfigMember + ConfigFileNotFound, + UnknownFileType, + UnpicklableConfigMember, ) from .runners import Local from .terminals import WINDOWS From 96017977f91e04f6c0bdd551093d7ed99244d45b Mon Sep 17 00:00:00 2001 From: Anton Petnitsky <168552591+Mukller@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:31:00 +0300 Subject: [PATCH 09/13] fix: apply Black-compatible formatting for flake8 E501 --- invoke/exceptions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/invoke/exceptions.py b/invoke/exceptions.py index 56d167f04..93f293bd9 100644 --- a/invoke/exceptions.py +++ b/invoke/exceptions.py @@ -302,8 +302,9 @@ class ConfigFileNotFound(IOError): def __init__(self, path: str) -> None: self.path = path super().__init__( - ("Runtime config file {!r} was not found." - " Check the path for typos.").format(path) + "Runtime config file {!r} was not found. Check the path for typos.".format( + path + ) ) From 80f4f358f74bbb59d8a436c45e90bf93f0f7cab7 Mon Sep 17 00:00:00 2001 From: Anton Petnitsky <168552591+Mukller@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:32:21 +0300 Subject: [PATCH 10/13] fix: split long exception message to satisfy flake8 E501 and Black --- invoke/exceptions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/invoke/exceptions.py b/invoke/exceptions.py index 93f293bd9..8e79e28f1 100644 --- a/invoke/exceptions.py +++ b/invoke/exceptions.py @@ -301,11 +301,11 @@ class ConfigFileNotFound(IOError): def __init__(self, path: str) -> None: self.path = path - super().__init__( - "Runtime config file {!r} was not found. Check the path for typos.".format( - path - ) + msg = ( + "Runtime config file {!r} was not found." + " Check the path for typos." ) + super().__init__(msg.format(path)) class UnpicklableConfigMember(Exception): From 1bc6145fae80d930b80de2453da8624da77cb09c Mon Sep 17 00:00:00 2001 From: Anton Petnitsky <168552591+Mukller@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:35:50 +0300 Subject: [PATCH 11/13] test: add missing dedupe.yaml fixture for program config tests --- tests/_support/configs/dedupe.yaml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 tests/_support/configs/dedupe.yaml 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 From 3f142ee11e1cb5513be9e69b032d3d7eba4ddd12 Mon Sep 17 00:00:00 2001 From: Anton Petnitsky <168552591+Mukller@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:36:06 +0300 Subject: [PATCH 12/13] fix: update test to expect ConfigFileNotFound for missing explicit runtime path --- tests/config.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/config.py b/tests/config.py index dbf364b90..e7b776ddd 100644 --- a/tests/config.py +++ b/tests/config.py @@ -658,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): From 93ace34d72dfbd74e5baef5e0a859184b927684b Mon Sep 17 00:00:00 2001 From: Anton Petnitsky <168552591+Mukller@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:36:40 +0300 Subject: [PATCH 13/13] fix: export ConfigFileNotFound from invoke top-level module --- invoke/__init__.py | 1 + 1 file changed, 1 insertion(+) 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,