From ed7ca1e58bb50bf793398c989577d392a3386826 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Sat, 8 Nov 2025 12:57:07 -0500 Subject: [PATCH 1/2] Support enums --- hatch_build/cli.py | 6 ++++++ hatch_build/tests/test_cli_model.py | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/hatch_build/cli.py b/hatch_build/cli.py index 13e5894..a68157d 100644 --- a/hatch_build/cli.py +++ b/hatch_build/cli.py @@ -1,4 +1,5 @@ from argparse import ArgumentParser +from enum import Enum from logging import Formatter, StreamHandler, getLogger from pathlib import Path from typing import TYPE_CHECKING, Callable, Dict, List, Literal, Optional, Tuple, Type, Union, get_args, get_origin @@ -98,6 +99,11 @@ def _recurse_add_fields(parser: ArgumentParser, model: Union["BaseModel", Type[" except TypeError: # TODO: handle more complex types if needed parser.add_argument(arg_name, type=str, default=default_value) + elif isinstance(field_type, type) and issubclass(field_type, Enum): + ############# + # MARK: Enum + enum_choices = [e.value for e in field_type] + parser.add_argument(arg_name, type=type(enum_choices[0]), choices=enum_choices, default=default_value) elif isinstance(field_type, type) and issubclass(field_type, Path): ############# # MARK: Path diff --git a/hatch_build/tests/test_cli_model.py b/hatch_build/tests/test_cli_model.py index c14c045..2c0de3b 100644 --- a/hatch_build/tests/test_cli_model.py +++ b/hatch_build/tests/test_cli_model.py @@ -1,4 +1,5 @@ import sys +from enum import Enum from pathlib import Path from typing import Dict, List, Literal, Optional from unittest.mock import patch @@ -8,6 +9,11 @@ from hatch_build.cli import hatchling, parse_extra_args_model +class MyEnum(Enum): + OPTION_A = "option_a" + OPTION_B = "option_b" + + class SubModel(BaseModel, validate_assignment=True): sub_arg: int = 42 sub_arg_with_value: str = "sub_default" @@ -19,6 +25,7 @@ class MyTopLevelModel(BaseModel, validate_assignment=True): extra_arg_with_value_equals: Optional[str] = "default_equals" extra_arg_literal: Literal["a", "b", "c"] = "a" + enum_arg: MyEnum = MyEnum.OPTION_A list_arg: List[int] = [1, 2, 3] dict_arg: Dict[str, str] = {} dict_arg_default_values: Dict[str, str] = {"existing-key": "existing-value"} @@ -33,6 +40,8 @@ class MyTopLevelModel(BaseModel, validate_assignment=True): submodel_dict: Dict[str, SubModel] = {} submodel_dict_instanced: Dict[str, SubModel] = {"a": SubModel()} + unsupported_literal: Literal[b"test"] = b"test" + class TestCLIMdel: def test_get_arg_from_model(self): @@ -49,6 +58,8 @@ def test_get_arg_from_model(self): "--extra-arg-not-in-parser", "--extra-arg-literal", "b", + "--enum-arg", + "option_b", "--list-arg", "1,2,3", "--dict-arg", @@ -80,6 +91,7 @@ def test_get_arg_from_model(self): assert model.extra_arg_with_value == "value" assert model.extra_arg_with_value_equals == "value2" assert model.extra_arg_literal == "b" + assert model.enum_arg == MyEnum.OPTION_B assert model.list_arg == [1, 2, 3] assert model.dict_arg == {"key1": "value1", "key2": "value2"} assert model.dict_arg_default_values == {"existing-key": "new-value"} From 873ff01661f9ea9f6863ecb46ef9dafc450ea66f Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Sat, 8 Nov 2025 15:26:51 -0500 Subject: [PATCH 2/2] Support various enum, list, and dict variants --- hatch_build/cli.py | 230 +++++++++++++++++++++++----- hatch_build/tests/test_cli_model.py | 134 +++++++++++----- 2 files changed, 283 insertions(+), 81 deletions(-) diff --git a/hatch_build/cli.py b/hatch_build/cli.py index a68157d..8616b53 100644 --- a/hatch_build/cli.py +++ b/hatch_build/cli.py @@ -1,3 +1,4 @@ +import sys from argparse import ArgumentParser from enum import Enum from logging import Formatter, StreamHandler, getLogger @@ -16,11 +17,28 @@ ) _extras = None -_log = getLogger(__name__) -_handler = StreamHandler() -_formatter = Formatter("[%(asctime)s][%(name)s][%(levelname)s]: %(message)s", datefmt="%Y-%m-%dT%H:%M:%S%z") -_handler.setFormatter(_formatter) -_log.addHandler(_handler) +_log = None + + +def _initlog(level: str = "WARNING"): + global _log + _log = getLogger(__name__) + _handler = StreamHandler(stream=sys.stderr) + _formatter = Formatter("[%(asctime)s][%(name)s][%(levelname)s]: %(message)s", datefmt="%Y-%m-%dT%H:%M:%S%z") + _handler.setFormatter(_formatter) + _log.addHandler(_handler) + _log.setLevel(level) + + +_initlog() + + +def _add_argument(parser: ArgumentParser, name: str, arg_type: type, default_value, **kwargs): + _log.debug(f"Adding argument: {name:<75} - {str(arg_type):<10} - {str(default_value):<10}") + if "action" in kwargs: + parser.add_argument(name, default=default_value, action=kwargs["action"]) + else: + parser.add_argument(name, type=arg_type, default=default_value, **kwargs) def parse_extra_args(subparser: Optional[ArgumentParser] = None) -> List[str]: @@ -30,13 +48,27 @@ def parse_extra_args(subparser: Optional[ArgumentParser] = None) -> List[str]: return vars(kwargs), extras +def _is_supported_type(field_type: type) -> bool: + if not isinstance(field_type, type): + return False + if get_origin(field_type) is Optional: + field_type = get_args(field_type)[0] + elif get_origin(field_type) is Union: + non_none_types = [t for t in get_args(field_type) if t is not type(None)] + if len(non_none_types) == 1: + field_type = non_none_types[0] + elif get_origin(field_type) is Literal: + return all(isinstance(arg, (str, int, float, bool, Enum)) for arg in get_args(field_type)) + return field_type in (str, int, float, bool) or issubclass(field_type, Enum) + + def _recurse_add_fields(parser: ArgumentParser, model: Union["BaseModel", Type["BaseModel"]], prefix: str = ""): from pydantic import BaseModel from pydantic_core import PydanticUndefined # Model is required if model is None: - raise ValueError("Model instance cannot be None") + raise ValueError("Model instance cannot be None") # coverage: ignore # Extract the fields from a model instance or class if isinstance(model, type): @@ -78,37 +110,41 @@ def _recurse_add_fields(parser: ArgumentParser, model: Union["BaseModel", Type[" # Handled types # - bool, str, int, float + # - Enum # - Path # - Nested BaseModel # - Literal # - List[T] - # - where T is bool, str, int, float + # - where T is bool, str, int, float, Enum # - List[BaseModel] where we have an instance to recurse into - # - Dict[str, T] - # - where T is bool, str, int, float - # - Dict[str, BaseModel] where we have an instance to recurse into + # - Dict[K, V] + # - where K is bool, str, int, float, Enum + # - where V is bool, str, int, float, Enum, BaseModel + # - Dict[K, BaseModel] where we have an instance to recurse into if field_type is bool: ############# # MARK: bool - parser.add_argument(arg_name, action="store_true", default=default_value) + _add_argument( + parser=parser, name=arg_name, arg_type=bool, default_value=default_value, action="store_true" if not default_value else "store_false" + ) elif field_type in (str, int, float): ######################## # MARK: str, int, float try: - parser.add_argument(arg_name, type=field_type, default=default_value) + _add_argument(parser=parser, name=arg_name, arg_type=field_type, default_value=default_value) except TypeError: # TODO: handle more complex types if needed - parser.add_argument(arg_name, type=str, default=default_value) + _add_argument(parser=parser, name=arg_name, arg_type=str, default_value=default_value) elif isinstance(field_type, type) and issubclass(field_type, Enum): ############# # MARK: Enum enum_choices = [e.value for e in field_type] - parser.add_argument(arg_name, type=type(enum_choices[0]), choices=enum_choices, default=default_value) + _add_argument(parser=parser, name=arg_name, arg_type=type(enum_choices[0]), default_value=default_value, choices=enum_choices) elif isinstance(field_type, type) and issubclass(field_type, Path): ############# # MARK: Path # Promote to/from string - parser.add_argument(arg_name, type=str, default=str(default_value) if isinstance(default_value, Path) else None) + _add_argument(parser=parser, name=arg_name, arg_type=str, default_value=str(default_value) if isinstance(default_value, Path) else None) elif isinstance(field_instance, BaseModel): ############################ # MARK: instance(BaseModel) @@ -123,17 +159,17 @@ def _recurse_add_fields(parser: ArgumentParser, model: Union["BaseModel", Type[" ################ # MARK: Literal literal_args = get_args(field_type) - if not all(isinstance(arg, (str, int, float, bool)) for arg in literal_args): + if not all(isinstance(arg, (str, int, float, bool, Enum)) for arg in literal_args): # Only support simple literal types for now _log.warning(f"Only Literal types of str, int, float, or bool are supported - field `{field_name}` got {literal_args}") continue #################################### # MARK: Literal[str|int|float|bool] - parser.add_argument(arg_name, type=type(literal_args[0]), choices=literal_args, default=default_value) + _add_argument(parser=parser, name=arg_name, arg_type=type(literal_args[0]), default_value=default_value) elif get_origin(field_type) in (list, List): ################ # MARK: List[T] - if get_args(field_type) and get_args(field_type)[0] not in (str, int, float, bool): + if get_args(field_type) and not _is_supported_type(get_args(field_type)[0]): # If theres already something here, we can procede by adding the command with a positional indicator if field_instance: ######################## @@ -147,22 +183,64 @@ def _recurse_add_fields(parser: ArgumentParser, model: Union["BaseModel", Type[" continue ################################# # MARK: List[str|int|float|bool] - parser.add_argument(arg_name, type=str, default=",".join(map(str, default_value)) if isinstance(field, str) else None) + _add_argument( + parser=parser, name=arg_name, arg_type=str, default_value=",".join(map(str, default_value)) if isinstance(field, str) else None + ) elif get_origin(field_type) in (dict, Dict): ###################### # MARK: Dict[str, T] key_type, value_type = get_args(field_type) - if key_type not in (str, int, float, bool) and not ( - get_origin(key_type) is Literal and all(isinstance(arg, (str, int, float, bool)) for arg in get_args(key_type)) - ): - # Check Key type, must be str, int, float, bool - _log.warning(f"Only dicts with str keys are supported - field `{field_name}` got key type {key_type}") + if not _is_supported_type(key_type): + # Check Key type, must be str, int, float, bool, enum + _log.warning(f"Only dicts with str, int, float, bool, or enum keys are supported - field `{field_name}` got key type {key_type}") continue - if value_type not in (str, int, float, bool) and not field_instance: + if isinstance(key_type, type) and issubclass(key_type, Enum): + # If key is enum, we can fully enumerate + + if not _is_supported_type(value_type) and not (isinstance(value_type, type) and issubclass(value_type, BaseModel)): + # Unsupported value type + _log.warning( + f"Only dicts with str, int, float, bool, enum, or BaseModel values are supported - field `{field_name}` got value type {value_type}" + ) + continue + + if isinstance(value_type, type) and issubclass(value_type, BaseModel): + # Add each submodel recursively, if it exists on the instance + ############################# + # MARK: Dict[Enum, BaseModel] + for enum_key in key_type: + if not field_instance or enum_key not in field_instance: + continue + _recurse_add_fields(parser, (field_instance or {}).get(enum_key, value_type), prefix=f"{field_name}.{enum_key.name}.") + _recurse_add_fields(parser, (field_instance or {}).get(enum_key, value_type), prefix=f"{field_name}.{enum_key.value}.") + + elif _is_supported_type(value_type): + # Add directly + ################### + # MARK: Dict[Enum, str|int|float|bool] + + for enum_key in key_type: + value = (field_instance or {}).get(enum_key, default_value.get(enum_key) if default_value else None) + _add_argument( + parser=parser, + name=f"{arg_name}.{enum_key.name}", + arg_type=value_type, + default_value=value, + ) + _add_argument( + parser=parser, + name=f"{arg_name}.{enum_key.value}", + arg_type=value_type, + default_value=value, + ) + + if not _is_supported_type(value_type) and not field_instance: # Check Value type, must be str, int, float, bool if an instance isnt provided - _log.warning(f"Only dicts with str values are supported - field `{field_name}` got value type {value_type}") + _log.warning( + f"Only dicts with str, int, float, bool, or enum values are supported - field `{field_name}` got value type {value_type}" + ) continue # If theres already something here, we can procede by adding the command by keyword @@ -171,30 +249,47 @@ def _recurse_add_fields(parser: ArgumentParser, model: Union["BaseModel", Type[" ############################# # MARK: Dict[str, BaseModel] for key, value in field_instance.items(): + if isinstance(key, Enum): + # Already handled above + continue _recurse_add_fields(parser, value, prefix=f"{field_name}.{key}.") continue + # If we have mixed, we don't support elif any(isinstance(v, BaseModel) for v in field_instance.values()): _log.warning(f"Mixed dict value types are not supported - field `{field_name}` has mixed BaseModel and non-BaseModel values") continue + # If we have non BaseModel values, we can still add a parser by route - if all(isinstance(v, (str, int, float, bool)) for v in field_instance.values()): + if all(isinstance(v, (str, int, float, bool, Enum)) for v in field_instance.values()): # We can set "known" values here for key, value in field_instance.items(): + if isinstance(key, Enum): + # Already handled above + continue + if isinstance(value, Enum): + value = value.name ########################################## # MARK: Dict[str, str|int|float|bool] - parser.add_argument( - f"{arg_name}.{key}", - type=type(value), - default=value, + _add_argument( + parser=parser, + name=f"{arg_name}.{key}", + arg_type=type(value), + default_value=value, ) # NOTE: don't continue to allow adding the full setter below + # Finally add the full setter for unknown values ########################################## - # MARK: Dict[str, str|int|float|bool|str] - parser.add_argument( - arg_name, type=str, default=",".join(f"{k}={v}" for k, v in default_value.items()) if isinstance(default_value, dict) else None - ) + # MARK: Dict[str, str|int|float|bool|str|Enum] + defaults = [] + for k, v in (default_value or {}).items(): + if isinstance(k, Enum): + defaults.append(f"{k.name}={v}") + defaults.append(f"{k.value}={v}") + else: + defaults.append(f"{k}={v}") + _add_argument(parser=parser, name=arg_name, arg_type=str, default_value=",".join(defaults) if defaults else None) else: _log.warning(f"Unsupported field type for argument '{field_name}': {field_type}") return parser @@ -212,6 +307,7 @@ def parse_extra_args_model(model: "BaseModel"): # Parse the extra args and update the model args, kwargs = parse_extra_args(parser) + for key, value in args.items(): # Handle nested fields if "." in key: @@ -229,14 +325,27 @@ def parse_extra_args_model(model: "BaseModel"): # Should never be out of bounds, but check to be sure if index >= len(sub_model): - raise IndexError(f"Index {index} out of range for field '{parts[i - 1]}' on model '{parent_model.__class__.__name__}'") + raise IndexError( + f"Index {index} out of range for field '{parts[i - 1]}' on model '{parent_model.__class__.__name__}'" + ) # coverage: ignore # Grab the model instance from the list model_to_set = sub_model[index] elif isinstance(sub_model, dict): # Dict key + + # If its an enum, we may need to match by name or value + for k in sub_model.keys(): + if isinstance(k, Enum): + if k.name == part or k.value == part: + part = k + break + + # Should always exist, but check to be sure if part not in sub_model: - raise KeyError(f"Key '{part}' not found for field '{parts[i - 1]}' on model '{parent_model.__class__.__name__}'") + raise KeyError( + f"Key '{part}' not found for field '{parts[i - 1]}' on model '{parent_model.__class__.__name__}'" + ) # coverage: ignore # Grab the model instance from the dict model_to_set = sub_model[part] @@ -276,6 +385,9 @@ def parse_extra_args_model(model: "BaseModel"): if not isinstance(model_to_set, BaseModel): if isinstance(model_to_set, dict): + if value is None: + continue + # We allow setting dict values directly # Grab the dict from the parent model, set the value, and continue if key in model_to_set: @@ -283,9 +395,29 @@ def parse_extra_args_model(model: "BaseModel"): elif key.replace("_", "-") in model_to_set: # Argparse converts dashes back to underscores, so undo model_to_set[key.replace("_", "-")] = value + elif key in [k.name for k in model_to_set.keys() if isinstance(k, Enum)]: + enum_key = [k for k in model_to_set.keys() if isinstance(k, Enum) and k.name == key][0] + model_to_set[enum_key] = value + elif key in [k.value for k in model_to_set.keys() if isinstance(k, Enum)]: + enum_key = [k for k in model_to_set.keys() if isinstance(k, Enum) and k.value == key][0] + model_to_set[enum_key] = value + elif ( + get_args(parent_model.__class__.model_fields[part].annotation) + and isinstance(get_args(parent_model.__class__.model_fields[part].annotation)[0], type) + and issubclass(get_args(parent_model.__class__.model_fields[part].annotation)[0], Enum) + ): + enum_type = get_args(parent_model.__class__.model_fields[part].annotation)[0] + for enum_key in enum_type: + if enum_key.name == key or enum_key.value == key: + key = enum_key + break + else: + raise KeyError(f"Key '{key}' not found for dict field on model '{parent_model.__class__.__name__}'") # coverage: ignore + model_to_set[key] = value else: - # Raise - raise KeyError(f"Key '{key}' not found in dict field on model '{parent_model.__class__.__name__}'") + raise KeyError(f"Key '{key}' not found for dict field on model '{parent_model.__class__.__name__}'") # coverage: ignore + + _log.debug(f"Set dict key '{key}' on parent model '{parent_model.__class__.__name__}' with value '{value}'") # Now adjust our variable accounting to set the whole dict back on the parent model, # allowing us to trigger any validation @@ -293,13 +425,15 @@ def parse_extra_args_model(model: "BaseModel"): value = model_to_set model_to_set = parent_model else: - _log.warning(f"Cannot set field '{key}' on non-BaseModel instance of type '{type(model_to_set).__name__}'") + _log.warning(f"Cannot set field '{key}' on non-BaseModel instance of type '{type(model_to_set).__name__}'. value: `{value}`") continue # Grab the field from the model class and make a type adapter field = model_to_set.__class__.model_fields[key] adapter = TypeAdapter(field.annotation) + _log.debug(f"Setting field '{key}' on model '{model_to_set.__class__.__name__}' with raw value '{value}'") + # Convert the value using the type adapter if get_origin(field.annotation) in (list, List): value = value or "" @@ -324,9 +458,13 @@ def parse_extra_args_model(model: "BaseModel"): for item in dict_items: if item: k, v = item.split("=", 1) + # If the key type is an enum, convert dict_value[k] = v + # Grab any previously existing dict to preserve other keys existing_dict = getattr(model_to_set, key, {}) or {} + _log.debug(f"Existing dict for field '{key}': {existing_dict}") + _log.debug(f"New dict items for field '{key}': {dict_value}") dict_value.update(existing_dict) value = dict_value else: @@ -334,10 +472,22 @@ def parse_extra_args_model(model: "BaseModel"): raise ValueError(f"Cannot convert value '{value}' to dict for field '{key}'") try: if value is not None: + # Post process and convert keys if needed + # pydantic shouldve done this automatically, but alas + if isinstance(value, dict) and get_args(field.annotation): + key_type = get_args(field.annotation)[0] + if isinstance(key_type, type) and issubclass(key_type, Enum): + for enum_key in key_type: + if enum_key.name in value: + v = value.pop(enum_key.name) + if value.get(enum_key) is None: + value[enum_key] = v + value = adapter.validate_python(value) # Set the value on the model setattr(model_to_set, key, value) + except ValidationError: _log.warning(f"Failed to validate field '{key}' with value '{value}' for model '{model_to_set.__class__.__name__}'") continue diff --git a/hatch_build/tests/test_cli_model.py b/hatch_build/tests/test_cli_model.py index 2c0de3b..7ff9451 100644 --- a/hatch_build/tests/test_cli_model.py +++ b/hatch_build/tests/test_cli_model.py @@ -1,22 +1,25 @@ import sys from enum import Enum +from io import StringIO from pathlib import Path -from typing import Dict, List, Literal, Optional +from typing import Dict, List, Literal, Optional, Union from unittest.mock import patch from pydantic import BaseModel -from hatch_build.cli import hatchling, parse_extra_args_model +from hatch_build.cli import _initlog, hatchling, parse_extra_args_model class MyEnum(Enum): OPTION_A = "option_a" OPTION_B = "option_b" + OPTION_C = "option_c" class SubModel(BaseModel, validate_assignment=True): sub_arg: int = 42 sub_arg_with_value: str = "sub_default" + sub_arg_enum: MyEnum = MyEnum.OPTION_A class MyTopLevelModel(BaseModel, validate_assignment=True): @@ -31,6 +34,11 @@ class MyTopLevelModel(BaseModel, validate_assignment=True): dict_arg_default_values: Dict[str, str] = {"existing-key": "existing-value"} path_arg: Path = Path(".") + list_enum: List[MyEnum] = [MyEnum.OPTION_A] + dict_enum: Dict[str, MyEnum] = {"first": MyEnum.OPTION_A} + dict_enum_key: Dict[MyEnum, str] = {MyEnum.OPTION_A: "first"} + dict_enum_key_model_value: Dict[MyEnum, SubModel] = {MyEnum.OPTION_A: SubModel()} + submodel: SubModel submodel2: SubModel = SubModel() submodel3: Optional[SubModel] = None @@ -41,61 +49,88 @@ class MyTopLevelModel(BaseModel, validate_assignment=True): submodel_dict_instanced: Dict[str, SubModel] = {"a": SubModel()} unsupported_literal: Literal[b"test"] = b"test" + unsupported_dict: Dict[SubModel, str] = {} + unsupported_dict_mixed_types: Dict[str, Union[str, SubModel]] = {} + unsupported_random_type: Optional[set] = None class TestCLIMdel: def test_get_arg_from_model(self): - with patch.object( - sys, - "argv", - [ - "hatch-build", - "--", - "--extra-arg", - "--extra-arg-with-value", - "value", - "--extra-arg-with-value-equals=value2", - "--extra-arg-not-in-parser", - "--extra-arg-literal", - "b", - "--enum-arg", - "option_b", - "--list-arg", - "1,2,3", - "--dict-arg", - "key1=value1,key2=value2", - "--dict-arg-default-values.existing-key", - "new-value", - "--path-arg", - "/some/path", - "--submodel.sub-arg", - "100", - "--submodel.sub-arg-with-value", - "sub_value", - "--submodel2.sub-arg", - "200", - "--submodel2.sub-arg-with-value", - "sub_value2", - "--submodel3.sub-arg", - "300", - "--submodel-list-instanced.0.sub-arg", - "400", - "--submodel-dict-instanced.a.sub-arg", - "500", - ], + with ( + patch.object( + sys, + "argv", + [ + "hatch-build", + "--", + "--extra-arg", + "--extra-arg-with-value", + "value", + "--extra-arg-with-value-equals=value2", + "--extra-arg-not-in-parser", + "--extra-arg-literal", + "b", + "--enum-arg", + "option_b", + "--list-arg", + "1,2,3", + "--dict-arg", + "key1=value1,key2=value2", + "--dict-arg-default-values.existing-key", + "new-value", + "--path-arg", + "/some/path", + "--list-enum", + "option_a,option_b", + "--dict-enum.first", + "option_b", + "--dict-enum-key.option_b", + "second", + "--dict-enum-key.OPTION_C", + "third", + "--dict-enum-key-model-value.option-a.sub-arg", + "600", + "--submodel.sub-arg", + "100", + "--submodel.sub-arg-with-value", + "sub_value", + "--submodel2.sub-arg", + "200", + "--submodel2.sub-arg-with-value", + "sub_value2", + "--submodel3.sub-arg", + "300", + "--submodel-list-instanced.0.sub-arg", + "400", + "--submodel-dict-instanced.a.sub-arg", + "500", + ], + ), + patch("sys.stderr", new_callable=StringIO) as mock_stderr, ): + _initlog("DEBUG") assert hatchling() == 0 model, extras = parse_extra_args_model(MyTopLevelModel(submodel=SubModel())) + assert len(extras) == 1 + assert "--extra-arg-not-in-parser" in extras + assert model.extra_arg is True assert model.extra_arg_with_value == "value" assert model.extra_arg_with_value_equals == "value2" assert model.extra_arg_literal == "b" + assert model.enum_arg == MyEnum.OPTION_B assert model.list_arg == [1, 2, 3] assert model.dict_arg == {"key1": "value1", "key2": "value2"} assert model.dict_arg_default_values == {"existing-key": "new-value"} assert model.path_arg == Path("/some/path") + + assert model.list_enum == [MyEnum.OPTION_A, MyEnum.OPTION_B] + assert model.dict_enum == {"first": MyEnum.OPTION_B} + assert model.dict_enum_key == {MyEnum.OPTION_A: "first", MyEnum.OPTION_B: "second", MyEnum.OPTION_C: "third"} + assert model.dict_enum_key_model_value[MyEnum.OPTION_A].sub_arg == 600 + assert model.submodel.sub_arg == 100 assert model.submodel.sub_arg_with_value == "sub_value" assert model.submodel2.sub_arg == 200 @@ -104,4 +139,21 @@ def test_get_arg_from_model(self): assert model.submodel_list_instanced[0].sub_arg == 400 assert model.submodel_dict_instanced["a"].sub_arg == 500 - assert "--extra-arg-not-in-parser" in extras + stderr = mock_stderr.getvalue() + for text in ( + "[sdist]\ndist/hatch_build-0.3.1.tar.gz", + "[wheel]\ndist/hatch_build-0.3.1-py3-none-any.whl", + "[hatch_build.cli][WARNING]: Only lists of str, int, float, or bool are supported - field `submodel_list` got ", + "[hatch_build.cli][WARNING]: Only dicts with str, int, float, bool, or enum values are supported - field `submodel_dict` got value type ", + "[hatch_build.cli][WARNING]: Only Literal types of str, int, float, or bool are supported - field `unsupported_literal` got (b'test',)", + "[hatch_build.cli][WARNING]: Only dicts with str, int, float, bool, or enum keys are supported - field `unsupported_dict` got key type ", + "[hatch_build.cli][WARNING]: Only dicts with str, int, float, bool, or enum values are supported - field `unsupported_dict_mixed_types` got value type typing.Union[str, test_cli_model.SubModel]", + "[hatch_build.cli][WARNING]: Unsupported field type for argument 'unsupported_random_type': ", + ): + assert text in stderr + stderr = stderr.replace(text, "") + if "[hatch_build.cli][WARNING]" in stderr.strip(): + for line in stderr.strip().splitlines(): + if "[hatch_build.cli][WARNING]" in line: + print("UNEXPECTED WARNING:", line) + assert False