Repository: omni-us/jsonargparse
Pull request #676 (2025-02-13 05:23:51)

Fix regression when using old CLI and inferring components from the local context
## Before submitting

- [x] Did you read the [contributing guideline](https://github.com/omni-us/jsonargparse/blob/main/CONTRIBUTING.rst)?
- [n/a] Did you update **the documentation**? (readme and public docstrings)
- [x] Did you write **unit tests** such that there is 100% coverage on related code? (required for bug fixes and new features)
- [x] Did you verify that new and existing **tests pass locally**?
- [x] Did you make sure that all changes preserve **backward compatibility**?
- [x] Did you update **the CHANGELOG**? (not for typos, docs, test updates, or minor internal changes/refactors)


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/omni-us/jsonargparse/pull/676?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`63f4505`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/63f4505fca5a0f6186ed8bfbaeac6ea22562c270?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) to head [(`d7b41f0`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/d7b41f0e897226c0c6ef9ef47e097809e4ab9268?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).

:white_check_mark: All tests successful. No failed tests found.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##              main      #676   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           22        22           
  Lines         6686      6686           
=========================================
  Hits          6686      6686           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/omni-us/jsonargparse/pull/676?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).


Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 58723e5..646b24b 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -34,6 +34,8 @@ Fixed
 - String parsing regressions (`#673
   <https://github.com/omni-us/jsonargparse/pull/673>`__, `#674
   <https://github.com/omni-us/jsonargparse/pull/674>`__).
+- Regression when using old ``CLI`` and inferring components from the local
+  context (`#676 <https://github.com/omni-us/jsonargparse/pull/676>`__).
 
 
 v4.36.0 (2025-01-17)
diff --git a/jsonargparse/_cli.py b/jsonargparse/_cli.py
index 8606137..7335256 100644
--- a/jsonargparse/_cli.py
+++ b/jsonargparse/_cli.py
@@ -41,7 +41,7 @@ def auto_cli(
     arguments and runs one of the functions or class methods depending on what
     was parsed. If the 'components' parameter is not given, then the components
     will be all the locals in the context and defined in the same module as from
-    where CLI is called.
+    where auto_cli is called.
 
     Args:
         components: One or more functions/classes to include in the command line interface.
@@ -58,14 +58,12 @@ def auto_cli(
     """
     return_parser = kwargs.pop("return_parser", False)
     stacklevel = kwargs.pop("_stacklevel", 2)
-    caller = inspect.stack()[1][0]
 
     if components is None:
-        module = inspect.getmodule(caller).__name__  # type: ignore[union-attr]
+        caller = inspect.stack()[stacklevel - 1][0]
+        module = inspect.getmodule(caller)
         components = [
-            v
-            for v in caller.f_locals.values()
-            if ((inspect.isclass(v) or callable(v)) and getattr(inspect.getmodule(v), "__name__", None) == module)
+            v for v in vars(module).values() if ((inspect.isclass(v) or callable(v)) and inspect.getmodule(v) is module)
         ]
         if len(components) == 0:
             raise ValueError(


Test patch:
diff --git a/jsonargparse_tests/test_cli.py b/jsonargparse_tests/test_cli.py
index 6fb3cb9..507cd3f 100644
--- a/jsonargparse_tests/test_cli.py
+++ b/jsonargparse_tests/test_cli.py
@@ -4,10 +4,12 @@ import asyncio
 import json
 import os
 import sys
-from contextlib import redirect_stderr, redirect_stdout, suppress
+from contextlib import contextmanager, redirect_stderr, redirect_stdout, suppress
 from dataclasses import asdict, dataclass
+from inspect import getmodule as inspect_getmodule
 from io import StringIO
 from pathlib import Path
+from types import ModuleType
 from typing import Callable, Literal, Optional
 from unittest.mock import patch
 
@@ -29,10 +31,11 @@ def get_cli_stdout(*args, **kwargs) -> str:
 # failure cases
 
 
+@pytest.mark.parametrize("cli_fn", [CLI, auto_cli])
 @pytest.mark.parametrize("components", [0, [], {"x": 0}])
-def test_unexpected_components(components):
+def test_unexpected_components(cli_fn, components):
     with pytest.raises(ValueError):
-        auto_cli(components)
+        cli_fn(components)
 
 
 class ConflictingSubcommandKey:
@@ -340,40 +343,63 @@ def test_function_and_class_function_without_parameters():
 # automatic components tests
 
 
-def test_automatic_components_empty_context():
+@contextmanager
+def mock_getmodule_locals(parent_fn, locals_list=[]):
+    module_name = "_" + parent_fn.__name__
+
+    mock_module = ModuleType(module_name)
+    for obj in locals_list + [CLI, auto_cli]:
+        setattr(mock_module, obj.__name__, obj)
+    sys.modules[module_name] = mock_module
+
+    for obj in locals_list:
+        obj.__module__ = module_name
+
+    def patched_getmodule(obj, *args):
+        if obj in locals_list or (parent_fn.__name__ in str(obj)):
+            return mock_module
+        return inspect_getmodule(obj, *args)
+
+    with patch("inspect.getmodule", side_effect=patched_getmodule):
+        yield
+        del sys.modules[module_name]
+
+
+@pytest.mark.parametrize("cli_fn", [CLI, auto_cli])
+def test_automatic_components_empty_context(cli_fn):
     def empty_context():
-        auto_cli()
+        cli_fn()
 
-    with patch("inspect.getmodule") as mock_getmodule:
-        mock_getmodule.return_value = sys.modules["jsonargparse._core"]
-        pytest.raises(ValueError, empty_context)
+    with mock_getmodule_locals(empty_context):
+        with pytest.raises(ValueError, match="Either components parameter must be given or"):
+            empty_context()
 
 
-def test_automatic_components_context_function():
-    def non_empty_context_function():
-        def function(a1: float):
-            return a1
+@pytest.mark.parametrize("cli_fn", [CLI, auto_cli])
+def test_automatic_components_context_function(cli_fn):
+    def function(a1: float):
+        return a1
 
-        return auto_cli(args=["6.7"])
+    def non_empty_context_function():
+        return cli_fn(args=["6.7"])
 
-    with patch("inspect.getmodule") as mock_getmodule:
-        mock_getmodule.return_value = sys.modules["jsonargparse._core"]
+    with mock_getmodule_locals(non_empty_context_function, [function]):
         assert 6.7 == non_empty_context_function()
 
 
-def test_automatic_components_context_class():
-    def non_empty_context_class():
-        class ClassX:
-            def __init__(self, i1: str):
-                self.i1 = i1
+@pytest.mark.parametrize("cli_fn", [CLI, auto_cli])
+def test_automatic_components_context_class(cli_fn):
+    class ClassX:
+        def __init__(self, i1: str):
+            self.i1 = i1
 
-            def method(self, m1: int):
-                return self.i1, m1
+        def method(self, m1: int):
+            return self.i1, m1
 
-        return auto_cli(args=["a", "method", "2"])
+    def non_empty_context_class():
+        return cli_fn(args=["a", "method", "2"])
 
-    with patch("inspect.getmodule") as mock_getmodule:
-        mock_getmodule.return_value = sys.modules["jsonargparse._core"]
+    with mock_getmodule_locals(non_empty_context_class, [ClassX]):
         assert ("a", 2) == non_empty_context_class()