From 220e040442f8dee9a9e60f02cdb8619b16bc748b Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:22:55 +0200 Subject: [PATCH 1/5] Dedicated page for talks and articles --- CONTRIBUTING.rst | 5 +- DOCUMENTATION.rst | 26 --------- README.rst | 6 ++ jsonargparse/_cli.py | 5 ++ sphinx/index.rst | 1 + sphinx/talks-and-articles.rst | 104 ++++++++++++++++++++++++++++++++++ 6 files changed, 120 insertions(+), 27 deletions(-) create mode 100644 sphinx/talks-and-articles.rst diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index d4ada001..108630d8 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,3 +1,5 @@ +.. _contributing: + Contributing ============ @@ -10,7 +12,8 @@ people to help and contribute, among them: - Spread the word in your community about the features you like from jsonargparse. - Help others to learn how to use jsonargparse by creating tutorials, such as - blog posts and videos. + blog posts and videos. If you do, let us know so that it can be added to + :ref:`talks-and-articles`. - Become active in existing GitHub issues and pull requests. - Create `issues `__ for reporting bugs and proposing improvements. diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst index e6ed7bde..c3108544 100644 --- a/DOCUMENTATION.rst +++ b/DOCUMENTATION.rst @@ -206,32 +206,6 @@ tool supports with their default values. Users can follow these steps: # Run the tool using the adapted config python example.py --config config.yaml -Comparison to Fire ------------------- - -The :func:`.auto_cli` feature is similar to and inspired by `Fire -`__. However, there are fundamental differences. -First, the purpose is not to allow calling any Python object from the command -line. It is only intended for running functions and classes specifically written -for this purpose. Second, the arguments are expected to have type hints, and the -given values will be validated according to these. Third, the return values of -the functions are not automatically printed. :func:`.auto_cli` returns the value -and it is up to the developer to decide what to do with it. - - -.. _tutorials: - -Tutorials -========= - -- `"jsonargparse - Say goodbye to configuration hassles" - `__ by Marianne Stecklina at PyCon DE - & PyData Berlin 2022 - - - Presentation video: https://youtu.be/2gDf2S0nHKg - - GitHub repository: https://github.com/stecklin/pycon22-jsonargparse - - .. _parsers: Parsers diff --git a/README.rst b/README.rst index 9aa0dffa..651f2461 100644 --- a/README.rst +++ b/README.rst @@ -27,6 +27,12 @@ a `substantial user base it serves as the framework behind pytorch-lightning's `LightningCLI `__. +The documentation is a reference that describes each feature in isolation, which +is not always the best way to learn. If you would rather see the *why* behind +the features and complete use cases built end to end, have a look at the `talks +and articles `__ +page, which collects presentations, blog posts and example projects. + Teaser examples --------------- diff --git a/jsonargparse/_cli.py b/jsonargparse/_cli.py index 7772f2b7..9f7955f7 100644 --- a/jsonargparse/_cli.py +++ b/jsonargparse/_cli.py @@ -43,6 +43,11 @@ def auto_cli( arguments and runs one of the functions or class methods depending on what was parsed. + Inspired by `Fire `__, though with + fundamental differences: arguments are derived from type hints and + validated against them, instead of being guessed from the given values; and + return values are not printed, they are given back to the caller. + Args: components: One or more functions/classes to include in the command line interface. args: List of arguments to parse or ``None`` to use ``sys.argv``. diff --git a/sphinx/index.rst b/sphinx/index.rst index ec886cbc..916687f4 100644 --- a/sphinx/index.rst +++ b/sphinx/index.rst @@ -32,6 +32,7 @@ jsonargparse.typing Index ===== +* :ref:`talks-and-articles` * :ref:`changelog` * :ref:`license` * :ref:`genindex` diff --git a/sphinx/talks-and-articles.rst b/sphinx/talks-and-articles.rst new file mode 100644 index 00000000..2b7d1f7d --- /dev/null +++ b/sphinx/talks-and-articles.rst @@ -0,0 +1,104 @@ +:orphan: + +.. _talks-and-articles: + +Talks and articles +================== + +Presentations, videos, blog posts and example projects that show jsonargparse +in action. They are a good complement to this documentation, since they explain +the *why* behind the features and walk through complete use cases. + +Note that these materials are snapshots in time. The library evolves, so some +details might differ from the current release. Always refer to the +documentation of the version you are using. + +If you have created something that would fit in this list, contributions are +very welcome, see :ref:`contributing`. + + +From API client to CLI +---------------------- + +*Series of blog posts by Mauricio Villegas, 2026* + +A series that starts from a plain Python class wrapping an HTTP API and, step by +step, turns it into a command line tool that is installable, configurable and +tab completable. Not a single argument parser is written along the way, and the +class stays free of any CLI concern, so it remains equally usable from a +notebook or a web service. + +- Example repository: https://github.com/mauvilsa/blog-earthquake-cli + + +Part 1: From API client to CLI, without writing a parser +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +*2026-08-03* + +A client for the USGS earthquake catalog becomes a full CLI with a single +:func:`.auto_cli` call. Shows how the signatures, type hints and docstrings that +the class already has are enough to get subcommands, help, validated choices, +nested dataclass options and config file support. + +- Post: https://dev.to/mauvilsa/from-api-client-to-cli-without-writing-a-parser-3h01 + + +Part 2: A CLI that works from anywhere +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +*2026-08-10* + +The CLI from part 1 only runs from its own directory. Packaging it with a +``pyproject.toml`` entry point makes it a command available anywhere, and +``default_config_files`` gives it settings that follow it around, layered from +system-wide to user to project, with the help showing which defaults a config +changed. + +- Post: https://dev.to/mauvilsa/a-cli-that-works-from-anywhere-5hf0 + + +Part 3: Tab completion without writing a completion script +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +*2026-08-20* + +A CLI knows what its valid values are, so users should not have to memorize them +or dig through the help. One setting, +:func:`.set_parsing_settings` with ``add_print_completion_argument``, adds a +``--print_completion`` option that emits completion scripts for bash, zsh, fish +and tcsh, with completions that are aware of subcommands, literal choices, paths +and nested dataclass fields. + +- Post: https://dev.to/mauvilsa/tab-completion-without-writing-a-completion-script-4nab + + +Low effort configurable python: type hints and dependency injection +------------------------------------------------------------------- + +*Presentation by Mauricio Villegas at PyBerlin 48, 2024-07-24* + +Why making a project configurable matters, and why it does not have to be +daunting. Explains how pytorch-lightning's ``LightningCLI`` was designed so that +developers get configurability with minimal effort, and how to apply the same +dependency injection ideas to your own projects with jsonargparse. + +- Meetup event: https://www.meetup.com/pyberlin/events/301394594/ +- Slides: https://drive.google.com/file/d/1Cf9Om5c33_4ZNeNfJv3axVlAJwLwTzYu/view + + +jsonargparse - Say goodbye to configuration hassles +--------------------------------------------------- + +*Presentation by Marianne Stecklina at PyCon DE & PyData Berlin 2022, +2022-04-12* + +A tour of the configuration troubles that appear as a project grows: +hard-coded parameters, the need for a CLI, juggling several config files and +dependencies between parameters. Shows how jsonargparse builds on top of +argparse to deal with them, with a runnable example project to follow along. + +- Talk page: https://2022.pycon.de/program/XK73C3/ +- Presentation video: https://youtu.be/2gDf2S0nHKg +- Slides: https://speakerdeck.com/stecklin/jsonargparse-say-goodbye-to-configuration-hassles +- Example repository: https://github.com/stecklin/pycon22-jsonargparse From 8be10ebb194a0d5bde27412a599429899bea7611 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:21:12 +0200 Subject: [PATCH 2/5] Improvements to CONTRIBUTING.rst --- CONTRIBUTING.rst | 154 +++++++++++++++++++++-------------------------- 1 file changed, 70 insertions(+), 84 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 108630d8..df3ebb3d 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -3,47 +3,42 @@ Contributing ============ -Contributions to jsonargparse are very welcome. There are multiple ways for -people to help and contribute, among them: +Contributions to jsonargparse are very welcome. There are many ways to help, +among them: - Star ⭐ the GitHub project ``__. - `Sponsor 🩷 `__ its maintenance and development. - Spread the word in your community about the features you like from jsonargparse. -- Help others to learn how to use jsonargparse by creating tutorials, such as - blog posts and videos. If you do, let us know so that it can be added to +- Help others learn how to use jsonargparse by creating tutorials, such as blog + posts and videos. If you do, let us know so that it can be added to :ref:`talks-and-articles`. - Become active in existing GitHub issues and pull requests. -- Create `issues `__ for - reporting bugs and proposing improvements. +- Create `issues `__ to report + bugs and propose improvements. - Create `pull requests `__ with documentation improvements, bug fixes or new features. .. note:: - While creating an issue before submitting a pull request is not mandatory, - it might be helpful. Issues allow for discussion and feedback before - significant development effort is invested. However, in some cases, code - changes can better illustrate a proposal, making it more effective to submit - a pull request directly. In such cases please avoid opening a largely - redundant issue. + Creating an issue before submitting a pull request is not mandatory, but it + can be helpful, since it allows for discussion and feedback before + significant effort is invested. In some cases, though, code changes + illustrate a proposal better, so submitting a pull request directly is more + effective. In such cases please avoid opening a largely redundant issue. Development environment ----------------------- -If you intend to work with the source code, note that this project does not -include any ``requirements.txt`` file. This is by intention. To make it very -clear what are the requirements for different use cases, all the requirements of -the project are stored in the file ``pyproject.toml``. The basic runtime -requirements are defined in ``dependencies``. Requirements for optional features -are stored in ``[project.optional-dependencies]``. Also in the same section -there are requirements for testing, development and documentation building: -``test``, ``dev`` and ``doc``. +All requirements of the project are defined in ``pyproject.toml``. The basic +runtime requirements are in ``dependencies``. Requirements for optional +features, as well as for testing, development and documentation building +(``test``, ``dev`` and ``doc``), are in ``[project.optional-dependencies]``. -The recommended way to work with the source code is the following. First clone -the repository, then create a virtual environment, activate it and finally -install the development requirements. More precisely the steps are: +The recommended way to work with the source code is to clone the repository, +create a virtual environment, activate it, and install the development +requirements: .. code-block:: bash @@ -51,19 +46,13 @@ install the development requirements. More precisely the steps are: cd jsonargparse python -m venv venv . venv/bin/activate - -The crucial step is installing the requirements which would be done by running: - -.. code-block:: bash - pip install -e ".[dev,all]" pre-commit ---------- -Please also install the `pre-commit `__ git hooks so -that unit tests and code checks are automatically run locally. This is done as -follows: +Please also install the `pre-commit `__ git hooks, so +that unit tests and code checks run automatically on your machine: .. code-block:: bash @@ -72,19 +61,16 @@ follows: .. note:: ``.pre-commit-config.yaml`` is configured to run the hooks using Python - 3.12. Ensure you have Python 3.12 installed and available in your - environment for ``pre-commit`` to function correctly. For development, other - Python versions will work, but for convenience, Python 3.12 is recommended. - -The ``pre-push`` stage runs several hooks, including tests, doctests, mypy, and -coverage. These hooks are designed to inform developers of issues that must be -resolved before a pull request can be merged. Note that these hooks may take -some time to complete. If you wish to push without running these hooks, use the -command ``git push --no-verify``. + 3.12, so make sure that this version is installed and available. Other + Python versions work for development, but 3.12 is recommended for + convenience. -Formatting of the code is done automatically by pre-commit. If some pre-commit -hooks fail and you decide to skip them, formatting will be automatically applied -by a GitHub action in pull requests. +The ``pre-push`` stage runs several hooks, including tests, doctests, mypy and +coverage. They inform developers of issues that must be resolved before a pull +request can be merged, and can take some time to complete. To push without +running them, use ``git push --no-verify``. Formatting of the code is applied +automatically by pre-commit. Even when pushing with ``--no-verify``, please make +sure that the formatting has been applied. Documentation ------------- @@ -95,8 +81,7 @@ To build the documentation run: sphinx-build sphinx sphinx/_build sphinx/*.rst -To view the built documentation, open the file ``sphinx/_build/index.html`` in a -browser. +Then open the file ``sphinx/_build/index.html`` in a browser. Code conventions ---------------- @@ -106,7 +91,7 @@ Code conventions Most module filenames start with ``_``, meaning they are private implementation details. For objects within modules, the ``_`` prefix indicates the object is only used within that same module. An object without a ``_`` prefix may be -imported by other modules but that does not make it public — it is simply +imported by other modules, but that does not make it public — it is simply internal to the package. The only truly public objects are those listed in ``jsonargparse.__all__`` and ``jsonargparse.typing.__all__``. @@ -119,9 +104,8 @@ style Tests ----- -Running the unit tests can be done either using `pytest -`__ or `tox -`__. Also pre-commit runs some additional +The unit tests can be run with `pytest `__ or `tox +`__. Pre-commit runs some additional tests. .. code-block:: bash @@ -130,10 +114,10 @@ tests. pytest # Run tests using pytest on the python of the environment pre-commit run -a --hook-stage pre-push # Run pre-push git hooks (tests, doctests, mypy, coverage) -Tests can be run in any environment without the source code. Before v4.47.0, the -tests were included in the main package. Since v4.47.0, they are provided in a -separate package. Prefer installing the tests package with the same version as -the main package. For example, for v4.47.0 run: +The tests can also be run in any environment without the source code. Since +v4.47.0 they are provided in a separate package, whereas before they were +included in the main package. Prefer installing the tests package with the same +version as the main package, for example: .. code-block:: bash @@ -141,12 +125,12 @@ the main package. For example, for v4.47.0 run: python -m jsonargparse_tests All contributed features and bug fixes must include tests. For bug fixes, ensure -the test fails without the code fix. Almost always tests should exercise only -the public API. Testing internal functions directly is rarely justified and +that the test fails without the code fix. Tests should almost always exercise +only the public API; testing internal functions directly is rarely justified and should be avoided. For tests involving signatures, define the classes and -functions used at the global module scope. Jsonargparse is not intended to -support dynamically defined classes and functions, so there is no value in -testing such cases. +functions at the global module scope. Jsonargparse is not intended to support +dynamically defined classes and functions, so there is no value in testing such +cases. For maintainable tests: @@ -156,17 +140,21 @@ For maintainable tests: multiple tests need the same files, parser configuration, or environment. - Avoid pushing trivial one-line setup into fixtures when it makes the test harder to read. -- Keep setup separate from assertions so each test clearly shows the behavior - being verified. -- The pytest output must be clean. If a test causes log output, the logs must - be captured and asserted using the ``logger`` fixture and ``capture_logs`` - context manager from ``conftest.py``. +- Keep setup separate from assertions, so that each test clearly shows the + behavior being verified. +- Keep the pytest output clean. If a test causes log output, the logs must be + captured and minimally asserted using the ``logger`` fixture and + ``capture_logs`` context manager from ``conftest.py``. Coverage -------- -For a nice html test coverage report, run: +Coverage is required to be 100% in ``jsonargparse/*`` files, with realistic +tests and without unwarranted ``# pragma: no cover``. This ensures that all +existing code is actually needed. + +For a nice html coverage report, run: .. code-block:: bash @@ -174,13 +162,13 @@ For a nice html test coverage report, run: Then open the file ``htmlcov/index.html`` in a browser. -To get a full coverage report, you need to install all supported python -versions, and then: +A full coverage report requires all supported Python versions to be installed, +and then: .. code-block:: bash rm -fr jsonargparse_tests/.coverage jsonargparse_tests/htmlcov - tox -- --cov=../jsonargparse --cov-append + tox --parallel -- --cov=../jsonargparse --cov-append cd jsonargparse_tests coverage html @@ -189,19 +177,16 @@ Then open the file ``jsonargparse_tests/htmlcov/index.html`` in a browser. Pull requests ------------- -When creating a pull request, it is recommended that you create a specific -branch in your fork for the changes you want to contribute, instead of using the -``main`` branch. +For the changes you want to contribute, it is recommended to create a specific +branch in your fork, instead of using the ``main`` branch. -The required tasks to do for a pull request, are listed in -`PULL_REQUEST_TEMPLATE.md +The tasks required for a pull request are listed in `PULL_REQUEST_TEMPLATE.md `__. -One of the tasks is adding a changelog entry. For this, note that this project -uses semantic versioning. Depending on whether the contribution is a bug fix or -a new feature, the changelog entry would go in a patch or minor release. The -changelog section for the next release does not have a definite date, for -example: +One of the tasks is adding a changelog entry. This project uses semantic +versioning, so the entry goes in a patch release for a bug fix, or in a minor +release for a new feature. The changelog section for the next release does not +have a definite date, for example: .. code-block:: @@ -214,13 +199,14 @@ example: If no such section exists, just add it with "(unreleased)" instead of a date. Have a look at previous releases to decide under which subsection the new entry -should go. If you are unsure, ask in the pull request. +should go. Entries must describe changes with respect to the previous release, +not with respect to unreleased commits. -Please don't open pull requests with breaking changes unless this has been +Please don't open pull requests with breaking changes, unless this has been discussed and agreed upon in an issue. -Contributions using coding agents are welcome. However, any agent-generated -code must be fully understood by the submitter and must make sense and follow -these contributing guidelines. Always ask the agent to read and follow these -guidelines. Also ask to read ``.github/PULL_REQUEST_TEMPLATE.md`` so that the -tasks before submitting are covered. +Contributions using coding agents are welcome. However, any agent-generated code +must be fully understood by the submitter, must make sense, and must follow +these contributing guidelines. Always ask the agent to read and follow this +document, and also ``.github/PULL_REQUEST_TEMPLATE.md``, so that the tasks +required before submitting are covered. From 9b1168ec30cca4fe455bd0bcbcf917d4908217d6 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:33:27 +0200 Subject: [PATCH 3/5] Use return type from stub files for functions given to Callable types with a class return --- CHANGELOG.rst | 5 +++ DOCUMENTATION.rst | 6 ++-- jsonargparse/_stubs_resolver.py | 16 +++++++++ jsonargparse/_typehints.py | 5 ++- jsonargparse_tests/test_stubs_resolver.py | 44 +++++++++++++++++++++-- 5 files changed, 70 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6d6b3e10..0641b5a5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -59,6 +59,11 @@ Fixed type is nested in a container or optional, e.g. ``builtins.NoneType`` for ``Optional[list[SomeBaseClass]]`` (`#960 `__). +- Functions without a return annotation at runtime, e.g. C-implemented ones like + ``time.localtime``, were rejected for ``Callable`` types with a class return + type, even when a stub file gives the return type. Now with the stubs resolver + the return type from the ``.pyi`` is used (`#??? + `__). Deprecated ^^^^^^^^^^ diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst index c3108544..0e687dd2 100644 --- a/DOCUMENTATION.rst +++ b/DOCUMENTATION.rst @@ -594,8 +594,10 @@ Some notes about this support are: subclass of the return type of the callable. For these cases running :meth:`instantiate <.ArgumentParser.instantiate>` will instantiate the class or provide a function that returns the instance of the class. For more details - see :ref:`callable-type`. Currently the callable's argument and return types - are not validated. + see :ref:`callable-type`. A function given by import path must have a return + annotation, or a return type in a stub file (see :ref:`stubs-resolver`), that + is the callable's return type or a subclass of it. Argument types are not + validated. - ``types.ModuleType`` is supported by giving the dot import path of a module, and on ``instantiate`` is replaced by the imported module object. diff --git a/jsonargparse/_stubs_resolver.py b/jsonargparse/_stubs_resolver.py index 31ca3424..04bbae6d 100644 --- a/jsonargparse/_stubs_resolver.py +++ b/jsonargparse/_stubs_resolver.py @@ -274,3 +274,19 @@ def get_stub_types(params, component, parent, logger) -> dict[str, Any] | None: if name not in known_params: types[name] = inspect._empty return types + + +def get_stub_return_type(function, logger) -> Any: + """Returns the return type of a function from its stub, or None if not found.""" + if not typeshed_client_support: + return None + resolver = get_stubs_resolver() + stub_import = resolver.get_component_imported_info(function, None) + if not stub_import or not stub_import.info.ast.returns: + return None + try: + return get_arg_type(stub_import.info.ast.returns, resolver.get_aliases(stub_import)) + except Exception as ex: + if logger: + logger.debug(f"Failed to parse type stub for {function.__qualname__!r} return type", exc_info=ex) + return None diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index 144bf4a9..259ec09b 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -2118,11 +2118,14 @@ def get_subclass_names(typehint, callable_return=False): def function_returns_subclass(function, subclass_types, logger) -> bool: """Whether the return type of a function is a subclass of the given types.""" from ._postponed_annotations import get_return_type + from ._stubs_resolver import get_stub_return_type try: return_type = get_return_type(function, logger) except ValueError: - return False # e.g. a builtin that doesn't have an inspectable signature + return_type = None # e.g. a builtin that doesn't have an inspectable signature + if return_type in {None, inspect._empty}: + return_type = get_stub_return_type(function, logger) return is_subclass(return_type, subclass_types) diff --git a/jsonargparse_tests/test_stubs_resolver.py b/jsonargparse_tests/test_stubs_resolver.py index affc180c..f4293130 100644 --- a/jsonargparse_tests/test_stubs_resolver.py +++ b/jsonargparse_tests/test_stubs_resolver.py @@ -11,12 +11,14 @@ from ipaddress import ip_network from random import Random, SystemRandom, uniform from tarfile import TarFile +from time import localtime, struct_time +from typing import Callable from unittest.mock import patch from uuid import UUID, uuid5 import pytest -from jsonargparse import set_parsing_settings +from jsonargparse import ArgumentError, set_parsing_settings from jsonargparse._parameter_resolvers import get_signature_parameters as get_params from jsonargparse._stubs_resolver import get_arg_type, get_mro_method_parent, get_stubs_resolver from jsonargparse_tests.conftest import ( @@ -56,8 +58,8 @@ def mock_stubs_missing_types(): @contextmanager -def mock_stubs_missing_resolver(): - with patch("jsonargparse._parameter_resolvers.get_stubs_resolver") as mock_instance: +def mock_stubs_missing_resolver(module="jsonargparse._parameter_resolvers"): + with patch(f"{module}.get_stubs_resolver") as mock_instance: mock_instance.return_value.get_component_imported_info.return_value = None yield @@ -352,3 +354,39 @@ def test_get_params_inspect_signature_failure_missing_type(logger): assert "int | str | bytes | ipaddress.IPv4Address | " in str(params[0].annotation) assert "get_parameters_from_ast failed" in logs.getvalue() assert "get_parameters_by_assumptions failed" not in logs.getvalue() + + +# callable return type from stubs + + +def test_callable_return_class_function_return_type_from_stubs(parser): + parser.add_argument("--fn", type=Callable[[float], struct_time]) + cfg = parser.parse_args(["--fn=time.localtime"]) + assert cfg.fn is localtime + assert localtime(0.0).tm_year == cfg.fn(0.0).tm_year + + +def test_callable_return_class_function_return_type_from_stubs_mismatch(parser): + parser.add_argument("--fn", type=Callable[[float], struct_time]) + with pytest.raises(ArgumentError) as ctx: + parser.parse_args(["--fn=time.time"]) + ctx.match("Expected 'time.time' to be a function that returns .*struct_time") + + +def test_callable_return_class_function_return_type_from_stubs_not_found(parser): + parser.add_argument("--fn", type=Callable[[float], struct_time]) + with mock_stubs_missing_resolver("jsonargparse._stubs_resolver"): + with pytest.raises(ArgumentError) as ctx: + parser.parse_args(["--fn=time.localtime"]) + ctx.match("Expected 'time.localtime' to be a function that returns .*struct_time") + + +def test_callable_return_class_function_return_type_from_stubs_failure(parser, logger): + parser.logger = logger + parser.add_argument("--fn", type=Callable[[float], struct_time]) + with patch("jsonargparse._stubs_resolver.get_arg_type", side_effect=Exception("bad")): + with capture_logs(logger) as logs: + with pytest.raises(ArgumentError) as ctx: + parser.parse_args(["--fn=time.localtime"]) + ctx.match("Expected 'time.localtime' to be a function that returns .*struct_time") + assert "Failed to parse type stub for 'localtime' return type" in logs.getvalue() From be41d2ca2f596c13593001caae9cfae62cfe2895 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:52:00 +0200 Subject: [PATCH 4/5] Fix crash and unsupported type for unsubscripted typing aliases --- CHANGELOG.rst | 10 +++ jsonargparse/_common.py | 11 +++ jsonargparse/_typehints.py | 5 +- jsonargparse_tests/test_typehints.py | 100 +++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0641b5a5..ba058100 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -64,6 +64,16 @@ Fixed type, even when a stub file gives the return type. Now with the stubs resolver the return type from the ``.pyi`` is used (`#??? `__). +- Unsubscripted ``typing`` sequence aliases, i.e. ``List``, ``Sequence``, + ``MutableSequence``, ``Iterable``, ``Collection``, ``Container``, + ``Reversible`` and ``Deque``, raised ``AttributeError: __args__``, which made + classes that have one in their signature, e.g. + ``torch.utils.data.DataLoader``, impossible to add (`#??? + `__). +- ``typing.Hashable`` and ``typing.Sized`` were not supported, while the + ``collections.abc`` spelling of the same types was. A bare one didn't validate + and a composed one, e.g. ``Optional[Hashable]``, raised ``Unsupported type + hint`` (`#??? `__). Deprecated ^^^^^^^^^^ diff --git a/jsonargparse/_common.py b/jsonargparse/_common.py index 57eef351..60aabb04 100644 --- a/jsonargparse/_common.py +++ b/jsonargparse/_common.py @@ -621,6 +621,14 @@ def get_generic_origins(class_or_tuple): return get_generic_origin(class_or_tuple) +def get_unsubscripted_alias_origin(typehint): + """Origin class of an unsubscripted typing alias, e.g. typing.List -> list, else None.""" + if isinstance(typehint, type) or hasattr(typehint, "__args__"): + return None + origin = getattr(typehint, "__origin__", None) + return origin if isinstance(origin, type) else None + + def get_unaliased_type(cls): new_cls = cls while True: @@ -629,6 +637,9 @@ def get_unaliased_type(cls): new_cls = get_annotated_base_type(new_cls) if is_alias_type(new_cls): new_cls = get_alias_target(new_cls) + origin = get_unsubscripted_alias_origin(new_cls) + if origin is not None: + new_cls = origin if new_cls == cur_cls: break return cur_cls diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index 259ec09b..74af66b0 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -835,9 +835,8 @@ def is_pathlike(typehint) -> bool: def is_list_pathlike(typehint) -> bool: typehint_origin = get_typehint_origin(typehint) - if typehint_origin in sequence_origin_types: - subtype = typehint.__args__[0] - return is_pathlike(subtype) + if typehint_origin in sequence_origin_types and hasattr(typehint, "__args__"): + return is_pathlike(typehint.__args__[0]) return False diff --git a/jsonargparse_tests/test_typehints.py b/jsonargparse_tests/test_typehints.py index 7bb9f66d..9eda817a 100644 --- a/jsonargparse_tests/test_typehints.py +++ b/jsonargparse_tests/test_typehints.py @@ -27,17 +27,21 @@ Dict, FrozenSet, Generic, + Hashable, Iterable, Iterator, List, Literal, Mapping, + MutableMapping, + MutableSequence, NoReturn, Optional, Protocol, Reversible, Sequence, Set, + Sized, Tuple, Type, TypedDict, @@ -1168,6 +1172,102 @@ def test_required_support(): assert ActionTypeHint.is_supported_typehint(Required[Any]) +# unsubscripted typing alias tests + + +@pytest.mark.parametrize( + ["alias", "expected"], + [ + (List, [1, 2]), + (Sequence, [1, 2]), + (MutableSequence, [1, 2]), + (Iterable, [1, 2]), + (Collection, [1, 2]), + (Container, [1, 2]), + (Reversible, [1, 2]), + (Deque, deque([1, 2])), + (Set, {1, 2}), + (FrozenSet, frozenset({1, 2})), + (AbstractSet, {1, 2}), + (Tuple, (1, 2)), + ], + ids=str, +) +def test_unsubscripted_sequence_alias(parser, alias, expected): + parser.add_argument("--x", type=alias) + cfg = parser.parse_args(["--x=[1, 2]"]) + assert cfg.x == expected + assert parser.dump(cfg, format="json") == '{"x":[1,2]}' + + +@pytest.mark.parametrize("alias", [Dict, Mapping, MutableMapping], ids=str) +def test_unsubscripted_mapping_alias(parser, alias): + parser.add_argument("--x", type=alias) + cfg = parser.parse_args(['--x={"a": 1}']) + assert cfg.x == {"a": 1} + assert parser.dump(cfg, format="json") == '{"x":{"a":1}}' + + +@pytest.mark.parametrize("alias", [List, Iterable, Deque], ids=str) +def test_unsubscripted_alias_in_union_with_class(parser, alias): + parser.add_argument("--x", type=Optional[Union[BaseC, alias]]) + cfg = parser.parse_args(["--x=[1, 2]"]) + assert list(cfg.x) == [1, 2] + cfg = parser.parse_args([f"--x={__name__}.BaseC"]) + assert cfg.x.class_path == f"{__name__}.BaseC" + + +class WithUnsubscriptedIterable: + def __init__(self, sampler: Union[BaseC, Iterable, None] = None): + self.sampler = sampler + + +def test_unsubscripted_alias_signature_parameter(parser): + parser.add_class_arguments(WithUnsubscriptedIterable, "c") + cfg = parser.parse_args(["--c.sampler=[1, 2]"]) + assert cfg.c.sampler == [1, 2] + init = parser.instantiate(cfg) + assert isinstance(init.c, WithUnsubscriptedIterable) + + +# the typing aliases must behave the same as their collections.abc counterparts + + +@pytest.mark.parametrize("hashable", [Hashable, abc.Hashable], ids=str) +def test_hashable(parser, hashable): + parser.add_argument("--x", type=hashable) + assert parser.parse_args(["--x=abc"]).x == "abc" + + +@pytest.mark.parametrize("sized", [Sized, abc.Sized], ids=str) +def test_sized(parser, sized): + parser.add_argument("--x", type=sized) + assert parser.parse_args(["--x=[1, 2]"]).x == [1, 2] + + +@pytest.mark.parametrize("hashable", [Hashable, abc.Hashable], ids=str) +def test_optional_hashable(parser, hashable): + parser.add_argument("--x", type=Optional[hashable]) + assert parser.parse_args(["--x=abc"]).x == "abc" + assert parser.parse_args(["--x=null"]).x is None + + +@pytest.mark.parametrize("sized", [Sized, abc.Sized], ids=str) +def test_optional_sized(parser, sized): + parser.add_argument("--x", type=Optional[sized]) + assert parser.parse_args(["--x=[1, 2]"]).x == [1, 2] + assert parser.parse_args(["--x=null"]).x is None + + +@pytest.mark.parametrize("hashable", [Hashable, abc.Hashable], ids=str) +def test_list_hashable(parser, hashable): + parser.add_argument("--x", type=List[hashable]) + assert parser.parse_args(['--x=["a", "b"]']).x == ["a", "b"] + with pytest.raises(ArgumentError) as ctx: + parser.parse_args(["--x=abc"]) + ctx.match("Expected a ") + + # subscripted generic TypedDict tests From 0baffc5f8be34b33a52dd5088a7da627dfb4e578 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:25:28 +0200 Subject: [PATCH 5/5] Accept functions as implementations of callable protocols --- CHANGELOG.rst | 14 ++-- DOCUMENTATION.rst | 5 +- jsonargparse/_actions.py | 3 + jsonargparse/_typehints.py | 87 ++++++++++++++++++------- jsonargparse_tests/test_subclasses.py | 94 +++++++++++++++++++++++++++ jsonargparse_tests/test_typehints.py | 10 +++ 6 files changed, 185 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ba058100..d1016ec7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -38,6 +38,10 @@ Added ``$schema`` key, which is ignored when parsing. This feature is experimental, so the details of the generated schema might change in non-major releases (`#961 `__). +- A protocol whose single method is ``__call__`` is now also implemented by a + function with a compatible signature, so the import path of a function is + accepted as value, see :ref:`type-hints` (`#963 + `__). Fixed ^^^^^ @@ -62,18 +66,18 @@ Fixed - Functions without a return annotation at runtime, e.g. C-implemented ones like ``time.localtime``, were rejected for ``Callable`` types with a class return type, even when a stub file gives the return type. Now with the stubs resolver - the return type from the ``.pyi`` is used (`#??? - `__). + the return type from the ``.pyi`` is used (`#963 + `__). - Unsubscripted ``typing`` sequence aliases, i.e. ``List``, ``Sequence``, ``MutableSequence``, ``Iterable``, ``Collection``, ``Container``, ``Reversible`` and ``Deque``, raised ``AttributeError: __args__``, which made classes that have one in their signature, e.g. - ``torch.utils.data.DataLoader``, impossible to add (`#??? - `__). + ``torch.utils.data.DataLoader``, impossible to add (`#963 + `__). - ``typing.Hashable`` and ``typing.Sized`` were not supported, while the ``collections.abc`` spelling of the same types was. A bare one didn't validate and a composed one, e.g. ``Optional[Hashable]``, raised ``Unsupported type - hint`` (`#??? `__). + hint`` (`#963 `__). Deprecated ^^^^^^^^^^ diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst index 0e687dd2..3676940c 100644 --- a/DOCUMENTATION.rst +++ b/DOCUMENTATION.rst @@ -561,7 +561,10 @@ Some notes about this support are: ``Proto[int]``. Subscripting substitutes the type arguments in the protocol's methods, so ``Proto[int]`` and ``Proto[str]`` accept different implementations. A ``TypeVar`` that remains, in the protocol or in the - implementation, matches any type, as static type checkers do. + implementation, matches any type, as static type checkers do. A protocol whose + single method is ``__call__`` is also implemented by a function with a + compatible signature, in which case the value is the function itself, instead + of a class to instantiate. - ``dataclasses``, final classes, attrs' ``define``, pydantic's ``dataclass`` and pydantic's ``BaseModel`` are supported even when nested. By default they diff --git a/jsonargparse/_actions.py b/jsonargparse/_actions.py index b2c5e657..ef3f5339 100644 --- a/jsonargparse/_actions.py +++ b/jsonargparse/_actions.py @@ -1,5 +1,6 @@ """Collection of useful actions to define arguments.""" +import inspect import os import re import sys @@ -409,6 +410,8 @@ def resolve_help_type(self, value, option_string): raise TypeError(f"{option_string}: {ex}") from ex if not any(is_subclass(val_class, b) or implements_protocol(val_class, b) for b in class_types): raise TypeError(f'{option_string}: "{value}" is not a {self._kind} {self._basename}') + if not inspect.isclass(val_class): # a function that implements a callable protocol + raise TypeError(f'{option_string}: "{value}" is not a class, so it has no help') return val_class def print_help(self, call_args): diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index 74af66b0..5dfb00be 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -399,7 +399,11 @@ def normalize_default(self, default): from ._parameter_resolvers import UnknownDefault default_type = type(default) - if not is_subclass(default_type, UnknownDefault) and self.is_subclass_typehint(default_type): + if ( + not is_subclass(default_type, UnknownDefault) + and self.is_subclass_typehint(default_type) + and not any(implements_protocol(default, t) for t in get_subclass_types(self._typehint) or ()) + ): raise ValueError("Subclass types require as default either a dict with class_path or a lazy instance.") return default @@ -1721,27 +1725,31 @@ def adapt_typehints( } -def get_protocol_method_signature(class_type, name, logger): - """Returns the parameters (excluding self) and return type of a method, with annotations resolved. +def get_protocol_signature(function, logger, skip_self: bool = False): + """Returns the parameters and return type of a function, with annotations resolved. In contrast to get_signature_parameters, the signature is taken as declared, i.e. ``*args`` and ``**kwargs`` are not resolved into the parameters that they might accept, since for protocols - what matters is how the method can be called. + what matters is how the function can be called. """ from jsonargparse._parameter_resolvers import ParamData, parameter_attributes from jsonargparse._postponed_annotations import evaluate_postponed_annotations, get_return_type + signature = inspect.signature(function) + params = [ParamData(**{a: getattr(p, a) for a in parameter_attributes}) for p in signature.parameters.values()] + evaluate_postponed_annotations(params, function, None, logger) + return (params[1:] if skip_self else params), get_return_type(function, logger) + + +def get_protocol_method_signature(class_type, name, logger): + """Returns the parameters (excluding self) and return type of a method, see get_protocol_signature.""" method = inspect.getattr_static(class_type, name) skip_self = not isinstance(method, staticmethod) if isinstance(method, (staticmethod, classmethod)): method = method.__func__ if not inspect.isfunction(method): raise ValueError(f"Expected {class_type.__name__}.{name} to be a function, but got {method}.") - - signature = inspect.signature(method) - params = [ParamData(**{a: getattr(p, a) for a in parameter_attributes}) for p in signature.parameters.values()] - evaluate_postponed_annotations(params, method, None, logger) - return (params[1:] if skip_self else params), get_return_type(method, logger) + return get_protocol_signature(method, logger, skip_self=skip_self) def type_var_wildcard_matches(proto_annotation, value_annotation) -> bool: @@ -1875,32 +1883,50 @@ def protocol_params_match(proto_params, value_params) -> bool: return all(p.default is not empty for n, p in value_kw.items() if n not in proto_kw) +def get_protocol_members(protocol) -> list[str]: + """Returns the names of the methods that an implementation of a protocol must have.""" + members = [] + for name, _ in inspect.getmembers(protocol, predicate=inspect.isfunction): + is_dunder = name.startswith("__") and name.endswith("__") + if (not is_dunder and name.startswith("_")) or (is_dunder and name in protocol_irrelevant_dunder_methods): + continue + members.append(name) + return members + + +def substitute_protocol_type_vars(proto_params, proto_return, type_var_map): + """Substitutes in place the TypeVars in the parameters and returns the substituted return type. + + A subscripted generic protocol is implemented by what its type arguments say, + e.g. Proto[int] by a run(self, x: int), the same as static type checkers do. + """ + for param in proto_params: + param.annotation = substitute_type_vars(param.annotation, type_var_map) + return substitute_type_vars(proto_return, type_var_map) + + def implements_protocol(value, protocol) -> bool: - if not inspect.isclass(value) or value is object or not is_protocol(protocol): + if not is_protocol(protocol) or value is object: + return False + if inspect.isfunction(value): + return function_implements_protocol(value, protocol) + if not inspect.isclass(value): return False origin = get_protocol_origin(protocol) type_var_map = get_type_var_map(protocol, origin) protocol = origin logger = parse_logger(True, "implements_protocol") - members = 0 - for name, _ in inspect.getmembers(protocol, predicate=inspect.isfunction): - is_dunder = name.startswith("__") and name.endswith("__") - if (not is_dunder and name.startswith("_")) or (is_dunder and name in protocol_irrelevant_dunder_methods): - continue + members = get_protocol_members(protocol) + for name in members: if not hasattr(value, name): return False - members += 1 try: value_params, value_return = get_protocol_method_signature(value, name, logger) except (ValueError, TypeError): return False proto_params, proto_return = get_protocol_method_signature(protocol, name, logger) - # a subscripted generic protocol is implemented by what its type arguments say, - # e.g. Proto[int] by a run(self, x: int), the same as static type checkers do - for param in proto_params: - param.annotation = substitute_type_vars(param.annotation, type_var_map) - proto_return = substitute_type_vars(proto_return, type_var_map) + proto_return = substitute_protocol_type_vars(proto_params, proto_return, type_var_map) if not protocol_params_match(proto_params, value_params): return False if not protocol_type_matches(proto_return, value_return): @@ -1908,6 +1934,22 @@ def implements_protocol(value, protocol) -> bool: return True if members else False +def function_implements_protocol(function, protocol) -> bool: + """Whether a function can be called in all the ways that the __call__ of a protocol can. + + Only a protocol whose single method is __call__ can be implemented by a function, + since a function doesn't have any other method. + """ + origin = get_protocol_origin(protocol) + if get_protocol_members(origin) != ["__call__"]: + return False + logger = parse_logger(True, "implements_protocol") + proto_params, proto_return = get_protocol_method_signature(origin, "__call__", logger) + proto_return = substitute_protocol_type_vars(proto_params, proto_return, get_type_var_map(protocol, origin)) + value_params, value_return = get_protocol_signature(function, logger) + return protocol_params_match(proto_params, value_params) and protocol_type_matches(proto_return, value_return) + + def is_protocol(class_type) -> bool: return getattr(class_type, "_is_protocol", False) @@ -1931,7 +1973,8 @@ def is_subclass_or_implements_protocol(value, class_type) -> bool: def is_instance_or_supports_protocol(value, class_type): if is_protocol(class_type): - return is_subclass_or_implements_protocol(value.__class__, class_type) + # a function implements a callable protocol by itself, any other value by its class + return implements_protocol(value if inspect.isfunction(value) else value.__class__, class_type) return is_instance(value, class_type) diff --git a/jsonargparse_tests/test_subclasses.py b/jsonargparse_tests/test_subclasses.py index e39899e4..9b5cdb29 100644 --- a/jsonargparse_tests/test_subclasses.py +++ b/jsonargparse_tests/test_subclasses.py @@ -2330,6 +2330,100 @@ def test_parse_implements_callable_protocol(parser): parser.parse_args(["--cls=[1]"]) +# function implements callable protocol tests + + +def implements_callable_interface(items: List[float]) -> List[float]: + return items + + +def not_implements_callable_interface1(items: str) -> List[float]: + return [] # pragma: no cover + + +def not_implements_callable_interface2(items: List[float], extra: int) -> List[float]: + return items # pragma: no cover + + +def not_implements_callable_interface3(items: List[float]) -> None: + return # pragma: no cover + + +@pytest.mark.parametrize( + "expected, protocol, value", + [ + (True, CallableInterface, implements_callable_interface), + (False, CallableInterface, not_implements_callable_interface1), + (False, CallableInterface, not_implements_callable_interface2), + (False, CallableInterface, not_implements_callable_interface3), + (False, CallableInterface, len), # not a function, so no inspectable signature + (False, Interface, implements_callable_interface), # a function can't have a predict method + ], +) +def test_function_implements_protocol(expected, protocol, value): + assert implements_protocol(value, protocol) is expected + assert is_instance_or_supports_protocol(value, protocol) is expected + + +class GenericCallableInterface(Protocol[ProtoVar]): + def __call__(self, items: List[ProtoVar]) -> List[ProtoVar]: ... + + +def implements_generic_callable_interface(items: List[int]) -> List[int]: + return items # pragma: no cover + + +@pytest.mark.parametrize( + "expected, protocol", + [ + (True, GenericCallableInterface), + (True, GenericCallableInterface[int]), + # the type arguments are substituted, so an int function is not a GenericCallableInterface[str] + (False, GenericCallableInterface[str]), + ], +) +def test_function_implements_generic_protocol(expected, protocol): + assert implements_protocol(implements_generic_callable_interface, protocol) is expected + + +def test_parse_function_implements_callable_protocol(parser): + parser.add_argument("--cls", type=CallableInterface) + cfg = parser.parse_args([f"--cls={__name__}.implements_callable_interface"]) + assert cfg.cls is implements_callable_interface + init = parser.instantiate(cfg) + assert init.cls([1.0, 2.0]) == [1.0, 2.0] + dump = parser.dump(cfg) + assert json_or_yaml_load(dump) == {"cls": f"{__name__}.implements_callable_interface"} + + with pytest.raises(ArgumentError, match="does not implement protocol"): + parser.parse_args([f"--cls={__name__}.not_implements_callable_interface1"]) + with pytest.raises(ArgumentError, match="is not a class, so it has no help"): + parser.parse_args([f"--cls.help={__name__}.implements_callable_interface"]) + + +def test_function_implements_callable_protocol_default(parser): + parser.add_argument("--cls", type=CallableInterface, default=implements_callable_interface) + cfg = parser.get_defaults() + assert cfg.cls is implements_callable_interface + cfg = parser.parse_args([]) + assert cfg.cls is implements_callable_interface + dump = parser.dump(cfg) + assert json_or_yaml_load(dump) == {"cls": f"{__name__}.implements_callable_interface"} + + +class TakesCallableInterface: + def __init__(self, fn: CallableInterface = implements_callable_interface): + self.fn = fn + + +def test_function_implements_callable_protocol_class_default(parser): + parser.add_class_arguments(TakesCallableInterface, "takes") + cfg = parser.parse_args([]) + assert cfg.takes.fn is implements_callable_interface + init = parser.instantiate(cfg) + assert init.takes.fn is implements_callable_interface + + # parameter skip tests diff --git a/jsonargparse_tests/test_typehints.py b/jsonargparse_tests/test_typehints.py index 9eda817a..85e523ec 100644 --- a/jsonargparse_tests/test_typehints.py +++ b/jsonargparse_tests/test_typehints.py @@ -2712,6 +2712,16 @@ def optimizer_factory(params: List[float]) -> Optimizer: return SGD(params) # pragma: no cover +def test_callable_protocol_instance_factory_function_default(parser): + parser.add_argument("--optimizer", type=OptimizerFactory, default=optimizer_factory) + cfg = parser.parse_args([]) + assert cfg.optimizer is optimizer_factory + dump = parser.dump(cfg) + assert json_or_yaml_load(dump) == {"optimizer": f"{__name__}.optimizer_factory"} + with pytest.raises(ValueError, match="Subclass types require as default"): + parser.add_argument("--optimizer2", type=OptimizerFactory, default=calendar.month) + + def test_callable_return_type_bounds_the_accepted_function(parser): parser.add_argument("--optimizer", type=Callable[[List[float]], Optimizer]) cfg = parser.parse_args([f"--optimizer={__name__}.optimizer_factory"])