dgilland / pydash

The kitchen sink of Python utility libraries for doing "stuff" in a functional way. Based on the Lo-Dash Javascript library.
http://pydash.readthedocs.io
MIT License
1.32k stars 93 forks source link

Add `maybe_apply` function #223

Closed DeviousStoat closed 7 months ago

DeviousStoat commented 7 months ago

One thing that would be cool to have in the library is a maybe apply function kinda thing. pydash.get is really cool but I am a bit sad that it is not typable in the current python type system. One very common use case of it I believe is to get data from an optional value:

import pydash as _

class SomeClass:
    attribute: int = 5

    @classmethod
    def build(cls) -> "SomeClass | None":
        ...

some_class = SomeClass.build()

attr = _.get(some_class, "attribute")  # `attr` is `Any`, we cannot type `get` properly

But we could have a maybe_apply function thing that would take a callable:

attr = _.maybe_apply(some_class, lambda x: x.attribute)  # `attr` is `int | None`

this is typable.

And it is not restricted to attribute or key getting, we can just apply anything to an optional value, it abstracts this pattern:

def add1(x: int) -> int:
    return x + 1

some_int: Optional[int]
if some_int is not None:
    some_int = add1(some_int)

# instead just do
some_int = _.maybe_apply(some_int, add1)

And with the chaining interface I think it would look really cool, eg:

import pydash as _
from dataclasses import dataclass

@dataclass
class SomeAddress:
    city: str | None

@dataclass
class SomeUser:
    addr: SomeAddress | None

@dataclass
class SomeClass:
    user: SomeUser | None

some_class: SomeClass

maybe_upper_city: str | None = (
    _.chain(some_class)
    .maybe_apply(lambda x: x.user)
    .maybe_apply(lambda x: x.addr)
    .maybe_apply(lambda x: x.city)
    .maybe_apply(lambda x: x.upper())
    .value()
)
coveralls commented 7 months ago

Coverage Status

coverage: 100.0%. remained the same when pulling 3b4af7ca70c6445cb94733595d74bd4cda70eb73 on DeviousStoat:maybe-apply into 079ab365fc00f21e05d9f33fe77e6cfd51fb9f45 on dgilland:develop.

dgilland commented 7 months ago

What do you think about naming it just apply()?

dgilland commented 7 months ago

What do you think about naming it just apply()?

Actually, I just remembered that there's pydash.chaining.thru: https://github.com/dgilland/pydash/blob/292517cefd3a483cde2c392dae9587bf6867d76e/src/pydash/chaining/chaining.py#L268-L287

that has the same basic idea as maybe_apply() minus the is not None check.

So wondering now if renaming maybe_apply -> maybe_thru and moving it into the pydash.chaining module would align better.

Otherwise, if wanting to do a breaking change, then rename thru -> apply and keep maybe_apply (not sure I want to do aliases since that was a thing borrowed from lodash before I got rid of all of them in pydash).

Thoughts?

DeviousStoat commented 7 months ago

So wondering now if renaming maybe_apply -> maybe_thru and moving it into the pydash.chaining module would align better.

I don't think it is too appropriate for the function of this PR. The naming thru makes a lot more sense in the context of chaining and moving the function to the chaining module would also go further in that sense. But I believe the maybe_apply should be a function in object, it is very useful even outside of the chaining context contrary to thru.

And if we have thru in chaining and maybe_thru in object it is a bit odd, don't you think?

Imo I like it more as maybe_apply in object. And then we can imaging having a family of apply functions later. With some other conditional applies, like def apply_if(value: T, func: Callable[[T], T2], cond: Callable[[T], bool]) -> T | T2 which would apply a function if the condition is true (with maybe a else function) or def exc_apply(value: T, func: Callable[[T], T2], exc: tuple[Exception, ...]) -> T2 | None which would try to apply a function and ignore some exceptions (with maybe a else function in case of exception). I believe we can have a pretty cool and simple interface to abstract common patterns on function applications in python which sounds really appropriate to have in this library.

I was not sure about the maybe though, I was more thinking about the usual functional programming terms with the Maybe structure, but perhaps it is better named opt_apply for optional apply in python.

dgilland commented 7 months ago

I guess what's weird about pydash.thru() is that it's a standalone function that can be used outside of chaining but described in the chaining context. But it does what apply() would do in the context of this PR with maybe_apply().

My main point was more about not having two functions that do the same thing but have different names (e.g. if we kept thru() but added apply(), maybe_apply(), apply_if(), etc). There was a time when pydash had a lot of aliases for functions to mirror what Lodash had. But it got to be too much, and I got rid of them in favor of standardizing on single names without aliases. So I'd like to avoid reintroducing aliases if at all possible.

I would be in fine with renaming thru() to apply(), though, and moving it to pydash.object. #222 is a breaking change so the next release will likely be v8.0.0 (any other breaking changes could also be considered).

For the specific naming of maybe_apply() vs opt_apply(). Usually when I see opt, I think option/options so it's not as clear to me. optional_apply() could work or apply_if_not_none() if aligning with apply_if() paradigm (e.g. apply_if_true, apply_if_truthy, apply_if_false, apply_if_falsy; not saying to add those, just pointing on potential naming patterns). So I guess when it comes to maybe|optional_apply() vs apply_if_<something>(), is the maybe/optional naming clear enough that it only applies to non-None values (i.e. maybe could mean to apply when non-None or truthy) or would the apply_if_<something>() be clearer? But if not really wanting to do any sort of apply_if_<something>() naming, then maybe|optional_apply() would probably be fine. Not sure where I'm leaning yet but just pointing out some other considerations.

Thoughts?

DeviousStoat commented 7 months ago

I would be in fine with renaming thru() to apply(), though, and moving it to pydash.object

This sounds good

About the naming, one really good point of having the apply functions prefixed with apply, so the apply_if_<something> pattern, is that it is really good when working with LSPs completion, you type apply and the LSP lists all the functions related to apply. Also in the pydash documentation API reference, as the functions are sorted alphabetically. Maybe it is a bit too verbose but I guess with nowadays tooling (like the LSP completion) a few characters doesn't matter too much.

dgilland commented 7 months ago

Let's go with prefixing with apply_* then.

For renaming maybe_apply, here are some ideas:

Open to other ideas too.

DeviousStoat commented 7 months ago

So I updated the PR:

Since we are going this apply road I thought it would be nice to have the useful ones in this PR, so apply_if_not_none is defined in terms of apply_if.

Let me know what you think

DeviousStoat commented 7 months ago

I pinned the version of pytest to <8 because pytest-mypy-testing doesn't work with pytest 8+. As per the last commits support for pytest 8 should be released soon

dgilland commented 7 months ago

I pinned the version of pytest to <8 because pytest-mypy-testing doesn't work with pytest 8+. As per the last commits support for pytest 8 should be released soon

Looks like a pytest 8.1.0 thing: https://github.com/pytest-dev/pytest/issues/11779

I opened an issue with pytest-mypy-testing: https://github.com/davidfritzsche/pytest-mypy-testing/issues/51