Repository: felix-martel/pydanclick

Issue:
Support ignoring unsupported fields
It would be nice to have an option to ignore pydantic fields that are not supported e.g. `@pydanclick.from_pydantic(ChatOpenAI, ignore_unsupported=True)
`. I know I can `exclude` specific fieldnames, but I'm dynamically constructing click commands from pydantic models and so I don't know which field names need to be excluded ahead of time.

This is an example of what happens currently with unsupported fields:
```sh-session
$ pip install click pydanclick langchain-openai
$ python3
Python 3.13.0 (main, Oct  7 2024, 05:02:14) [Clang 15.0.0 (clang-1500.3.9.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from langchain_openai import ChatOpenAI
>>> import click
>>> import pydanclick
>>> @click.command()
... @pydanclick.from_pydantic(ChatOpenAI)
... def cli(chatopenai): pass
...
Traceback (most recent call last):
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydantic/type_adapter.py", line 270, in _init_core_attrs
    self._core_schema = _getattr_no_parents(self._type, '__pydantic_core_schema__')
                        ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydantic/type_adapter.py", line 112, in _getattr_no_parents
    raise AttributeError(attribute)
AttributeError: __pydantic_core_schema__

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<python-input-3>", line 2, in <module>
    @pydanclick.from_pydantic(ChatOpenAI)
     ~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydanclick/main.py", line 51, in from_pydantic
    options, validator = convert_to_click(
                         ~~~~~~~~~~~~~~~~^
        model,
        ^^^^^^
    ...<6 lines>...
        extra_options=extra_options,
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydanclick/model/model_conversion.py", line 72, in convert_to_click
    qualified_names, options = convert_fields_to_options(
                               ~~~~~~~~~~~~~~~~~~~~~~~~~^
        fields,
        ^^^^^^^
    ...<3 lines>...
        extra_options=cast(Dict[DottedFieldName, _ParameterKwargs], extra_options),
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydanclick/model/field_conversion.py", line 49, in convert_fields_to_options
    option = _get_option_from_field(
        argument_name=argument_name,
    ...<6 lines>...
        option_kwargs=extra_options.get(field.dotted_name, {}),
    )
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydanclick/model/field_conversion.py", line 161, in _get_option_from_field
    "type": _get_type_from_field(field_info),
            ~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydanclick/model/type_conversion.py", line 148, in _get_type_from_field
    return _get_pydanclick_type(_get_click_type_from_field(field))
                                ~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydanclick/model/type_conversion.py", line 194, in _get_click_type_from_field
    return _create_custom_type(field)
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydanclick/model/type_conversion.py", line 200, in _create_custom_type
    type_adapter = TypeAdapter(cast(Type[Any], field.annotation))
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydantic/type_adapter.py", line 257, in __init__
    self._init_core_attrs(rebuild_mocks=False)
    ~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydantic/type_adapter.py", line 135, in wrapped
    return func(self, *args, **kwargs)
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydantic/type_adapter.py", line 277, in _init_core_attrs
    self._core_schema = _get_schema(self._type, config_wrapper, parent_depth=self._parent_depth)
                        ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydantic/type_adapter.py", line 95, in _get_schema
    schema = gen.generate_schema(type_)
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydantic/_internal/_generate_schema.py", line 655, in generate_schema
    schema = self._generate_schema_inner(obj)
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydantic/_internal/_generate_schema.py", line 929, in _generate_schema_inner
    return self.match_type(obj)
           ~~~~~~~~~~~~~~~^^^^^
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydantic/_internal/_generate_schema.py", line 1029, in match_type
    return self._match_generic_type(obj, origin)
           ~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydantic/_internal/_generate_schema.py", line 1058, in _match_generic_type
    return self._union_schema(obj)
           ~~~~~~~~~~~~~~~~~~^^^^^
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydantic/_internal/_generate_schema.py", line 1378, in _union_schema
    choices.append(self.generate_schema(arg))
                   ~~~~~~~~~~~~~~~~~~~~^^^^^
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydantic/_internal/_generate_schema.py", line 655, in generate_schema
    schema = self._generate_schema_inner(obj)
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydantic/_internal/_generate_schema.py", line 929, in _generate_schema_inner
    return self.match_type(obj)
           ~~~~~~~~~~~~~~~^^^^^
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydantic/_internal/_generate_schema.py", line 1038, in match_type
    return self._unknown_type_schema(obj)
           ~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^
  File "/Users/aw/Projects/rectalogic/elelem/t/venv/lib/python3.13/site-packages/pydantic/_internal/_generate_schema.py", line 558, in _unknown_type_schema
    raise PydanticSchemaGenerationError(
    ...<7 lines>...
    )
pydantic.errors.PydanticSchemaGenerationError: Unable to generate pydantic-core schema for <class 'langchain_core.caches.BaseCache'>. Set `arbitrary_types_allowed=True` in the model_config to ignore this error or implement `__get_pydantic_core_schema__` on your type to fully support it.

If you got this error by calling handler(<some type>) within `__get_pydantic_core_schema__` then you likely need to call `handler.generate_schema(<some type>)` since we do not call `__get_pydantic_core_schema__` on `<some type>` otherwise to avoid infinite recursion.

For further information visit https://errors.pydantic.dev/2.9/u/schema-for-unknown-type
>>>
>>> ChatOpenAI.model_config
{'extra': 'ignore', 'protected_namespaces': (), 'arbitrary_types_allowed': True, 'populate_by_name': True}
>>> from langchain_core.caches import BaseCache
>>> BaseCache.model_config
Traceback (most recent call last):
  File "<python-input-5>", line 1, in <module>
    BaseCache.model_config
AttributeError: type object 'BaseCache' has no attribute 'model_config'
```

