Skip to content

Commit 9ad1b61

Browse files
authored
Fix regression when using old CLI and inferring components from the local context (#676)
1 parent 63f4505 commit 9ad1b61

File tree

3 files changed

+57
-31
lines changed

3 files changed

+57
-31
lines changed

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ Fixed
3434
- String parsing regressions (`#673
3535
<https://github.com/omni-us/jsonargparse/pull/673>`__, `#674
3636
<https://github.com/omni-us/jsonargparse/pull/674>`__).
37+
- Regression when using old ``CLI`` and inferring components from the local
38+
context (`#676 <https://github.com/omni-us/jsonargparse/pull/676>`__).
3739

3840

3941
v4.36.0 (2025-01-17)

jsonargparse/_cli.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def auto_cli(
4141
arguments and runs one of the functions or class methods depending on what
4242
was parsed. If the 'components' parameter is not given, then the components
4343
will be all the locals in the context and defined in the same module as from
44-
where CLI is called.
44+
where auto_cli is called.
4545
4646
Args:
4747
components: One or more functions/classes to include in the command line interface.
@@ -58,14 +58,12 @@ def auto_cli(
5858
"""
5959
return_parser = kwargs.pop("return_parser", False)
6060
stacklevel = kwargs.pop("_stacklevel", 2)
61-
caller = inspect.stack()[1][0]
6261

6362
if components is None:
64-
module = inspect.getmodule(caller).__name__ # type: ignore[union-attr]
63+
caller = inspect.stack()[stacklevel - 1][0]
64+
module = inspect.getmodule(caller)
6565
components = [
66-
v
67-
for v in caller.f_locals.values()
68-
if ((inspect.isclass(v) or callable(v)) and getattr(inspect.getmodule(v), "__name__", None) == module)
66+
v for v in vars(module).values() if ((inspect.isclass(v) or callable(v)) and inspect.getmodule(v) is module)
6967
]
7068
if len(components) == 0:
7169
raise ValueError(

jsonargparse_tests/test_cli.py

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
import json
55
import os
66
import sys
7-
from contextlib import redirect_stderr, redirect_stdout, suppress
7+
from contextlib import contextmanager, redirect_stderr, redirect_stdout, suppress
88
from dataclasses import asdict, dataclass
9+
from inspect import getmodule as inspect_getmodule
910
from io import StringIO
1011
from pathlib import Path
12+
from types import ModuleType
1113
from typing import Callable, Literal, Optional
1214
from unittest.mock import patch
1315

@@ -29,10 +31,11 @@ def get_cli_stdout(*args, **kwargs) -> str:
2931
# failure cases
3032

3133

34+
@pytest.mark.parametrize("cli_fn", [CLI, auto_cli])
3235
@pytest.mark.parametrize("components", [0, [], {"x": 0}])
33-
def test_unexpected_components(components):
36+
def test_unexpected_components(cli_fn, components):
3437
with pytest.raises(ValueError):
35-
auto_cli(components)
38+
cli_fn(components)
3639

3740

3841
class ConflictingSubcommandKey:
@@ -340,40 +343,63 @@ def test_function_and_class_function_without_parameters():
340343
# automatic components tests
341344

342345

343-
def test_automatic_components_empty_context():
346+
@contextmanager
347+
def mock_getmodule_locals(parent_fn, locals_list=[]):
348+
module_name = "_" + parent_fn.__name__
349+
350+
mock_module = ModuleType(module_name)
351+
for obj in locals_list + [CLI, auto_cli]:
352+
setattr(mock_module, obj.__name__, obj)
353+
sys.modules[module_name] = mock_module
354+
355+
for obj in locals_list:
356+
obj.__module__ = module_name
357+
358+
def patched_getmodule(obj, *args):
359+
if obj in locals_list or (parent_fn.__name__ in str(obj)):
360+
return mock_module
361+
return inspect_getmodule(obj, *args)
362+
363+
with patch("inspect.getmodule", side_effect=patched_getmodule):
364+
yield
365+
del sys.modules[module_name]
366+
367+
368+
@pytest.mark.parametrize("cli_fn", [CLI, auto_cli])
369+
def test_automatic_components_empty_context(cli_fn):
344370
def empty_context():
345-
auto_cli()
371+
cli_fn()
346372

347-
with patch("inspect.getmodule") as mock_getmodule:
348-
mock_getmodule.return_value = sys.modules["jsonargparse._core"]
349-
pytest.raises(ValueError, empty_context)
373+
with mock_getmodule_locals(empty_context):
374+
with pytest.raises(ValueError, match="Either components parameter must be given or"):
375+
empty_context()
350376

351377

352-
def test_automatic_components_context_function():
353-
def non_empty_context_function():
354-
def function(a1: float):
355-
return a1
378+
@pytest.mark.parametrize("cli_fn", [CLI, auto_cli])
379+
def test_automatic_components_context_function(cli_fn):
380+
def function(a1: float):
381+
return a1
356382

357-
return auto_cli(args=["6.7"])
383+
def non_empty_context_function():
384+
return cli_fn(args=["6.7"])
358385

359-
with patch("inspect.getmodule") as mock_getmodule:
360-
mock_getmodule.return_value = sys.modules["jsonargparse._core"]
386+
with mock_getmodule_locals(non_empty_context_function, [function]):
361387
assert 6.7 == non_empty_context_function()
362388

363389

364-
def test_automatic_components_context_class():
365-
def non_empty_context_class():
366-
class ClassX:
367-
def __init__(self, i1: str):
368-
self.i1 = i1
390+
@pytest.mark.parametrize("cli_fn", [CLI, auto_cli])
391+
def test_automatic_components_context_class(cli_fn):
392+
class ClassX:
393+
def __init__(self, i1: str):
394+
self.i1 = i1
369395

370-
def method(self, m1: int):
371-
return self.i1, m1
396+
def method(self, m1: int):
397+
return self.i1, m1
372398

373-
return auto_cli(args=["a", "method", "2"])
399+
def non_empty_context_class():
400+
return cli_fn(args=["a", "method", "2"])
374401

375-
with patch("inspect.getmodule") as mock_getmodule:
376-
mock_getmodule.return_value = sys.modules["jsonargparse._core"]
402+
with mock_getmodule_locals(non_empty_context_class, [ClassX]):
377403
assert ("a", 2) == non_empty_context_class()
378404

379405

0 commit comments

Comments
 (0)