omni-us / jsonargparse

Implement minimal boilerplate CLIs derived from type hints and parse from command line, config files and environment variables
https://jsonargparse.readthedocs.io
MIT License
302 stars 41 forks source link

Initial scriptconfig support #532

Open Erotemic opened 1 month ago

Erotemic commented 1 month ago

What does this PR do?

This PR is an initial attempt at https://github.com/omni-us/jsonargparse/issues/244

This allows a user to define a scriptconfig object that gives them fine-grained control over the arguments and metadata on the CLI, without requiring the variables to be typed more than once. In other words, the signature of a function is maintained in a class, and it is assumed that the keyword arguments given to that class will be used to create an instance of the scriptconfig object.

Here is what the MWE looks like:

import scriptconfig as scfg

class MyClassConfig(scfg.DataConfig):
    key1 = scfg.Value(1, alias=['key_one'], help='description1')
    key2 = scfg.Value(None, type=str, help='description1')
    key3 = scfg.Value(False, isflag=True, help='description1')
    key4 = 123
    key5 = '123'

class MyClass:
    __scriptconfig__ = MyClassConfig

    def __init__(self, regular_param1, **kwargs):
        self.regular_param1 = regular_param1
        self.config = MyClassConfig(**kwargs)

def main():
    import jsonargparse
    import shlex
    import ubelt as ub
    import rich
    from rich.markup import escape
    parser = jsonargparse.ArgumentParser()
    parser.add_class_arguments(MyClass, nested_key='my_class', fail_untyped=False, sub_configs=True)
    parser.add_argument('--foo', default='bar')
    parser.add_argument('-b', '--baz', '--buzz', default='bar')
    print('Parse Args')
    cases = [
        '',
        '--my_class.key1 123',
        '--my_class.key_one 123ab',
        '--my_class.key4 strings-are-ok',
    ]
    for case_idx, case in enumerate(cases):
        print('--- Case {case_idx} ---')
        print(f'case={case}')
        args = shlex.split(case)
        config = parser.parse_args(args)
        instances = parser.instantiate_classes(config)

        my_class = instances.my_class
        rich.print(f'config = {escape(ub.urepr(config, nl=2))}')
        rich.print(f'my_class.config = {escape(ub.urepr(my_class.config, nl=2))}')
        print('---')

if __name__ == '__main__':
    """
    CommandLine:
        cd ~/code/geowatch/dev/mwe/
        python jsonargparse_scriptconfig_integration_test.py
    """
    main()

You'll note that it looks similar to dataclass / pydantic / attrs object. However, a major difference is that it doesn't rely on type annotations to store metadata. It uses a special "Value" class, which can be augmented with things like help, aliases, and other information useful to building both a CLI and a function / class signature.

The main scriptconfig page is here for more information: https://gitlab.kitware.com/utils/scriptconfig

I've been using a monkey-patched version of jsonargparse that allows me to work with scriptconfig objects for over a year now, and in late 2023 there was some change to jsonargparse that broke me. This force me to pin jsonargparse and pytorch_lightning to older versions, and I've finally had time to look into it. However, I'd really really really like is there was something codified into jsonargparse that would either directly support scriptconfig or expose some way for users to do custom things when running add_class_arguments based on a property in the class itself (which will let it integrate with lightning much easier). I don't particularly care if scriptconfig itself is supported, what I need is something that won't get deprecated or refactored that I can hook into.

So far this PR just adds basic support for scriptconfig itself. This involves some extra logic in _add_scriptconfig_arguments, and in order to support the alias feature of scriptconfig, I updated ParamData so it is now equipped with a short_aliases and a long_aliases attribute. I've also added a method to it so it can construct the args that will ultimately be passed to argparse. This makes modifications to _add_signature_parameter a bit cleaner, and also sets the stage to allow other argument sources to specify aliases.

Before submitting

WRT to this checklist, I don't want to invest too much time into this before having a discussion with the authors.

I'm looking for feedback on the level of support the maintainers would be willing to provide for something like this. I think the simple thing to do would be to "add scriptconfig support" and be done with it, in which case I could clean up the existing code. The alternative is to allow allow classes to have some attribute (e.g. __customize_signature__) that lets the user return a list of additional ParamData objects that should be recognized by the CLI. However, that involves making ParamData a public class, so that has its own set of tradeoffs.

codecov[bot] commented 1 month ago

Codecov Report

Attention: Patch coverage is 58.62069% with 12 lines in your changes missing coverage. Please review.

Project coverage is 99.81%. Comparing base (2f3cce5) to head (1ee309c).

