Skip to content

Commit 2e8a0ce

Browse files
committed
CLI now when given a class without methods, the class instance is returned.
1 parent e800d9f commit 2e8a0ce

File tree

4 files changed

+62
-8
lines changed

4 files changed

+62
-8
lines changed

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ Changed
4444
<https://github.com/Lightning-AI/lightning/issues/17247>`__).
4545
- The ``signatures`` extras now installs the ``typing-extensions`` package on
4646
python<=3.9.
47+
- ``CLI`` now when given a class without methods, the class instance is
48+
returned.
4749

4850
Deprecated
4951
^^^^^^^^^^

README.rst

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ is:
129129
print(f'{name} won {prize}€!')
130130

131131
if __name__ == '__main__':
132-
CLI()
132+
CLI(command)
133133

134134
Note that the ``name`` and ``prize`` parameters have type hints and are
135135
described in the docstring. These are shown in the help of the command line
@@ -152,11 +152,6 @@ tool. In a shell you could see the help and run a command as follows:
152152
shown, jsonargparse needs to be installed with the ``signatures`` extras
153153
require as explained in section :ref:`installation`.
154154

155-
:func:`.CLI` without arguments searches for functions and classes defined in the
156-
same module and in the local context where :func:`.CLI` is called. Giving a
157-
single or a list of functions/classes as first argument to :func:`.CLI` skips
158-
the automatic search and only includes what is given.
159-
160155
When :func:`.CLI` receives a single class, the first arguments are for
161156
parameters to instantiate the class, then a method name is expected (i.e.
162157
methods become :ref:`sub-commands`) and the remaining arguments are for
@@ -203,6 +198,37 @@ Then in a shell you could run:
203198
>>> CLI(Main, args=['--max_prize=1000', 'person', 'Lucky']) # doctest: +ELLIPSIS
204199
'Lucky won ...€!'
205200

201+
If the class given does not have any methods, there will be no sub-commands and
202+
:func:`.CLI` will return an instance of the class. For example:
203+
204+
.. testcode::
205+
206+
from dataclasses import dataclass
207+
from jsonargparse import CLI
208+
209+
@dataclass
210+
class Settings:
211+
name: str
212+
prize: int = 100
213+
214+
if __name__ == '__main__':
215+
print(CLI(Settings, as_positional=False))
216+
217+
Then in a shell you could run:
218+
219+
.. code-block:: bash
220+
221+
$ python example.py --name=Lucky
222+
Settings(name='Lucky', prize=100)
223+
224+
.. doctest:: :hide:
225+
226+
>>> CLI(Settings, as_positional=False, args=['--name=Lucky']) # doctest: +ELLIPSIS
227+
Settings(name='Lucky', prize=100)
228+
229+
Note the use of ``as_positional=False`` to make required arguments as
230+
non-positional.
231+
206232
If more than one function is given to :func:`.CLI`, then any of them can be run
207233
via :ref:`sub-commands` similar to the single class example above, i.e.
208234
``example.py function [arguments]`` where ``function`` is the name of the
@@ -214,7 +240,7 @@ class and the second the name of the method, i.e. ``example.py class
214240

215241
.. note::
216242

217-
The two examples above are extremely simple, only defining parameters with
243+
The examples above are extremely simple, only defining parameters with
218244
``str`` and ``int`` type hints. The true power of jsonargparse is its
219245
support for a wide range of types, see :ref:`type-hints`. It is even
220246
possible to use general classes as type hints, allowing to easily implement

jsonargparse/cli.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,18 @@ def get_help_str(component, logger):
114114
def _add_component_to_parser(component, parser, as_positional, fail_untyped, config_help):
115115
kwargs = dict(as_positional=as_positional, fail_untyped=fail_untyped, sub_configs=True)
116116
if inspect.isclass(component):
117+
subcommand_keys = [
118+
k for k, v in inspect.getmembers(component)
119+
if callable(v) and k[0] != '_'
120+
]
121+
if not subcommand_keys:
122+
added_args = parser.add_class_arguments(component, as_group=False, **kwargs)
123+
if not parser.description:
124+
parser.description = get_help_str(component, parser.logger)
125+
return added_args
117126
added_args = parser.add_class_arguments(component, **kwargs)
118127
subcommands = parser.add_subcommands(required=True)
119-
for key in [k for k, v in inspect.getmembers(component) if callable(v) and k[0] != '_']:
128+
for key in subcommand_keys:
120129
description = get_help_str(getattr(component, key), parser.logger)
121130
subparser = type(parser)(description=description)
122131
subparser.add_argument('--config', action=ActionConfigFile, help=config_help)
@@ -137,6 +146,8 @@ def _run_component(component, cfg):
137146
if not inspect.isclass(component):
138147
return component(**cfg)
139148
subcommand = cfg.pop('subcommand')
149+
if not subcommand:
150+
return component(**cfg)
140151
subcommand_cfg = cfg.pop(subcommand, {})
141152
subcommand_cfg.pop('config', None)
142153
component_obj = component(**cfg)

jsonargparse_tests/test_cli.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import unittest
55
import unittest.mock
66
from contextlib import redirect_stderr, redirect_stdout
7+
from dataclasses import asdict, dataclass
78
from io import StringIO
89
from typing import Optional
910

@@ -252,6 +253,20 @@ def method1(self, m1: int):
252253
self.assertEqual(('a', 2), non_empty_context_2())
253254

254255

256+
def test_class_without_methods_cli(self):
257+
@dataclass
258+
class SettingsClass:
259+
p1: str
260+
p2: int = 3
261+
262+
settings = CLI(SettingsClass, args=['--p1=x', '--p2=0'], as_positional=False)
263+
self.assertIsInstance(settings, SettingsClass)
264+
self.assertEqual(asdict(settings), {'p1': 'x', 'p2': 0})
265+
266+
parser = capture_parser(lambda: CLI(SettingsClass, args=[], as_positional=False))
267+
self.assertEqual(parser.groups, {})
268+
269+
255270
class CLITempDirTests(TempDirTestCase):
256271

257272
def test_subclass_type_config_file(self):

0 commit comments

Comments
 (0)