diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 65f5ddf3..6d6b3e10 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -29,6 +29,15 @@ Added ``dict[str, SomeBaseClass]``, now have a ``--*.help`` option, and subclasses in any container are now included in the known subclasses shown in the help (`#960 `__). +- New ``jsonschema`` completion type, i.e. ``--print_completion=jsonschema`` and + ``parser.get_completion_script("jsonschema")``, which generates a JSON Schema + (draft 2020-12) that describes the config files accepted by the parser, + including descriptions from docstrings, defaults, required keys, type + restrictions, the types that the plain argparse actions give and one entry per + known subclass of subclass types. Configs can point to a schema with a + ``$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 `__). Fixed ^^^^^ diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst index 0ff974d2..e6ed7bde 100644 --- a/DOCUMENTATION.rst +++ b/DOCUMENTATION.rst @@ -3449,67 +3449,182 @@ function to provide an instance of the parser to :class:`.ActionParser`. .. _tab-completion: +.. _completion-scripts: -Tab completion -============== +Completion scripts +================== -Tab completion is available for jsonargparse parsers by using either the `shtab -`__ package or the `argcomplete -`__ package. +From a parser, jsonargparse can generate artifacts that describe what the parser +accepts, so that other tools can validate and complete configs and command +lines. The supported completion types are: -shtab ------ +- ``jsonschema``: a JSON Schema that describes the config files that the parser + accepts. Always available. +- ``shtab-*``: a completion script for a given shell, e.g. ``shtab-bash``. + Available when the `shtab `__ package is + installed. + +Both are generated with the :meth:`.ArgumentParser.get_completion_script` +method, or from the command line, see :ref:`print-completion-argument`. + +Covered further down is completion at runtime in the shell, which jsonargparse +supports through the `argcomplete `__ +package, see :ref:`argcomplete`. It does not involve any generated artifact. -For ``shtab`` to work, there is no need to set ``complete``/``choices`` to the -parser actions, and no need to call `shtab.add_argument_to -`__. This is done -automatically by :meth:`parse_args <.ArgumentParser.parse_args>`. The only -requirement is to install shtab either directly or by installing jsonargparse -with the ``shtab`` extra as explained in section :ref:`installation`. -There are two ways to generate shell completion scripts when ``shtab`` is -installed: via the :meth:`.ArgumentParser.get_completion_script` method or by -enabling a command-line argument. +.. _print-completion-argument: -Programmatic generation -^^^^^^^^^^^^^^^^^^^^^^^ +The --print_completion argument +------------------------------- -The :meth:`.ArgumentParser.get_completion_script` method can be used to -generate completion scripts programmatically. The method accepts a -``completion_type`` parameter that specifies the shell. For shtab, use -``shtab-`` followed by the shell name (e.g., ``shtab-bash``, ``shtab-zsh``). +To enable generation of completion scripts via the command line, use +:func:`.set_parsing_settings` with ``add_print_completion_argument=True``. This +adds a ``--print_completion`` argument to top-level parsers (not subparsers), +which accepts the completion types listed above. .. testcode:: - from jsonargparse import ArgumentParser + from jsonargparse import set_parsing_settings + + set_parsing_settings(add_print_completion_argument=True) + +Without changing python code, it is also possible to add the +``--print_completion`` argument by setting the environment variable +``JSONARGPARSE_ADD_PRINT_COMPLETION_ARGUMENT=true``. + + +jsonschema +---------- + +The ``jsonschema`` completion type gives a `JSON Schema +`__ (draft 2020-12) that describes the config files +accepted by the parser. + +.. testcode:: parser = ArgumentParser(prog="example") parser.add_argument("--bool", type=bool) - script = parser.get_completion_script("shtab-bash", preambles=[]) - # script now contains the bash completion script + schema = parser.get_completion_script("jsonschema") + # schema now contains the JSON schema -.. warning:: +The equivalent from the command line is: - After calling :meth:`.get_completion_script`, the parser instance is - invalidated and cannot be used for parsing arguments. Create a new parser - instance if you need to parse arguments afterward. +.. code-block:: bash -Command-line argument -^^^^^^^^^^^^^^^^^^^^^ + $ example.py --print_completion=jsonschema > schema.json -To enable generation of completion scripts via a command-line argument, use -:func:`.set_parsing_settings` with ``add_print_completion_argument=True``. This -adds a ``--print_completion`` argument to top-level parsers (not subparsers). +This schema is useful as a machine-readable interface for tools. For example: + +- IDE/editor assistance (autocompletion, hints, and inline validation). +- Config contract checks in CI pipelines. +- Generating documentation from parser structure. + +To get validation and autocompletion for a config file in an editor such as +`Visual Studio Code +`__, +the config can point to the generated schema with a ``$schema`` key: + +.. code-block:: json + + { + "$schema": "./schema.json", + "bool": true + } + +The key is accepted in any config that a parser loads, :ref:`sub-config-files` +included, and it is removed before parsing, so it never becomes part of the +parsed namespace. Accordingly, every object in the schema that describes a +config accepts the key. + +The schema is derived from the same information that the ``--help`` output is +based on, so it includes: + +- The structure of nested keys, i.e. argument groups and subclasses-disabled + types become objects, and which of their keys are required. +- The accepted types, including unions, literals, enums, containers and the + restrictions of types such as :class:`.PositiveInt` and :class:`.Email`. For + the plain argparse actions, which have no type hint, this is what the action + gives, e.g. a boolean for ``store_true``, an integer for ``count``, the + possible values for ``store_const`` and an array for ``append``. +- The defaults of the arguments, except for the required ones, the ones whose + default is ``argparse.SUPPRESS``, since not giving those leaves no key, and the + unset ones, see :ref:`unset-values`. Without ``unset_sentinel``, a ``None`` + default is unset, so ``null`` is never described as a default. With it, an + explicit ``default=None`` is described, as long as the type accepts ``null``. +- Descriptions taken from the docstrings of the classes and functions that the + arguments come from, or from the ``help`` given to ``add_argument``. +- For subclass types, one entry per known subclass, each with a ``class_path`` + fixed to that subclass and an ``init_args`` object describing the accepted + init parameters of that specific class. +- For parsers with subcommands, one object per subcommand and a ``subcommand`` + key. This key is optional, since a config that has a single subcommand block + implies it, and when a config has several blocks the subcommand can be given + as a command line argument. + +Subclasses and types that are used in more than one place are added once to +``$defs`` and referenced with ``$ref``, which also makes recursive types work. + +The schema is intended to accept what the parser accepts, though for subclass +types it is stricter: a string is accepted, since it can be a class path or a +path to a sub-config file, but an object is only accepted for the known +subclasses, i.e. one with ``class_path``, ``init_args`` (mandatory only for the +subclasses that have a required init parameter) and ``dict_kwargs``. An object +that accepts any ``class_path`` would keep tools from suggesting the known +subclasses and from pointing out a class path that has a typo or is not the +accepted import path, in which case the ``init_args`` would go undescribed. Only +when a type has no known subclass is any ``class_path`` accepted, without +describing its ``init_args``. + +A union with a subtype that accepts anything, i.e. ``Any`` or an unvalidated +type, is kept as ``{"anyOf": [..., {}]}`` instead of the equivalent ``{}``, so +that tools still have the other subschemas to describe and complete against. The +exception is when another subtype constrains the keys of an object, e.g. a +subclass, dataclass or typed dict. Then the subschemas that accept any object, +i.e. from ``Any``, ``dict`` and unvalidated types, are excluded, making the +schema stricter than the parser, but in exchange mistakes in the keys are +pointed out instead of going unnoticed. + +.. note:: + + The subclasses of a type that the schema includes are the ones known to + python at the time the schema is generated, i.e. only those whose modules + happen to have been imported. + +.. note:: + + The ``jsonschema`` completion type is experimental. The details of the + generated schema might change in non-major releases. + + +shtab +----- + +The ``shtab-*`` completion types give a shell completion script, using +``shtab-`` followed by the shell name, e.g. ``shtab-bash`` or ``shtab-zsh``. + +For ``shtab`` to work, there is no need to set ``complete``/``choices`` to the +parser actions, and no need to call `shtab.add_argument_to +`__. The only +requirement is to install shtab either directly or by installing jsonargparse +with the ``shtab`` extra as explained in section :ref:`installation`. .. testcode:: - from jsonargparse import set_parsing_settings + parser = ArgumentParser(prog="example") + parser.add_argument("--bool", type=bool) - set_parsing_settings(add_print_completion_argument=True) + script = parser.get_completion_script("shtab-bash", preambles=[]) + # script now contains the bash completion script + +.. warning:: + + After calling :meth:`.get_completion_script` for an ``shtab-*`` completion + type, the parser instance is invalidated and cannot be used for parsing + arguments. -With this setting enabled, completion scripts can be generated from the command -line. For example, in Linux to enable bash completions for all users, as root: +From the command line, for example in Linux to enable bash completions for all +users, as root: .. code-block:: bash @@ -3522,10 +3637,6 @@ them: $ eval "$(example.py --print_completion=shtab-bash)" -Without changing python code, it is also possible to add the ``--print_completion`` -argument by setting the environment variable -``JSONARGPARSE_ADD_PRINT_COMPLETION_ARGUMENT=true``. - Completion behavior ^^^^^^^^^^^^^^^^^^^ @@ -3586,6 +3697,8 @@ completed, as well as the values that they accept, e.g.: Expected type: bool; 2/2 matched choices true false +.. _argcomplete: + argcomplete ----------- @@ -3593,16 +3706,16 @@ For ``argcomplete`` to work, there is no need to implement completer functions or to call `argcomplete.autocomplete `__ since this is done automatically by :meth:`parse_args <.ArgumentParser.parse_args>`. The -only requirement to enable tab completion is to install argcomplete either +only requirement to enable shell completion is to install argcomplete either directly or by installing jsonargparse with the ``argcomplete`` extra as explained in section :ref:`installation`. -The tab completion can be enabled `globally +The shell completion can be enabled `globally `__ for all argcomplete compatible tools or for each `individual `__ tool. -Using the same ``bool`` example as shown above, activate tab completion and use +Using the same ``bool`` example as shown above, activate completion and use it as follows: .. code-block:: bash diff --git a/jsonargparse/_common.py b/jsonargparse/_common.py index 8247562d..57eef351 100644 --- a/jsonargparse/_common.py +++ b/jsonargparse/_common.py @@ -270,6 +270,10 @@ class ImportDenied(ImportError, ValueError): ) +# key that configs may have to point to a JSON Schema, only for editor support, so ignored when parsing +config_schema_key = "$schema" + + parsing_settings: dict = { "validate_defaults": False, "validate_subclass_spec_in_any": False, @@ -410,8 +414,9 @@ class when a value for a type that accepts any value, i.e. ``Any``, positionals are applied to optionals in the order that they were added to the parser. By default, this is ``False``. add_print_completion_argument: If ``True``, top-level parsers - automatically include ``--print_completion`` argument when - ``shtab`` is installed. + automatically include a ``--print_completion`` argument. Its + accepted values are ``jsonschema`` and, when ``shtab`` is installed, + one ``shtab-*`` value per supported shell. stubs_resolver_allow_py_files: Whether the stubs resolver should search in ``.py`` files in addition to ``.pyi`` files. omegaconf_absolute_to_relative_paths: If ``True``, when loading configs diff --git a/jsonargparse/_completions.py b/jsonargparse/_completions.py index 21b17d26..0d45936e 100644 --- a/jsonargparse/_completions.py +++ b/jsonargparse/_completions.py @@ -1,4 +1,5 @@ import argparse +import json import locale import os import re @@ -43,7 +44,7 @@ def handle_completions(parser): def add_print_completion_argument(parser): - if getattr(parser, "parent_parser", None) or not find_spec("shtab"): + if getattr(parser, "parent_parser", None): return print_completion_argument = get_parsing_setting("add_print_completion_argument") if not print_completion_argument and "--print_shtab" not in parser._option_string_actions: @@ -128,14 +129,17 @@ def __init__( default=argparse.SUPPRESS, **kwargs, ): - import shtab + choices = ["jsonschema"] + if find_spec("shtab"): + import shtab + choices.extend(f"shtab-{shell}" for shell in shtab.SUPPORTED_SHELLS) super().__init__( option_strings=option_strings, dest=dest, default=default, - choices=[f"shtab-{shell}" for shell in shtab.SUPPORTED_SHELLS], - help="Print shell completion script.", + choices=choices, + help="Print completion script.", ) def __call__(self, parser, namespace, completion_type, option_string=None): @@ -144,11 +148,17 @@ def __call__(self, parser, namespace, completion_type, option_string=None): def get_completion_script(parser, completion_type: str, **kwargs) -> str: + if completion_type == "jsonschema": + from ._completions_jsonschema import config_jsonschema + + return json.dumps(config_jsonschema(parser), indent=2) # doesn't modify the parser if not completion_type.startswith("shtab-"): raise ValueError(f"Unsupported completion_type: {completion_type}.") if not find_spec("shtab"): raise ValueError(f"shtab package is required for completion type '{completion_type}'.") - return get_shtab_script(parser, completion_type[len("shtab-") :], **kwargs) + script = get_shtab_script(parser, completion_type[len("shtab-") :], **kwargs) + parser._invalidate_by_completion_script() + return script def get_shtab_script(parser, shell: str, preambles: list[str] | None = None) -> str: diff --git a/jsonargparse/_completions_jsonschema.py b/jsonargparse/_completions_jsonschema.py new file mode 100644 index 00000000..dae756ab --- /dev/null +++ b/jsonargparse/_completions_jsonschema.py @@ -0,0 +1,547 @@ +"""Generation of a JSON Schema that describes the configs accepted by a parser.""" + +import argparse +import operator +import re +import uuid +from enum import Enum +from types import ModuleType +from typing import Any, Optional, Tuple, Union + +from ._actions import ActionConfigFile, ActionYesNo, _ActionConfigLoad, filter_non_parsing_actions +from ._common import ( + config_schema_key, + get_generic_origin, + get_parsing_setting, + get_unaliased_type, + is_subclass, + parser_context, +) +from ._jsonschema import ActionJsonSchema +from ._namespace import Namespace +from ._optionals import get_doc_short_description +from ._required import iter_required_keys, restore_suppressed_required +from ._subcommands import ActionSubCommands +from ._typehints import ( + ActionTypeHint, + callable_origin_types, + get_all_subclass_paths, + get_callable_return_type, + get_typed_dict_annotations, + get_typed_dict_required_keys, + get_typehint_origin, + is_single_subclass_or_closed_type, + is_single_subclass_type, + literal_types, + mapping_origin_types, + not_required_required_types, + sequence_origin_types, + tuple_set_origin_types, + typed_dict_meta_types, +) +from ._util import NoneType, get_import_path, import_object +from .typing import get_registered_type + +schema_uri = "https://json-schema.org/draft/2020-12/schema" + +config_schema_key_schema = { + "type": "string", + "format": "uri-reference", + "description": ( + "Location of the JSON Schema that describes this config, so that editors give validation and " + "autocompletion for it. Ignored when the config is parsed." + ), +} + +subcommand_description = ( + "Name of the subcommand to run. It can be omitted, in which case it is the only subcommand block " + "present in the config, or can be given as a command line argument." +) + +basic_type_schemas = { + bool: {"type": "boolean"}, + int: {"type": "integer"}, + float: {"type": "number"}, + str: {"type": "string"}, + NoneType: {"type": "null"}, + dict: {"type": "object"}, + list: {"type": "array"}, + ModuleType: {"type": "string"}, +} + +uuid_schema = { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", +} + +restriction_keywords = { + operator.gt: "exclusiveMinimum", + operator.ge: "minimum", + operator.lt: "exclusiveMaximum", + operator.le: "maximum", + operator.eq: "const", +} + +tuple_origin_types = {Tuple, tuple} +set_origin_types = tuple_set_origin_types - tuple_origin_types + + +def config_jsonschema(parser) -> dict: + """Returns a JSON Schema that describes the configs accepted by the given parser.""" + return ParserJsonschema().generate(parser) + + +def new_object(description: Optional[str] = None, schema_key: bool = True) -> dict: + """A config object, which accepts the schema key unless it describes a value, e.g. a typed dict.""" + schema: dict = {"type": "object", "additionalProperties": False} + if description: + schema["description"] = description + schema["properties"] = {config_schema_key: dict(config_schema_key_schema)} if schema_key else {} + return schema + + +def add_required(schema: dict, key: str) -> None: + required = schema.setdefault("required", []) + if key not in required: + required.append(key) + + +def json_value(value): + """Converts a default value into its json representation.""" + if isinstance(value, Namespace): + return json_value(value.as_dict()) + if isinstance(value, dict): + return {str(k): json_value(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [json_value(v) for v in value] + if isinstance(value, set): + return [json_value(v) for v in sorted(value, key=str)] + if isinstance(value, Enum): + return value.name + if isinstance(value, (str, int, float, bool)) or value is None: + return value + registered = get_registered_type(type(value)) + if registered: # the same serialization that dump gives, e.g. a path as its string + return json_value(registered.serializer(value)) + return str(value) + + +def is_type_only(schema: dict) -> bool: + return set(schema) == {"type"} and isinstance(schema["type"], str) + + +unvalidated_schemas: list = [{}, {"type": "object"}] + + +def is_shape_constrained(schema) -> bool: + """Whether a schema constrains the shape of an object, i.e. tells apart known from unknown keys.""" + if isinstance(schema, dict): + if "$ref" in schema or "properties" in schema: + return True + return any(is_shape_constrained(value) for value in schema.values()) + if isinstance(schema, list): + return any(is_shape_constrained(item) for item in schema) + return False + + +def anyof_schema(schemas: list) -> dict: + """Combines subschemas, merging those that only constrain the type.""" + unique: list = [] + for schema in schemas: + if schema not in unique: + unique.append(schema) + if any(is_shape_constrained(s) for s in unique): + # A subschema that accepts any object would make mistakes in the keys of the shape constrained + # ones go unnoticed, which is more valuable than accepting exactly what the parser accepts. + unique = [s for s in unique if s not in unvalidated_schemas] + types = [s["type"] for s in unique if is_type_only(s)] + combined = [s for s in unique if not is_type_only(s) and s != {}] + if types: + combined.insert(0, {"type": types[0] if len(types) == 1 else types}) + if {} in unique: + # An unconstrained subschema, e.g. from an Any subtype, already accepts anything. Still, the + # other subschemas are kept, so that tools have something to describe and complete against. + combined.append({}) + if len(combined) == 1: + return combined[0] + return {"anyOf": combined} + + +def collect_ref_names(schema) -> set: + """Returns the names of the definitions that a schema references.""" + names = set() + if isinstance(schema, dict): + if "$ref" in schema: + names.add(schema["$ref"].rsplit("/", 1)[-1]) + for value in schema.values(): + names |= collect_ref_names(value) + elif isinstance(schema, list): + for item in schema: + names |= collect_ref_names(item) + return names + + +def get_action_description(action) -> Optional[str]: + help_string = getattr(action, "help", None) + if not help_string or help_string == argparse.SUPPRESS: + return None + if "%(" in help_string: + params = {k: v for k, v in vars(action).items() if v is not argparse.SUPPRESS} + params["default"] = json_value(params.get("default")) + try: + help_string = help_string % params + except (KeyError, TypeError, ValueError): + # Keep the original help text if interpolation fails + pass + return help_string + + +argparse_action_schemas = { + argparse._StoreTrueAction: {"type": "boolean"}, + argparse._StoreFalseAction: {"type": "boolean"}, + argparse._CountAction: {"type": "integer"}, +} + +const_action_types = (argparse._StoreConstAction, argparse._AppendConstAction) +append_action_types = (argparse._AppendAction, argparse._AppendConstAction) + + +def is_const_action(action) -> bool: + """Whether an action sets a fixed value, excluding the boolean flags which have their own schema.""" + return isinstance(action, const_action_types) and not isinstance( + action, (argparse._StoreTrueAction, argparse._StoreFalseAction) + ) + + +def is_append_action(action) -> bool: + # extend adds the items of each value to the list, so its schema already is the list + return isinstance(action, append_action_types) and not isinstance(action, argparse._ExtendAction) + + +def get_dest_consts(parser) -> dict: + """Returns the values that const actions can set, grouped by dest since several of them can share one.""" + unset_sentinel = get_parsing_setting("unset_sentinel") + consts: dict = {} + for action in filter_non_parsing_actions(parser._actions): + if not is_const_action(action): + continue + values = consts.setdefault(action.dest, []) + candidates = [action.const] + if isinstance(action, argparse._StoreConstAction): + candidates.append(action.default) # not giving the option leaves the default, which is also a valid value + for value in candidates: + if value is not unset_sentinel and value is not argparse.SUPPRESS and value not in values: + values.append(value) + return consts + + +def registered_type_schema(typehint, registered) -> dict: + # types registered with a serializer that is a basic type are dumped as that type, e.g. Decimal as float + schema = dict(basic_type_schemas.get(registered.serializer, {"type": "string"})) + restrictions = getattr(typehint, "_restrictions", None) + if restrictions and getattr(typehint, "_join", "and") == "and": + for comparison, reference in restrictions: + keyword = restriction_keywords.get(comparison) + if keyword: + schema[keyword] = reference + elif getattr(typehint, "_regex", None) is not None: + schema["pattern"] = typehint._regex.pattern + return schema + + +def value_type_schema(value_type) -> dict: + """Schema for the type given to add_argument, for actions that don't have a type hint.""" + if value_type in basic_type_schemas: + return dict(basic_type_schemas[value_type]) + registered = get_registered_type(value_type) + if registered: + return registered_type_schema(value_type, registered) + return {} + + +class ParserJsonschema: + """Builds a JSON Schema from a parser, keeping shared subschemas in ``$defs``.""" + + def __init__(self): + self.defs: dict = {} + self.def_types: dict = {} + + def generate(self, parser) -> dict: + schema = new_object(parser.description) + with restore_suppressed_required(), parser_context(parent_parser=parser): + self.add_properties(parser, schema) + defs = self.reachable_defs(schema) + if defs: + schema["$defs"] = defs + return {"$schema": schema_uri, **schema} + + def reachable_defs(self, schema: dict) -> dict: + """Discards definitions created for subschemas that ended up not being used.""" + reachable: set = set() + pending = collect_ref_names(schema) + while pending: + name = pending.pop() + if name not in reachable: + reachable.add(name) + pending |= collect_ref_names(self.defs.get(name, {})) + return {name: definition for name, definition in self.defs.items() if name in reachable} + + # properties + + def add_properties(self, parser, schema: dict) -> None: + required_keys = set(iter_required_keys(parser)) + descriptions = {name: group.title for name, group in parser.groups.items() if group.title} + consts = get_dest_consts(parser) + for action in filter_non_parsing_actions(parser._actions): + if isinstance(action, (ActionConfigFile, _ActionConfigLoad)): + continue + if isinstance(action, ActionSubCommands): + self.add_subcommands(action, schema) + continue + required = action.dest in required_keys + action_schema = self.action_schema(action, required, consts.get(action.dest)) + self.set_dest(schema, action.dest, action_schema, required, descriptions) + + def set_dest(self, schema: dict, dest: str, dest_schema: dict, required: bool, descriptions: dict) -> None: + keys = dest.split(".") + node = schema + for num, key in enumerate(keys[:-1]): + properties = node.setdefault("properties", {}) + if properties.get(key, {}).get("type") != "object": + properties[key] = new_object(descriptions.get(".".join(keys[: num + 1]))) + if required: + add_required(node, key) + node = properties[key] + properties = node.setdefault("properties", {}) + properties[keys[-1]] = dest_schema + if required: + add_required(node, keys[-1]) + + def add_subcommands(self, action, schema: dict) -> None: + # the subcommand key is never required: it is implied when the config has a single subcommand + # block, and when there are several the chosen one can be given as a command line argument + names = list(action._name_parser_map.keys()) + properties = schema.setdefault("properties", {}) + properties[action.dest] = {"enum": names, "description": subcommand_description} + for name, subparser in action._name_parser_map.items(): + subcommand_schema = new_object(subparser.description) + self.add_properties(subparser, subcommand_schema) + properties[name] = subcommand_schema + if subcommand_schema.get("required"): # only then is giving the subcommand key unavoidable + schema.setdefault("allOf", []).append( + { + "if": {"properties": {action.dest: {"const": name}}, "required": [action.dest]}, + "then": {"required": [name]}, + } + ) + + # actions + + def action_schema(self, action, required: bool, consts: Optional[list]) -> dict: + schema: dict = self.action_type_schema(action, consts) + if action.nargs in {"+", "*"}: + schema = {"type": "array", "items": schema} + if action.nargs == "+": + schema["minItems"] = 1 + elif isinstance(action.nargs, int) and action.nargs > 1: + schema = {"type": "array", "items": schema, "minItems": action.nargs, "maxItems": action.nargs} + if is_append_action(action): # one value is collected into the list per occurrence of the option + schema = {"type": "array", "items": schema} + description = get_action_description(action) + if description: + schema["description"] = description + # a suppressed default leaves no key and an unset one is not a value, so neither is a default to describe + default = getattr(action, "default", None) + unset_sentinel = get_parsing_setting("unset_sentinel") + describe = not required and default is not argparse.SUPPRESS and default is not unset_sentinel + if describe and (default is not None or self.allows_null(schema)): + schema["default"] = json_value(default) + return schema + + def action_type_schema(self, action, consts: Optional[list]) -> dict: + if action.choices: + return {"enum": [json_value(choice) for choice in action.choices]} + if isinstance(action, ActionTypeHint): + return self.typehint_schema(action._typehint, action) + if isinstance(action, ActionJsonSchema): + return dict(action._validator.schema) + if isinstance(action, ActionYesNo): + return {"type": "boolean"} + for action_type, schema in argparse_action_schemas.items(): + if isinstance(action, action_type): + return dict(schema) + if consts and is_const_action(action): + return {"enum": [json_value(const) for const in consts]} + return value_type_schema(action.type) + + def allows_null(self, schema: dict) -> bool: + if "$ref" in schema: + return self.allows_null(self.defs.get(schema["$ref"].rsplit("/", 1)[-1], {})) + if "anyOf" in schema: + return any(self.allows_null(s) for s in schema["anyOf"]) + if "enum" in schema: + return None in schema["enum"] + schema_type = schema.get("type") + if schema_type is None: + return True + return schema_type == "null" or (isinstance(schema_type, list) and "null" in schema_type) + + # type hints + + def typehint_schema(self, typehint, action) -> dict: + typehint = get_unaliased_type(typehint) + origin = get_typehint_origin(typehint) + root = origin if origin is not None else typehint # unsubscripted generics, e.g. list instead of list[int] + + if typehint in {Any, object}: + return {} + if origin in not_required_required_types: # requiredness comes from the TypedDict, not the type + return self.typehint_schema(typehint.__args__[0], action) + if typehint is uuid.UUID: + return dict(uuid_schema) + if typehint in basic_type_schemas: + return dict(basic_type_schemas[typehint]) + registered = get_registered_type(typehint) + if registered: + return registered_type_schema(typehint, registered) + if is_subclass(typehint, Enum): + return {"enum": list(typehint.__members__)} + if type(typehint) in typed_dict_meta_types: + return self.typed_dict_schema(typehint, action) + if root in literal_types: + return {"enum": [json_value(arg) for arg in typehint.__args__]} + if origin is Union: + return anyof_schema([self.typehint_schema(a, action) for a in typehint.__args__]) + if root is type: + return {"type": "string"} + if root in callable_origin_types: + return self.callable_schema(typehint, action) + if root in tuple_origin_types: + return self.tuple_schema(typehint, action) + if root in set_origin_types: + return self.items_schema(typehint, action, {"type": "array", "uniqueItems": True}) + if root in sequence_origin_types: + return self.items_schema(typehint, action, {"type": "array"}) + if root in mapping_origin_types: + args: tuple = getattr(typehint, "__args__", ()) + if len(args) == 2: + values_schema = self.typehint_schema(args[1], action) + if values_schema: + return {"type": "object", "additionalProperties": values_schema} + return {"type": "object"} + if is_single_subclass_type(typehint, origin): + return self.class_ref(typehint, action, subclass=True) + if is_single_subclass_or_closed_type(typehint, origin): + return self.class_ref(typehint, action, subclass=False) + return {} + + def items_schema(self, typehint, action, schema: dict) -> dict: + args = getattr(typehint, "__args__", ()) + if args: + items = self.typehint_schema(args[0], action) + if items: + schema["items"] = items + return schema + + def tuple_schema(self, typehint, action) -> dict: + args = getattr(typehint, "__args__", ()) + if not args: + return {"type": "array"} + if len(args) == 2 and args[1] is Ellipsis: + return self.items_schema(typehint, action, {"type": "array"}) + prefix_items = [self.typehint_schema(a, action) for a in args] + return { + "type": "array", + "prefixItems": prefix_items, + "items": False, + "minItems": len(prefix_items), + } + + def callable_schema(self, typehint, action) -> dict: + schemas = [{"type": "string"}] + return_type = get_callable_return_type(typehint) + if return_type and is_single_subclass_type(return_type, get_typehint_origin(return_type)): + schemas.append(self.class_ref(return_type, action, subclass=True)) + return anyof_schema(schemas) + + def typed_dict_schema(self, typehint, action) -> dict: + annotations = get_typed_dict_annotations(typehint) + required_keys = get_typed_dict_required_keys(typehint, annotations) + schema = new_object(get_doc_short_description(typehint), schema_key=False) + for key, annotation in annotations.items(): + schema["properties"][key] = self.typehint_schema(annotation, action) + if key in required_keys: + add_required(schema, key) + return schema + + # classes + + def class_ref(self, class_type, action, subclass: bool) -> dict: + class_type = get_generic_origin(class_type) + name = self.def_name(class_type) + if name not in self.defs: + self.defs[name] = {} # placeholder, so that recursive types resolve to the same $ref + if subclass: + definition = self.subclass_def(class_type, action) + else: + definition = self.class_parser_schema(class_type, action, get_doc_short_description(class_type)) + self.defs[name].update(definition) + return {"$ref": f"#/$defs/{name}"} + + def def_name(self, class_type) -> str: + name = class_type.__name__ + if self.def_types.get(name, class_type) is not class_type: # a different class with the same name + name = re.sub(r"\W+", "_", str(get_import_path(class_type))) + self.def_types.setdefault(name, class_type) + return name + + def subclass_def(self, class_type, action) -> dict: + """Describes all forms accepted for a subclass: a class path string or a subclass spec.""" + class_paths = get_all_subclass_paths(class_type) + schemas: list = [{"type": "string"}] # a class path or a path to a sub-config file + if class_paths: + # an object that accepts any class_path would keep tools from suggesting and validating the known ones + schemas += [self.class_path_schema(path, action) for path in class_paths] + else: # no known subclass, so any class path is accepted without describing its init args + schemas.append(self.unknown_class_path_schema()) + return anyof_schema(schemas) + + def class_path_schema(self, class_path: str, action) -> dict: + schema = new_object(get_doc_short_description(import_object(class_path))) + init_args_schema = self.class_parser_schema(class_path, action) + schema["properties"].update( + { + "class_path": {"const": class_path}, + "init_args": init_args_schema, + "dict_kwargs": {"type": "object"}, + } + ) + # init_args can only be omitted when none of the init parameters is required + schema["required"] = ["class_path"] + (["init_args"] if init_args_schema.get("required") else []) + return schema + + def unknown_class_path_schema(self) -> dict: + """Accepts subclasses whose module is not imported, without describing their init args.""" + schema = new_object() + schema["properties"].update( + { + "class_path": {"type": "string"}, + "init_args": {"type": "object"}, + "dict_kwargs": {"type": "object"}, + } + ) + schema["required"] = ["class_path"] + return schema + + def class_parser_schema(self, class_type, action, description: Optional[str] = None) -> dict: + sub_add_kwargs = dict(getattr(action, "sub_add_kwargs", None) or {}) + sub_add_kwargs.pop("linked_targets", None) + try: + class_parser = ActionTypeHint.get_class_parser(class_type, sub_add_kwargs=sub_add_kwargs) + except Exception as ex: + action.logger.debug(f"Unable to get schema for init args of '{class_type}': {ex}") + return {"type": "object"} + schema = new_object(description) + self.add_properties(class_parser, schema) + return schema diff --git a/jsonargparse/_core.py b/jsonargparse/_core.py index 5553dda4..05115844 100644 --- a/jsonargparse/_core.py +++ b/jsonargparse/_core.py @@ -23,6 +23,7 @@ previous_config, ) from ._common import ( + config_schema_key, debug_mode_active, get_optionals_as_positionals_actions, get_parsing_setting, @@ -1149,10 +1150,8 @@ def _invalidate_by_completion_script(self) -> None: setattr(self, name, self._raise_invalidated_by_completion_script) def get_completion_script(self, completion_type: str, **kwargs) -> str: - """Returns shell completion script for a completion type.""" - completion_script = get_completion_script_internal(self, completion_type, **kwargs) - self._invalidate_by_completion_script() - return completion_script + """Returns a shell completion script or a JSON Schema for a completion type.""" + return get_completion_script_internal(self, completion_type, **kwargs) ## Other methods ## @@ -1378,6 +1377,10 @@ def _apply_actions( num += 1 + if action is None and key.rsplit(".", 1)[-1] == config_schema_key: + cfg.pop(key) # only meant for editors, see completion type jsonschema + continue + if action is None or isinstance(action, ActionSubCommands): value = cfg[key] if isinstance(value, dict): diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index 15465a51..144bf4a9 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -59,6 +59,7 @@ from ._common import ( ImportDenied, check_import_path, + config_schema_key, get_generic_origin, get_parsing_setting, get_unaliased_type, @@ -1986,6 +1987,7 @@ def subclass_spec_as_namespace(val, prev_val=None): prev_val = Namespace(class_path=prev_val) if isinstance(val, dict): val = Namespace(val) + val.pop(config_schema_key, None) # only meant for editors, see completion type jsonschema if "init_args" in val and isinstance(val["init_args"], dict): val["init_args"] = Namespace(val["init_args"]) if not is_subclass_spec(val) and isinstance(prev_val, (Namespace, dict)) and "class_path" in prev_val: diff --git a/jsonargparse_tests/test_completions_jsonschema.py b/jsonargparse_tests/test_completions_jsonschema.py new file mode 100644 index 00000000..b3024817 --- /dev/null +++ b/jsonargparse_tests/test_completions_jsonschema.py @@ -0,0 +1,1076 @@ +from __future__ import annotations + +import argparse +import dataclasses +import json +import re +import sys +import uuid +from abc import ABC, abstractmethod +from calendar import Calendar +from enum import Enum +from importlib.util import find_spec +from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Type, TypedDict, TypeVar, Union +from unittest.mock import patch + +import pytest + +from jsonargparse import ( + SUPPRESS, + ActionParser, + ActionYesNo, + ArgumentParser, + Namespace, + lazy_instance, + set_parsing_settings, +) +from jsonargparse._typehints import NotRequired +from jsonargparse.typing import ( + ClosedUnitInterval, + Email, + Path_fr, + PositiveInt, + register_type, + restricted_number_type, +) +from jsonargparse_tests.conftest import ( + capture_logs, + get_parse_args_stdout, + skip_if_docstring_parser_unavailable, + skip_if_jsonschema_unavailable, +) + +schema_uri = "https://json-schema.org/draft/2020-12/schema" + + +def get_schema(parser) -> dict: + return json.loads(parser.get_completion_script("jsonschema")) + + +def validate(schema: dict, instance) -> None: + from jsonschema import Draft202012Validator + + Draft202012Validator.check_schema(schema) + Draft202012Validator(schema).validate(instance) + + +def iter_errors(schema: dict, instance) -> list: + from jsonschema import Draft202012Validator + + return list(Draft202012Validator(schema).iter_errors(instance)) + + +# print_completion argument + + +def test_print_completion_choices_without_shtab(parser, parsing_settings_patch): + parser.add_argument("--val", type=int) + set_parsing_settings(add_print_completion_argument=True) + + def find_spec_patch(module): + return None if module == "shtab" else find_spec(module) + + with patch("jsonargparse._completions.find_spec", side_effect=find_spec_patch): + help_str = get_parse_args_stdout(parser, ["--help"]) + assert "--print_completion" in help_str + assert "jsonschema" in help_str + assert "shtab-" not in help_str + + +def test_print_completion_jsonschema(parser, parsing_settings_patch): + parser.add_argument("--num", type=int, required=True) + set_parsing_settings(add_print_completion_argument=True) + schema = json.loads(get_parse_args_stdout(parser, ["--print_completion=jsonschema"])) + assert schema["properties"]["num"] == {"type": "integer"} + + +def test_get_completion_script_jsonschema_keeps_parser_usable(parser): + parser.add_argument("--num", type=int) + get_schema(parser) + assert parser.parse_args(["--num=1"]).num == 1 + assert get_schema(parser)["properties"]["num"] == {"type": "integer"} + + +def test_get_completion_script_unsupported_type(parser): + with pytest.raises(ValueError, match="Unsupported completion_type"): + parser.get_completion_script("unsupported") + + +# basics + + +def test_schema_root(parser): + parser.add_argument("--num", type=int, required=True) + parser.add_argument("--opt", type=str, default="x") + schema = get_schema(parser) + assert schema["$schema"] == schema_uri + assert schema["type"] == "object" + assert schema["additionalProperties"] is False + assert schema["required"] == ["num"] + assert schema["properties"]["opt"] == {"type": "string", "default": "x"} + + +def test_schema_key_in_root(parser): + parser.add_argument("--num", type=int) + properties = get_schema(parser)["properties"] + assert list(properties) == ["$schema", "num"] + assert properties["$schema"]["type"] == "string" + assert properties["$schema"]["format"] == "uri-reference" + assert "editors" in properties["$schema"]["description"] + + +def test_schema_key_in_config_objects(parser, subparser): + parser.add_argument("--group.num", type=int) + parser.add_argument("--cls", type=Base) + subparser.add_argument("--num", type=int) + parser.add_subcommands().add_subcommand("cmd", subparser) + schema = get_schema(parser) + assert "$schema" in schema["properties"]["group"]["properties"] + assert "$schema" in schema["properties"]["cmd"]["properties"] + entry = class_path_entries(schema["$defs"]["Base"])[f"{__name__}.Base"] + assert "$schema" in entry["properties"] + assert "$schema" in entry["properties"]["init_args"]["properties"] + + +def test_schema_key_not_in_typed_dict(parser): + # a typed dict is a value, not a config, so the parser does not ignore the key in it + parser.add_argument("--movie", type=Movie) + assert "$schema" not in get_schema(parser)["properties"]["movie"]["properties"] + + +@skip_if_jsonschema_unavailable +def test_schema_key_validation(parser): + parser.add_argument("--num", type=int) + schema = get_schema(parser) + validate(schema, {"$schema": "./schema.json", "num": 1}) + assert iter_errors(schema, {"$schema": 1}) + + +def test_schema_key_ignored_when_parsing(parser): + parser.add_argument("--cfg", action="config") + parser.add_argument("--num", type=int) + cfg = parser.parse_args(['--cfg={"$schema": "./schema.json", "num": 1}']) + assert cfg.num == 1 + assert "$schema" not in cfg + + +def test_schema_key_ignored_in_group_subconfig(parser, tmp_cwd): + (tmp_cwd / "group.json").write_text(json.dumps({"$schema": "./schema.json", "num": 2})) + parser.add_argument("--group", type=Data) + cfg = parser.parse_args(["--group=group.json"]) + assert cfg.group.num == 2 + assert "$schema" not in cfg.group + + +def test_schema_key_ignored_in_subclass_subconfig(parser, tmp_cwd): + spec = {"$schema": "./schema.json", "class_path": f"{__name__}.Sub", "init_args": {"sub": 3}} + (tmp_cwd / "cls.json").write_text(json.dumps(spec)) + parser.add_argument("--cls", type=Base, sub_configs=True) + cfg = parser.parse_args(["--cls=cls.json"]) + assert cfg.cls.class_path == f"{__name__}.Sub" + assert cfg.cls.init_args == Namespace(sub=3) + assert "$schema" not in cfg.cls + + +def test_schema_key_ignored_in_list_item_subconfigs(parser, tmp_cwd): + (tmp_cwd / "cls.json").write_text(json.dumps({"$schema": "./schema.json", "class_path": f"{__name__}.Sub"})) + (tmp_cwd / "data.json").write_text(json.dumps({"$schema": "./schema.json", "num": 4})) + parser.add_argument("--vals", type=List[Union[Base, Data]], sub_configs=True) + cfg = parser.parse_args(['--vals=["cls.json", "data.json"]']) + assert cfg.vals[0].class_path == f"{__name__}.Sub" + assert "$schema" not in cfg.vals[0] + assert cfg.vals[1].num == 4 + assert "$schema" not in cfg.vals[1] + + +def test_schema_key_ignored_in_subcommand_config(parser, subparser): + subparser.add_argument("--num", type=int) + parser.add_argument("--cfg", action="config") + parser.add_subcommands().add_subcommand("cmd", subparser) + cfg = parser.parse_args(['--cfg={"$schema": "./s.json", "cmd": {"$schema": "./s.json", "num": 5}}']) + assert cfg.cmd.num == 5 + assert "$schema" not in cfg + assert "$schema" not in cfg.cmd + + +def test_schema_key_not_ignored_when_parser_accepts_it(parser): + parser.add_argument("--cfg", action="config") + parser.add_argument("--$schema", type=str) + cfg = parser.parse_args(['--cfg={"$schema": "value"}']) + assert cfg["$schema"] == "value" + + +def test_schema_key_as_argument(parser): + parser.add_argument("--$schema", type=int) + assert get_schema(parser)["properties"]["$schema"] == {"type": "integer"} + + +def test_schema_key_not_ignored_in_dict_value(parser, tmp_cwd): + (tmp_cwd / "dict.json").write_text(json.dumps({"$schema": "./schema.json", "num": "6"})) + parser.add_argument("--dic", type=Dict[str, str], sub_configs=True) + cfg = parser.parse_args(["--dic=dict.json"]) + assert cfg.dic["$schema"] == "./schema.json" + + +def test_required_argument_has_no_default(parser): + parser.add_argument("--num", type=int, required=True) + assert get_schema(parser)["properties"]["num"] == {"type": "integer"} + + +def test_non_parsing_actions_not_in_schema(parser, parsing_settings_patch): + set_parsing_settings(add_print_completion_argument=True) + parser.add_argument("--cfg", action="config") + parser.add_argument("--num", type=int) + properties = get_schema(parser)["properties"] + assert list(properties) == ["$schema", "num"] + + +def test_nested_key_groups(parser): + parser.add_argument("--group.opt", type=str, default="x") + parser.add_argument("--group.req", type=int, required=True) + schema = get_schema(parser) + group = schema["properties"]["group"] + assert group["type"] == "object" + assert group["additionalProperties"] is False + assert group["properties"]["opt"] == {"type": "string", "default": "x"} + assert group["required"] == ["req"] + assert schema["required"] == ["group"] + + +def test_positional_argument(parser): + parser.add_argument("pos", type=int) + assert get_schema(parser)["properties"]["pos"] == {"type": "integer"} + + +def test_action_parser(parser, subparser): + subparser.add_argument("--sub", type=int, default=1) + parser.add_argument("--nest", action=ActionParser(parser=subparser)) + properties = get_schema(parser)["properties"] + assert properties["nest"]["properties"]["sub"] == {"type": "integer", "default": 1} + + +# descriptions + + +class DocumentedClass: + """Short description of the class. + + Args: + num: Description of num. + name: Description of name. + """ + + def __init__(self, num: int = 1, name: str = "x"): + pass # pragma: no cover + + +@skip_if_docstring_parser_unavailable +def test_description_from_docstrings(parser): + parser.add_class_arguments(DocumentedClass, "cls") + schema = get_schema(parser) + group = schema["properties"]["cls"] + assert group["description"] == "Short description of the class" + assert group["properties"]["num"]["description"] == "Description of num." + assert group["properties"]["name"]["description"] == "Description of name." + + +def test_description_from_help(parser): + parser.add_argument("--num", type=int, help="Description of num.") + assert get_schema(parser)["properties"]["num"]["description"] == "Description of num." + + +def test_description_suppressed_help(parser): + parser.add_argument("--num", type=int, help="==SUPPRESS==") + assert get_schema(parser)["properties"]["num"] == {"type": "integer"} + + +def test_description_percent_formatting(parser): + parser.add_argument("--num", type=int, default=2, help="Number, default %(default)s.") + assert get_schema(parser)["properties"]["num"]["description"] == "Number, default 2." + + +def test_parser_description(parser): + parser.description = "The tool description." + parser.add_argument("--num", type=int) + assert get_schema(parser)["description"] == "The tool description." + + +# simple types + + +def test_basic_types(parser): + parser.add_argument("--bool", type=bool) + parser.add_argument("--int", type=int) + parser.add_argument("--float", type=float) + parser.add_argument("--str", type=str) + parser.add_argument("--any", type=Any) + properties = get_schema(parser)["properties"] + assert properties["bool"] == {"type": "boolean"} + assert properties["int"] == {"type": "integer"} + assert properties["float"] == {"type": "number"} + assert properties["str"] == {"type": "string"} + assert properties["any"] == {} + + +def test_container_types(parser): + parser.add_argument("--dict", type=dict) + parser.add_argument("--dict_str", type=Dict[str, int]) + parser.add_argument("--dict_any", type=Dict[str, Any]) + parser.add_argument("--list", type=List[Union[float, bool]]) + parser.add_argument("--tuple", type=Tuple[int, str]) + parser.add_argument("--tuple_ellipsis", type=Tuple[int, ...]) + parser.add_argument("--set", type=set) + properties = get_schema(parser)["properties"] + assert properties["dict"] == {"type": "object"} + assert properties["dict_str"] == {"type": "object", "additionalProperties": {"type": "integer"}} + assert properties["dict_any"] == {"type": "object"} + assert properties["list"] == {"type": "array", "items": {"type": ["number", "boolean"]}} + assert properties["tuple"] == { + "type": "array", + "prefixItems": [{"type": "integer"}, {"type": "string"}], + "items": False, + "minItems": 2, + } + assert properties["tuple_ellipsis"] == {"type": "array", "items": {"type": "integer"}} + assert properties["set"] == {"type": "array", "uniqueItems": True} + + +def test_union_simple_types_merged(parser): + parser.add_argument("--val", type=Union[int, str, None]) + assert get_schema(parser)["properties"]["val"] == {"type": ["integer", "string", "null"]} + + +def test_union_with_any_keeps_informative_subschemas(parser): + # the Any member accepts anything, but the others are kept so that editors can complete them + parser.add_argument("--val", type=Union[str, Any]) + parser.add_argument("--nums", type=Union[int, float, Any]) + properties = get_schema(parser)["properties"] + assert properties["val"] == {"anyOf": [{"type": "string"}, {}]} + assert properties["nums"] == {"anyOf": [{"type": ["integer", "number"]}, {}]} + + +@skip_if_jsonschema_unavailable +def test_union_with_any_validates_anything(parser): + parser.add_argument("--val", type=Union[str, Any]) + schema = get_schema(parser) + for value in ["x", 1, None, {"a": 1}, [1, 2]]: + validate(schema, {"val": value}) + + +def test_literal_type(parser): + parser.add_argument("--val", type=Literal["a", "b", 1, None]) + assert get_schema(parser)["properties"]["val"] == {"enum": ["a", "b", 1, None]} + + +class Fruit(Enum): + apple = 1 + banana = 2 + + +def test_enum_type(parser): + parser.add_argument("--val", type=Fruit, default=Fruit.banana) + assert get_schema(parser)["properties"]["val"] == {"enum": ["apple", "banana"], "default": "banana"} + + +def test_choices(parser): + parser.add_argument("--val", choices=["a", "b"], default="a") + assert get_schema(parser)["properties"]["val"] == {"enum": ["a", "b"], "default": "a"} + + +def test_uuid_type(parser): + parser.add_argument("--uid", type=uuid.UUID) + schema = get_schema(parser)["properties"]["uid"] + assert schema["type"] == "string" + assert schema["format"] == "uuid" + assert re.match(schema["pattern"], str(uuid.uuid4())) + + +def test_path_type(parser): + parser.add_argument("--path", type=Path_fr) + assert get_schema(parser)["properties"]["path"] == {"type": "string"} + + +def test_registered_type_serialized_as_basic_type(parser): + parser.add_argument("--data", type=bytes) + assert get_schema(parser)["properties"]["data"] == {"type": "string"} + + +def test_restricted_number_types(parser): + bounded_int = restricted_number_type("BoundedInt", int, [("<=", 10), (">", 2)]) + const_int = restricted_number_type("ConstInt", int, ("==", 5)) + non_zero_int = restricted_number_type("NonZeroInt", int, ("!=", 0)) + outside_int = restricted_number_type("OutsideInt", int, [("<", 0), (">", 10)], join="or") + parser.add_argument("--positive_int", type=PositiveInt) + parser.add_argument("--unit_interval", type=ClosedUnitInterval) + parser.add_argument("--bounded_int", type=bounded_int) + parser.add_argument("--const_int", type=const_int) + parser.add_argument("--non_zero_int", type=non_zero_int) + parser.add_argument("--outside_int", type=outside_int) + properties = get_schema(parser)["properties"] + assert properties["positive_int"] == {"type": "integer", "exclusiveMinimum": 0} + assert properties["unit_interval"] == {"type": "number", "minimum": 0.0, "maximum": 1.0} + assert properties["bounded_int"] == {"type": "integer", "maximum": 10, "exclusiveMinimum": 2} + assert properties["const_int"] == {"type": "integer", "const": 5} + # restrictions without a json schema equivalent are not described + assert properties["non_zero_int"] == {"type": "integer"} + assert properties["outside_int"] == {"type": "integer"} + + +def test_restricted_string_type(parser): + parser.add_argument("--email", type=Email) + assert get_schema(parser)["properties"]["email"] == { + "type": "string", + "pattern": "^[^@ ]+@[^@ ]+\\.[^@ ]+$", + } + + +def test_nargs_list(parser): + parser.add_argument("--nums", type=int, nargs="+", default=[1]) + assert get_schema(parser)["properties"]["nums"] == { + "type": "array", + "items": {"type": "integer"}, + "minItems": 1, + "default": [1], + } + + +def test_nargs_fixed(parser): + parser.add_argument("--nums", type=int, nargs=2) + assert get_schema(parser)["properties"]["nums"] == { + "type": "array", + "items": {"type": "integer"}, + "minItems": 2, + "maxItems": 2, + } + + +# dataclasses + + +@dataclasses.dataclass +class Data: + """A dataclass. + + Args: + num: Description of num. + """ + + num: int = 1 + name: str = "x" + + +@skip_if_docstring_parser_unavailable +def test_dataclass_as_type(parser): + parser.add_argument("--data", type=Optional[Data]) + schema = get_schema(parser) + assert schema["properties"]["data"] == {"anyOf": [{"type": "null"}, {"$ref": "#/$defs/Data"}]} + assert schema["$defs"]["Data"]["description"] == "A dataclass." + assert schema["$defs"]["Data"]["additionalProperties"] is False + assert config_properties(schema["$defs"]["Data"]) == { + "num": {"type": "integer", "description": "Description of num.", "default": 1}, + "name": {"type": "string", "default": "x"}, + } + + +def test_dataclass_added_as_group(parser): + parser.add_class_arguments(Data, "data") + schema = get_schema(parser) + assert schema["properties"]["data"]["properties"]["num"]["default"] == 1 + assert "$defs" not in schema + + +@dataclasses.dataclass +class NestedData: + data: Data + items: List[Data] = dataclasses.field(default_factory=list) + + +def test_dataclass_nested_expanded_and_defs_reused(parser): + parser.add_argument("--nested", type=NestedData) + schema = get_schema(parser) + nested = schema["properties"]["nested"] + assert nested["properties"]["data"]["properties"]["num"]["default"] == 1 + assert nested["properties"]["items"] == {"type": "array", "items": {"$ref": "#/$defs/Data"}, "default": []} + assert list(schema["$defs"]) == ["Data"] + + +# subclass types + + +class Base: + """Base description. + + Args: + base: Description of base. + """ + + def __init__(self, base: str = "base"): + pass # pragma: no cover + + +class Sub(Base): + """Sub description.""" + + def __init__(self, sub: int = 1): + pass # pragma: no cover + + +class OtherSub(Base): + def __init__(self, flag: bool = False): + pass # pragma: no cover + + +class RequiredSub(Base): + def __init__(self, req: int): + pass # pragma: no cover + + +base_paths = [f"{__name__}.{n}" for n in ["Base", "Sub", "OtherSub", "RequiredSub"]] + + +def config_properties(schema: dict) -> dict: + """The properties of a config object, without the schema key that all of them accept.""" + return {key: value for key, value in schema["properties"].items() if key != "$schema"} + + +def class_path_entries(definition: dict) -> dict: + """The subschemas of a subclass definition that describe one specific class path.""" + entries = {} + for entry in definition.get("anyOf", [definition]): + class_path = entry.get("properties", {}).get("class_path", {}) + if "const" in class_path: + entries[class_path["const"]] = entry + return entries + + +def test_subclass_type(parser): + parser.add_argument("--cls", type=Base) + schema = get_schema(parser) + assert schema["properties"]["cls"] == {"$ref": "#/$defs/Base"} + entries = class_path_entries(schema["$defs"]["Base"]) + assert list(entries) == base_paths + for entry in entries.values(): + assert entry["type"] == "object" + assert entry["additionalProperties"] is False + assert entry["properties"]["dict_kwargs"] == {"type": "object"} + init_args = {path: entry["properties"]["init_args"] for path, entry in entries.items()} + assert set(config_properties(init_args[base_paths[0]])) == {"base"} + assert set(config_properties(init_args[base_paths[1]])) == {"sub"} + assert set(config_properties(init_args[base_paths[2]])) == {"flag"} + + +def test_subclass_init_args_required_only_when_a_parameter_is_required(parser): + parser.add_argument("--cls", type=Base) + entries = class_path_entries(get_schema(parser)["$defs"]["Base"]) + assert entries[f"{__name__}.Base"]["required"] == ["class_path"] + assert entries[f"{__name__}.Sub"]["required"] == ["class_path"] + assert entries[f"{__name__}.RequiredSub"]["required"] == ["class_path", "init_args"] + assert entries[f"{__name__}.RequiredSub"]["properties"]["init_args"]["required"] == ["req"] + + +def test_subclass_only_known_class_paths(parser): + # an entry that accepts any class_path would keep editors from suggesting the known ones + parser.add_argument("--cls", type=Base) + definition = get_schema(parser)["$defs"]["Base"] + assert definition["anyOf"][0] == {"type": "string"} # a class path or a path to a sub-config file + assert list(class_path_entries(definition)) == base_paths + assert len(definition["anyOf"]) == len(base_paths) + 1 + + +@skip_if_docstring_parser_unavailable +def test_subclass_descriptions(parser): + parser.add_argument("--cls", type=Base) + entries = class_path_entries(get_schema(parser)["$defs"]["Base"]) + assert entries[f"{__name__}.Base"]["description"] == "Base description." + assert entries[f"{__name__}.Sub"]["description"] == "Sub description." + assert "description" not in entries[f"{__name__}.OtherSub"] + assert entries[f"{__name__}.Base"]["properties"]["init_args"]["properties"]["base"] == { + "type": "string", + "description": "Description of base.", + "default": "base", + } + + +@skip_if_jsonschema_unavailable +def test_subclass_type_validation(parser): + parser.add_argument("--cls", type=Base) + schema = get_schema(parser) + # all forms that the parser accepts + validate(schema, {"cls": {"class_path": f"{__name__}.Sub", "init_args": {"sub": 2}}}) + validate(schema, {"cls": {"class_path": f"{__name__}.Sub"}}) + validate(schema, {"cls": {"class_path": f"{__name__}.Sub", "dict_kwargs": {"extra": 1}}}) + validate(schema, {"cls": f"{__name__}.Sub"}) + validate(schema, {"cls": "Sub"}) + validate(schema, {"cls": "sub_config.yaml"}) + # what the parser rejects + assert iter_errors(schema, {"cls": {"class_path": f"{__name__}.Sub", "init_args": {"flag": True}}}) + assert iter_errors(schema, {"cls": {"class_path": f"{__name__}.RequiredSub"}}) + assert iter_errors(schema, {"cls": {"class_path": f"{__name__}.Sub", "bogus": 1}}) + assert iter_errors(schema, {"cls": {"init_args": {"sub": 2}}}) + # stricter than the parser, so that the known subclasses are suggested and validated + assert iter_errors(schema, {"cls": {"class_path": "not_imported.Class", "init_args": {"anything": 1}}}) + + +def test_defs_discarded_when_unreachable(parser): + # the nested key turns the --cls schema into an object, so the Base def must not be kept + parser.add_argument("--cls", type=Optional[Base]) + parser.add_argument("--cls.num", type=int) + schema = get_schema(parser) + assert config_properties(schema["properties"]["cls"]) == {"num": {"type": "integer"}} + assert "$defs" not in schema + + +def test_subclass_defs_reused(parser): + parser.add_argument("--cls1", type=Base) + parser.add_argument("--cls2", type=Optional[Base]) + schema = get_schema(parser) + assert schema["properties"]["cls1"]["$ref"] == "#/$defs/Base" + assert schema["properties"]["cls2"]["anyOf"][1]["$ref"] == "#/$defs/Base" + assert list(schema["$defs"]) == ["Base"] + + +class Recursive: + def __init__(self, child: Optional["Recursive"] = None): + pass # pragma: no cover + + +def test_subclass_recursive_type(parser): + parser.add_argument("--rec", type=Recursive) + schema = get_schema(parser) + entry = class_path_entries(schema["$defs"]["Recursive"])[f"{__name__}.Recursive"] + child = entry["properties"]["init_args"]["properties"]["child"] + assert child == {"anyOf": [{"type": "null"}, {"$ref": "#/$defs/Recursive"}]} + + +def test_subclass_in_list(parser): + parser.add_argument("--items", type=List[Base]) + schema = get_schema(parser) + assert schema["properties"]["items"] == {"type": "array", "items": {"$ref": "#/$defs/Base"}} + + +def test_callable_returning_subclass(parser): + parser.add_argument("--fn", type=Callable[[int], Base]) + schema = get_schema(parser) + assert schema["properties"]["fn"] == {"anyOf": [{"type": "string"}, {"$ref": "#/$defs/Base"}]} + + +def test_callable_without_return_type(parser): + parser.add_argument("--fn", type=Callable) + assert get_schema(parser)["properties"]["fn"] == {"type": "string"} + + +def test_type_from_another_package(parser): + parser.add_argument("--cal", type=Calendar) + schema = get_schema(parser) + class_paths = set(class_path_entries(schema["$defs"]["Calendar"])) + assert {"calendar.Calendar", "calendar.TextCalendar", "calendar.HTMLCalendar"} <= class_paths + + +# subcommands + + +def test_subcommands(parser, subparser, subsubparser): + subparser.description = "The first command." + subparser.add_argument("--num", type=int, required=True) + subsubparser.add_argument("--opt", type=int, default=1) + subcommands = parser.add_subcommands() + subcommands.add_subcommand("cmd1", subparser) + subcommands.add_subcommand("cmd2", subsubparser) + schema = get_schema(parser) + assert schema["properties"]["subcommand"]["enum"] == ["cmd1", "cmd2"] + assert "can be omitted" in schema["properties"]["subcommand"]["description"] + assert schema["properties"]["cmd1"]["description"] == "The first command." + assert schema["properties"]["cmd1"]["properties"]["num"] == {"type": "integer"} + assert schema["properties"]["cmd1"]["required"] == ["num"] + # the subcommand key is optional, so nothing is required in the root + assert "required" not in schema + # only cmd1 has required keys, so only for it giving the subcommand key implies giving a block + assert schema["allOf"] == [ + { + "if": {"properties": {"subcommand": {"const": "cmd1"}}, "required": ["subcommand"]}, + "then": {"required": ["cmd1"]}, + } + ] + + +@skip_if_jsonschema_unavailable +def test_subcommands_validation(parser, subparser, subsubparser): + subparser.add_argument("--num", type=int, required=True) + subsubparser.add_argument("--opt", type=int, default=1) + subcommands = parser.add_subcommands() + subcommands.add_subcommand("cmd1", subparser) + subcommands.add_subcommand("cmd2", subsubparser) + schema = get_schema(parser) + validate(schema, {"subcommand": "cmd1", "cmd1": {"num": 1}}) + validate(schema, {"subcommand": "cmd2"}) + # the subcommand key can be omitted, be it a single block or several of them + validate(schema, {"cmd1": {"num": 1}}) + validate(schema, {"cmd1": {"num": 1}, "cmd2": {"opt": 2}}) + validate(schema, {}) + assert iter_errors(schema, {"subcommand": "cmd1"}) + assert iter_errors(schema, {"subcommand": "cmd3"}) + assert iter_errors(schema, {"subcommand": "cmd1", "cmd1": {}}) + assert iter_errors(schema, {"cmd1": {"bogus": 1}}) + + +# ActionJsonSchema + + +@skip_if_jsonschema_unavailable +def test_action_jsonschema_argument(parser): + from jsonargparse import ActionJsonSchema + + item_schema = {"type": "array", "items": {"type": "integer"}} + parser.add_argument("--op", action=ActionJsonSchema(schema=item_schema)) + assert get_schema(parser)["properties"]["op"] == item_schema + + +# defaults + + +def test_defaults_json_representation(parser): + parser.add_argument("--dict", type=Dict[str, Fruit], default={"a": Fruit.apple}) + parser.add_argument("--set", type=Set[int], default={2, 1}) + parser.add_argument("--tuple", type=Tuple[int, str], default=(1, "x")) + parser.add_argument("--cls", type=Base, default=lazy_instance(Sub, sub=3)) + parser.add_argument("--any", type=Any, default=Calendar()) + properties = get_schema(parser)["properties"] + assert properties["dict"]["default"] == {"a": "apple"} + assert properties["set"]["default"] == [1, 2] + assert properties["tuple"]["default"] == [1, "x"] + assert properties["cls"]["default"] == {"class_path": f"{__name__}.Sub", "init_args": {"sub": 3}} + assert properties["any"]["default"].startswith("