Discussion:

Patch:
diff --git a/pydanclick/main.py b/pydanclick/main.py
index bf7ae09..be83915 100644
--- a/pydanclick/main.py
+++ b/pydanclick/main.py
@@ -22,6 +22,7 @@ def from_pydantic(
     parse_docstring: bool = True,
     docstring_style: Literal["google", "numpy", "sphinx"] = "google",
     extra_options: Optional[Dict[str, _ParameterKwargs]] = None,
+    ignore_unsupported: Optional[bool] = False,
 ) -> Callable[[Callable[..., T]], Callable[..., T]]:
     """Decorator to add fields from a Pydantic model as options to a Click command.
 
@@ -37,6 +38,7 @@ def from_pydantic(
             documentation to the Click `help` option
         docstring_style: style of the docstring (`google`, `numpy` or `sphinx`). Ignored if `parse_docstring` is False
         extra_options: a mapping from field names to a dictionary of options passed to the `click.option()` function
+        ignore_unsupported: ignore unsupported model fields instead of raising
 
     Returns:
         a decorator that adds options to a function
@@ -57,6 +59,7 @@ def from_pydantic(
         parse_docstring=parse_docstring,
         docstring_style=docstring_style,
         extra_options=extra_options,
+        ignore_unsupported=ignore_unsupported,
     )
 
     def wrapper(f: Callable[..., T]) -> Callable[..., T]:
diff --git a/pydanclick/model/field_conversion.py b/pydanclick/model/field_conversion.py
index e8c0e36..7b4614a 100644
--- a/pydanclick/model/field_conversion.py
+++ b/pydanclick/model/field_conversion.py
@@ -3,6 +3,7 @@
 from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, Union
 
 import click
+from pydantic import PydanticUserError
 from pydantic.fields import FieldInfo
 from pydantic_core import PydanticUndefined
 
@@ -20,6 +21,7 @@ def convert_fields_to_options(
     aliases: Optional[Dict[DottedFieldName, OptionName]] = None,
     shorten: Optional[Dict[DottedFieldName, OptionName]] = None,
     extra_options: Optional[Dict[DottedFieldName, _ParameterKwargs]] = None,
+    ignore_unsupported: Optional[bool] = False,
 ) -> Tuple[Dict[ArgumentName, DottedFieldName], List[click.Option]]:
     """Convert Pydantic fields to Click options.
 
@@ -30,6 +32,7 @@ def convert_fields_to_options(
         shorten: short option names for specific fields, as a mapping from dotted field names to 1-letter option names
         extra_options: extra options to pass to `click.Option` for specific fields, as a mapping from dotted field names
             to option dictionary
+        ignore_unsupported: ignore unsupported model fields instead of raising
 
     Returns:
         a pair `(qualified_names, options)` where `qualified_names` is a mapping from argument names to dotted field
@@ -46,18 +49,23 @@ def convert_fields_to_options(
     qualified_names = {}
     for field in fields:
         argument_name = ArgumentName(argument_prefix + "_".join(field.parents))
-        option = _get_option_from_field(
-            argument_name=argument_name,
-            option_name=get_option_name(
-                field.dotted_name, aliases=aliases, is_boolean=field.is_boolean_flag, prefix=option_prefix
-            ),
-            field_info=field.field_info,
-            documentation=field.field_info.description or field.documentation,
-            short_name=shorten.get(field.dotted_name, None),
-            option_kwargs=extra_options.get(field.dotted_name, {}),
-        )
-        options.append(option)
-        qualified_names[argument_name] = field.dotted_name
+        try:
+            option = _get_option_from_field(
+                argument_name=argument_name,
+                option_name=get_option_name(
+                    field.dotted_name, aliases=aliases, is_boolean=field.is_boolean_flag, prefix=option_prefix
+                ),
+                field_info=field.field_info,
+                documentation=field.field_info.description or field.documentation,
+                short_name=shorten.get(field.dotted_name, None),
+                option_kwargs=extra_options.get(field.dotted_name, {}),
+            )
+            options.append(option)
+            qualified_names[argument_name] = field.dotted_name
+        except PydanticUserError as e:
+            if ignore_unsupported and e.code == "schema-for-unknown-type":
+                continue
+            raise
     return qualified_names, options
 
 
diff --git a/pydanclick/model/model_conversion.py b/pydanclick/model/model_conversion.py
index a358b15..7c6538f 100644
--- a/pydanclick/model/model_conversion.py
+++ b/pydanclick/model/model_conversion.py
@@ -25,6 +25,7 @@ def convert_to_click(
     parse_docstring: bool = True,
     docstring_style: Literal["google", "numpy", "sphinx"] = "google",
     extra_options: Optional[Dict[str, _ParameterKwargs]] = None,
+    ignore_unsupported: Optional[bool] = False,
 ) -> Tuple[List[click.Option], Callable[..., M]]:
     """Extract Click options from a Pydantic model.
 
@@ -56,6 +57,7 @@ def convert_to_click(
         docstring_style: docstring style of the model. Only used if `parse_docstring=True`
         extra_options: extra options to pass to `click.Option` for specific fields, as a mapping from dotted field names
             to option dictionary
+        ignore_unsupported: ignore unsupported model fields instead of raising
 
     Returns:
         a pair `(options, validate)` where `options` is the list of Click options extracted from the model, and
@@ -75,6 +77,7 @@ def convert_to_click(
         aliases=cast(Optional[Dict[DottedFieldName, OptionName]], rename),
         shorten=cast(Optional[Dict[DottedFieldName, OptionName]], shorten),
         extra_options=cast(Dict[DottedFieldName, _ParameterKwargs], extra_options),
+        ignore_unsupported=ignore_unsupported,
     )
     validator = functools.partial(model_validate_kwargs, model=model, qualified_names=qualified_names)
     return options, validator


Test patch:
diff --git a/tests/test_field_conversion.py b/tests/test_field_conversion.py
index dc93d93..d0c94f2 100644
--- a/tests/test_field_conversion.py
+++ b/tests/test_field_conversion.py
@@ -1,5 +1,6 @@
 import click
 import pytest
+from pydantic import PydanticUserError
 from pydantic.fields import FieldInfo
 
 from pydanclick.model.field_collection import _Field
@@ -61,4 +62,19 @@ def test_get_option_name(name, aliases, prefix, is_boolean, expected_result):
     assert get_option_name(name, aliases=aliases, prefix=prefix, is_boolean=is_boolean) == expected_result
 
 
+def test_unsupported_field():
+    class Unsupported:
+        pass
+
+    unsupported_field = FieldInfo(annotation=Unsupported)
+    fields = [
+        _Field(name="a", dotted_name="foo.a", field_info=unsupported_field, parents=("foo", "a")),
+    ]
+    with pytest.raises(PydanticUserError):
+        _, options = convert_fields_to_options(fields, ignore_unsupported=False)
+
+    _, options = convert_fields_to_options(fields, ignore_unsupported=True)
+    assert options == []
+
+
 # TODO: ensure prefix is correctly preprended to the argument name