Files Patch % Lines
jsonargparse/_signatures.py 20.00% 12 Missing :warning:
Additional details and impacted files ```diff @@ Coverage Diff @@ ## main #532 +/- ## =========================================== - Coverage 100.00% 99.81% -0.19% =========================================== Files 22 22 Lines 6418 6445 +27 =========================================== + Hits 6418 6433 +15 - Misses 0 12 +12 ``` | [Flag](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | Coverage Δ | | |---|---|---| | [py3.10](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `85.55% <58.62%> (-0.13%)` | :arrow_down: | | [py3.10_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.51% <58.62%> (-0.19%)` | :arrow_down: | | [py3.10_pydantic1](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `48.44% <55.17%> (+0.01%)` | :arrow_up: | | [py3.10_pydantic2](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `48.22% <55.17%> (+0.01%)` | :arrow_up: | | [py3.10_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.52% <58.62%> (-0.19%)` | :arrow_down: | | [py3.11](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `85.53% <58.62%> (-0.13%)` | :arrow_down: | | [py3.11_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.51% <58.62%> (-0.19%)` | :arrow_down: | | [py3.11_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.52% <58.62%> (-0.19%)` | :arrow_down: | | [py3.12](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `85.67% <58.62%> (-0.13%)` | :arrow_down: | | [py3.12_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.51% <58.62%> (-0.19%)` | :arrow_down: | | [py3.12_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.52% <58.62%> (-0.19%)` | :arrow_down: | | [py3.7](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `86.00% <58.62%> (-0.13%)` | :arrow_down: | | [py3.7_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.02% <58.62%> (-0.19%)` | :arrow_down: | | [py3.7_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.07% <58.62%> (-0.19%)` | :arrow_down: | | [py3.8](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `86.19% <58.62%> (-0.13%)` | :arrow_down: | | [py3.8_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.19% <58.62%> (-0.19%)` | :arrow_down: | | [py3.8_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.22% <58.62%> (-0.19%)` | :arrow_down: | | [py3.9](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `86.07% <58.62%> (-0.13%)` | :arrow_down: | | [py3.9_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.09% <58.62%> (-0.19%)` | :arrow_down: | | [py3.9_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/532/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.11% <58.62%> (-0.19%)` | :arrow_down: | Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#carryforward-flags-in-the-pull-request-comment) to find out more.

:umbrella: View full report in Codecov by Sentry.
:loudspeaker: Have feedback on the report? Share it here.

mauvilsa commented 3 weeks ago

@Erotemic thank you for the pull request. Scriptconfig support like proposed here most likely won't happen. But I will have this in mind to add some public interface that would allow you to do this. Note that this is not a simple topic. New features should behave well with all other existing features and not prevent or make more difficult other potential new features. For example what is done here for aliases could conflict with #301. Please just be patient since this will take time.

For the time being we can use this pull request to test that your integration works with the latest changes.

Erotemic commented 3 weeks ago

Thanks for taking the time to look at / review this. Avoiding integration with a particular library makes a lot of sense, but I'm glad you're open to some public API to make something like this possible.

My thought is that ParamData is the obvious thing to expose to the user. If there was something like:

class MyClass:
    __jsonargparse_hook__ = ...

import jsonargparse
parser = jsonargparse.ArgumentParser()
parser.add_class_arguments(MyClass, nested_key='my_class', fail_untyped=False, sub_configs=True)

Where __jsonargparse_hook__ (or a better name) was a callback that users could use to return a custom list of ParamData objects, then that would allow me to write something specific for scriptconfig to integrate with jsonargparse without jsonargparse having direct knowledge of specific consumer packages. Perhaps the signature looks like:

def __jsonargparse_hook__(params: List[ParamData]) -> List[ParamData]:
    """
    Input is the initial list of default parameters that would be added. Modify / add / remove these 
    and return the modified list to control details of which arguments are exposed to jsonargparse. 
    """

However, ParamData may need to be modified (or given an initial restricted public form that is added to as the default develops). I think the first step is to add support for aliases. It looks like I made a PR to add alias last year that I forgot about: https://github.com/omni-us/jsonargparse/pull/255 but it is outdated and it looks like things have changed.

I don't have as much time to work on FOSS code in the next few months, but jsonargparse is a critical enough part of my workflow that I can prioritize it, but I want to have a solid plan before I commit to anything.

Let me know what you think.

mauvilsa commented 2 weeks ago

Thank you for the proposal. Though, I don't like it much. I see several issues with it.

In my previous comment I was asking for patience because right now I am not sure how the integration API would be. And it will take some time for this, since I am focused on other stuff right now. But I will propose something here when ready.