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

Add maybe_apply function #223

Merged
merged 8 commits into from
Mar 4, 2024
Merged

Conversation

DeviousStoat
Copy link
Contributor

@DeviousStoat DeviousStoat commented Feb 20, 2024

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
Copy link

coveralls commented Feb 20, 2024

Coverage Status

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

@dgilland
Copy link
Owner

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

@dgilland
Copy link
Owner

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

Actually, I just remembered that there's pydash.chaining.thru:

def thru(value: T, interceptor: t.Callable[[T], T2]) -> T2:
"""
Returns the result of calling `interceptor` on `value`. The purpose of this method is to pass
`value` through a function during a method chain.
Args:
value: Current value of chain operation.
interceptor: Function called with `value`.
Returns:
Results of ``interceptor(value)``.
Example:
>>> chain([1, 2, 3, 4]).thru(lambda x: x * 2).value()
[1, 2, 3, 4, 1, 2, 3, 4]
.. versionadded:: 2.0.0
"""
return interceptor(value)

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
Copy link
Contributor Author

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
Copy link
Owner

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
Copy link
Contributor Author

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
Copy link
Owner

Let's go with prefixing with apply_* then.

For renaming maybe_apply, here are some ideas:

  • apply_maybe
  • apply_if_not_none
  • apply_if_set
  • apply_if_assigned
  • apply_if_defined

Open to other ideas too.

@DeviousStoat
Copy link
Contributor Author

DeviousStoat commented Feb 28, 2024

So I updated the PR:

  • Removed thru from chaining
  • Added apply in objects
  • Added apply_if in objects
  • Added apply_if_not_none in objects
  • Added apply_ignore_excs in objects

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

Copy link
Owner

@dgilland dgilland left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some nits on naming, but everything else looks good.

...


def apply_ignore_excs(obj, func, excs, fallback=None):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about naming this apply_safely() or apply_with_catch()?

For argument naming:

  • excs -> exceptions: Prefer the full name here (we're using exceptions in another function as well)
  • fallback -> default: default is used elsewhere to signify the value to return when can't return an actual value

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking some more on this function. Wondering if we should have a way to specify that obj should be returned on exception without having to pass it in explicitly. For example, if using this in chaining, then the value of obj won't necessarily be known when the method is called so won't be able to pass it as the default/fallback.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could have the default value be UNSET that would return obj on exception.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very good points, I applied everything.

About the naming I named it apply_catch. I find apply_safely a bit misleading as it only catches specific exceptions and apply_with_catch sounds a bit funny to me. Let me know what you think.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me!

@DeviousStoat DeviousStoat force-pushed the maybe-apply branch 3 times, most recently from a4ea5bc to f8f69ed Compare March 3, 2024 21:51
@DeviousStoat
Copy link
Contributor Author

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
Copy link
Owner

dgilland commented Mar 4, 2024

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: pytest-dev/pytest#11779

I opened an issue with pytest-mypy-testing: davidfritzsche/pytest-mypy-testing#51

@dgilland dgilland merged commit 81b4a99 into dgilland:develop Mar 4, 2024
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants