Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: strategy for using class methods #405

Merged
merged 1 commit into from Aug 24, 2023

Conversation

pohlt
Copy link
Contributor

@pohlt pohlt commented Aug 3, 2023

Here's a new PR for #394.

  • Code
  • Tests
  • Docs

@pohlt pohlt force-pushed the class_methods_strategy branch 2 times, most recently from f8aed12 to 57c81c5 Compare August 3, 2023 10:51
@pohlt pohlt marked this pull request as ready for review August 3, 2023 10:51
@Tinche
Copy link
Member

Tinche commented Aug 14, 2023

Pretty cool, thanks! I'll take a look in the next few days.

@Tinche Tinche added this to the 23.2 milestone Aug 14, 2023
@pohlt
Copy link
Contributor Author

pohlt commented Aug 14, 2023

My local black is configured for a line length of 100 characters.
Reformatted with 88 and updated the branch.

Any reason why you don't use pre-commit to check potential commits locally?

@Tinche
Copy link
Member

Tinche commented Aug 15, 2023

I'm not a big fan of precommit, the dummy configuration here is I think inherited from the parent organization. You can use make lint or tox -e lint to run the lints ;)

How would you feel if we made it so that if the given function takes two arguments, we also make the strategy pass in the converter too? I'm thinking it might be convenient and easy to support.

@define
class MyClass:
    a: int
    b: Nested

    @classmethod
    def _structure(cls, data: dict, converter):
        return cls(data["b"] + 1, converter.structure(data['b'], Nested))

    def _unstructure(self):
        return {"c": self.a - 1}  # unstructuring as "c", not "a"

@pohlt
Copy link
Contributor Author

pohlt commented Aug 15, 2023

Makes a lot of sense. I didn't think of nested structures.
Maybe I will find some time this week to add it.

@pohlt
Copy link
Contributor Author

pohlt commented Aug 15, 2023

Just to make sure we are one the same page: That would mean changing stuff in dispatch.py, right?

How would you handle the distinction between functions accepting and not accepting a converter argument? inspect.signature, try ... except, ...?

@Tinche
Copy link
Member

Tinche commented Aug 16, 2023

Yeah, I figured we'd use inspect.signature. I don't think it needs changes other than the strategy, though. Here's my suggestion (for structure at least):

def use_class_methods(
    converter: BaseConverter,
    structure_method_name: Optional[str] = None,
    unstructure_method_name: Optional[str] = None,
) -> None:
    if structure_method_name:

        def make_class_method_structure(cl: Type) -> Callable:
            fn = getattr(cl, structure_method_name)
            sig = signature(fn)
            if len(sig.parameters) == 1:
                return lambda v, _: fn(v)
            if len(sig.parameters) == 2:
                return lambda v, _: fn(v, converter)
            raise TypeError("Provide a class method with one or two arguments.")

        converter.register_structure_hook_factory(
            lambda t: hasattr(t, structure_method_name), make_class_method_structure
        )

    if unstructure_method_name:
        converter.register_unstructure_hook_func(
            lambda t: hasattr(t, unstructure_method_name),
            lambda v: getattr(v, unstructure_method_name)(),
        )

I've switched to register_structure_hook_factory which is one level up, instead of a generic structure function we use a factory of structure functions which is very handy here. I'll leave it to you to adjust the unstructure path accordingly if this works for you ;)

@pohlt
Copy link
Contributor Author

pohlt commented Aug 16, 2023

How about this? No factory, but a little bit shorter:

def use_class_methods(
    converter: BaseConverter,
    structure_method_name: Optional[str] = None,
    unstructure_method_name: Optional[str] = None,
) -> None:

    def call_wrapper(n, f):
        n_parameters = len(signature(f).parameters)
        if n_parameters == n:
            return f
        if n_parameters == n + 1:
            return lambda *v: f(*v, converter)
        raise TypeError("Provide a class method with one or two arguments.")

    if structure_method_name:
        converter.register_structure_hook_func(
            lambda t: hasattr(t, structure_method_name),
            lambda v, t: call_wrapper(1, getattr(t, structure_method_name))(v),
        )

    if unstructure_method_name:
        converter.register_unstructure_hook_func(
            lambda t: hasattr(t, unstructure_method_name),
            lambda v: call_wrapper(0, getattr(v, unstructure_method_name))(),
        )

P.S.: The error text has to be adapted for the unstructure case.

@Tinche
Copy link
Member

Tinche commented Aug 17, 2023

That'll work, but the issue there is that it calls signature on each un/structure and the actual signature of those methods isn't going to change, so it's inefficient. And signature is a relatively slow function.

@Tinche Tinche closed this Aug 17, 2023
@Tinche
Copy link
Member

Tinche commented Aug 17, 2023

Sorry, misclicked Close

@Tinche Tinche reopened this Aug 17, 2023
@pohlt
Copy link
Contributor Author

pohlt commented Aug 17, 2023

You're right. I used your approach and added tests and docs. See #405

@Tinche
Copy link
Member

Tinche commented Aug 19, 2023

Looks great! I might tinker with the docs after you fix the tests, but solid work.

@pohlt pohlt force-pushed the class_methods_strategy branch 2 times, most recently from 062634a to 997c648 Compare August 19, 2023 18:36
@pohlt
Copy link
Contributor Author

pohlt commented Aug 21, 2023

When writing the _structure method I realized that I have to do the resolution of the union type.
Would it be possible to do a structuring into a class attribute?

  @define
    class Nested:
        a: Union["Nested", None]
        c: int

        @classmethod
        def _structure(cls, data, conv):
            b = data["b"]
            return cls(None if b is None else conv.structure(b, cls), data["c"])

            # Would something like this be possible?
            return cls(conv.structure_partial(b, Nested.a), data["c"])  # or structure_partial(b, Nested, "a")

@Tinche
Copy link
Member

Tinche commented Aug 21, 2023

You can do that today by just using structure:

from typing import Union

from attrs import define, fields, resolve_types

from cattrs import Converter
from cattrs.strategies import use_class_methods


@define
class Nested:
    a: "Union[Nested, None]"
    c: int

    @classmethod
    def _structure(cls, data, conv):
        b = data["b"]

        # Would something like this be possible?
        return cls(conv.structure(b, fields(Nested).a.type), data["c"])


resolve_types(Nested)

c = Converter()
use_class_methods(c, "_structure")
print(c.structure({"b": {"b": None, "c": 1}, "c": 1}, Nested))

The biggest issue is how Python handles stringified type annotations, which are necessary for self-referencing types, hence the call to resolve_types. This should be improved in Python 3.13 ;)

@pohlt
Copy link
Contributor Author

pohlt commented Aug 21, 2023

Awesome! I changed the test to use your version.

And I hopefully fixed all remaining issues with Python < 3.10.

@pohlt
Copy link
Contributor Author

pohlt commented Aug 22, 2023

I don't understand the CI issues. Judging by the logs, the installation process just stalls. Just a hiccup on the CI runner?

@Tinche
Copy link
Member

Tinche commented Aug 22, 2023

They look fine to me. On this line: https://github.com/python-attrs/cattrs/pull/405/files#diff-40771e4a7b976ef4fbc939a5524111d62406bf9162a342dff6ac335c33f33948R38 you need to use typing.Type instead of type for Python 3.7 and 3.8, since on those versions type[] doesn't work.

@pohlt
Copy link
Contributor Author

pohlt commented Aug 23, 2023

Strange. For me, the logs just stopped. Anyway, type[] is fixed now. Thanks for the hint.

@Tinche
Copy link
Member

Tinche commented Aug 23, 2023

Looks like https://github.com/python-attrs/cattrs/pull/405/files#diff-40771e4a7b976ef4fbc939a5524111d62406bf9162a342dff6ac335c33f33948R13 needs to be switched over to the old Union syntax too, for older Pythons.

@Tinche
Copy link
Member

Tinche commented Aug 24, 2023

Cool, thanks!

@Tinche Tinche merged commit 7c569d6 into python-attrs:main Aug 24, 2023
8 of 9 checks passed
@pohlt
Copy link
Contributor Author

pohlt commented Aug 27, 2023

Glad I could contribute something.
Thanks for the energy and thought you put into (c)attrs. Highly appreciated!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants