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

How to use arguments with default value? #277

Open
NellyWhads opened this issue Dec 3, 2024 · 18 comments
Open

How to use arguments with default value? #277

NellyWhads opened this issue Dec 3, 2024 · 18 comments

Comments

@NellyWhads
Copy link

I have a simple decorator constructed like so:

def public_api(stage: Literal["alpha", "beta", "stable"] = "stable"):
    """Decorate public APIs with versioning.

    Parameters
    ----------
    stage : {'alpha', 'beta', 'stable'}, default='stable'
        Stage of the API.
    """
    logger = logging.getLogger(__name__)
    _pub_api_tracker = threading.local()

    def decorator(wrapped: Callable):
        @wrapt.decorator
        def wrapper(wrapped, _instance, args, kwargs):
            if stage != "stable" and not hasattr(_pub_api_tracker, "used"):
                logger.info(
                    f"{stage.title()} API: {wrapped.__module__}.{wrapped.__qualname__} "
                    f"is in {stage} stage and may have breaking changes."
                )
                _pub_api_tracker.used = True

            if stage == "stable":
                # Verify that the function or method is fully type annotated
                sig = inspect.signature(wrapped)
                for param in sig.parameters.values():
                    if param.annotation is param.empty:
                        raise TypeError(f"Parameter '{param.name}' in {wrapped.__name__} is missing a type annotation.")
                if sig.return_annotation is sig.empty:
                    raise TypeError(f"Return type of {wrapped.__name__} is missing a type annotation.")
            return wrapped(*args, **kwargs)

        return wrapper(wrapped)

    return decorator

I can of course use the default argument of this decorator by applying it like so:

@public_api()
def my_func(input: str) -> str:
    return input

However, it does not behave well when used like this:

@public_api
def my_func(input: str) -> str:
    return input

Is there a way to make the latter work as well without losing all of the clean typing on the decorator?

@GrahamDumpleton
Copy link
Owner

For a start, you don't need the extra level of wrapping as you have. You should be able to write it as:

def public_api(stage: Literal["alpha", "beta", "stable"] = "stable"):
    """Decorate public APIs with versioning.

    Parameters
    ----------
    stage : {'alpha', 'beta', 'stable'}, default='stable'
        Stage of the API.
    """
    logger = logging.getLogger(__name__)
    _pub_api_tracker = threading.local()

    @wrapt.decorator
    def wrapper(wrapped, _instance, args, kwargs):
        if stage != "stable" and not hasattr(_pub_api_tracker, "used"):
            logger.info(
                f"{stage.title()} API: {wrapped.__module__}.{wrapped.__qualname__} "
                f"is in {stage} stage and may have breaking changes."
            )
            _pub_api_tracker.used = True

        if stage == "stable":
            # Verify that the function or method is fully type annotated
            sig = inspect.signature(wrapped)
            for param in sig.parameters.values():
                if param.annotation is param.empty:
                    raise TypeError(f"Parameter '{param.name}' in {wrapped.__name__} is missing a type annotation.")
            if sig.return_annotation is sig.empty:
                raise TypeError(f"Return type of {wrapped.__name__} is missing a type annotation.")
        return wrapped(*args, **kwargs)

    return wrapper

As to optional decorator arguments see:

So you need something like:

import functools
import wrapt

def public_api(wrapped: Callable = None, stage: Literal["alpha", "beta", "stable"] = "stable"):
    """Decorate public APIs with versioning.

    Parameters
    ----------
    stage : {'alpha', 'beta', 'stable'}, default='stable'
        Stage of the API.
    """

    if wrapped is None:
        return functools.partial(public_api, stage=stage)

    logger = logging.getLogger(__name__)
    _pub_api_tracker = threading.local()

    @wrapt.decorator
    def wrapper(wrapped, _instance, args, kwargs):
        if stage != "stable" and not hasattr(_pub_api_tracker, "used"):
            logger.info(
                f"{stage.title()} API: {wrapped.__module__}.{wrapped.__qualname__} "
                f"is in {stage} stage and may have breaking changes."
            )
            _pub_api_tracker.used = True

        if stage == "stable":
            # Verify that the function or method is fully type annotated
            sig = inspect.signature(wrapped)
            for param in sig.parameters.values():
                if param.annotation is param.empty:
                    raise TypeError(f"Parameter '{param.name}' in {wrapped.__name__} is missing a type annotation.")
            if sig.return_annotation is sig.empty:
                raise TypeError(f"Return type of {wrapped.__name__} is missing a type annotation.")
        return wrapped(*args, **kwargs)

    return wrapper

@NellyWhads
Copy link
Author

Oh cool - i thought I read through all the docs but it seems i didn't catch this example!

@NellyWhads
Copy link
Author

Actually tried this out and I see things breaking:

Core

"""Test the decorator API."""

import functools
import inspect
import logging
import threading
from typing import Callable, Literal, Optional

import wrapt


def public_api(wrapped: Optional[Callable] = None, stage: Literal["alpha", "beta", "stable"] = "stable"):
    """Decorate public APIs with versioning.

    Parameters
    ----------
    stage : {'alpha', 'beta', 'stable'}, default='stable'
        Stage of the API.
    """
    if wrapped is None:
        return functools.partial(public_api, stage=stage)

    logger = logging.getLogger(__name__)
    _pub_api_tracker = threading.local()

    @wrapt.decorator
    def wrapper(wrapped, _instance, args, kwargs):
        if stage != "stable" and not hasattr(_pub_api_tracker, "used"):
            logger.info(
                f"{stage.title()} API: {wrapped.__module__}.{wrapped.__qualname__} "
                f"is in {stage} stage and may have breaking changes."
            )
            _pub_api_tracker.used = True

        if stage == "stable":
            # Verify that the function or method is fully type annotated
            sig = inspect.signature(wrapped)
            for param in sig.parameters.values():
                if param.annotation is param.empty:
                    raise TypeError(f"Parameter '{param.name}' in {wrapped.__name__} is missing a type annotation.")
            if sig.return_annotation is sig.empty:
                raise TypeError(f"Return type of {wrapped.__name__} is missing a type annotation.")
        return wrapped(*args, **kwargs)

    return wrapper


@public_api()
def my_function(x: int) -> int:
    """My function docstring."""
    return x + 1

Result when using paretheses:

image

Result without parentheses:

image

@NellyWhads NellyWhads reopened this Dec 3, 2024
@NellyWhads
Copy link
Author

I think I got a fully working implementation. If you believe this is meaningful, I can update the docs with an even simpler example:

def public_api(wrapped: Optional[Callable] = None, stage: Literal["alpha", "beta", "stable"] = "stable"):
    """Decorate public APIs with versioning.

    Parameters
    ----------
    stage : {'alpha', 'beta', 'stable'}, default='stable'
        Stage of the API.
    """
    logger = logging.getLogger(__name__)
    _pub_api_tracker = threading.local()

    def decorator(wrapped: Callable, stage: Literal["alpha", "beta", "stable"] = stage):
        @wrapt.decorator
        def wrapper(wrapped, _instance, args, kwargs):
            if stage != "stable" and not hasattr(_pub_api_tracker, "used"):
                logger.info(
                    f"{stage.title()} API: {wrapped.__module__}.{wrapped.__qualname__} "
                    f"is in {stage} stage and may have breaking changes."
                )
                _pub_api_tracker.used = True

            if stage == "stable":
                # Verify that the function or method is fully type annotated
                sig = inspect.signature(wrapped)
                for param in sig.parameters.values():
                    if param.annotation is param.empty:
                        raise TypeError(f"Parameter '{param.name}' in {wrapped.__name__} is missing a type annotation.")
                if sig.return_annotation is sig.empty:
                    raise TypeError(f"Return type of {wrapped.__name__} is missing a type annotation.")
            return wrapped(*args, **kwargs)

        return wrapper(wrapped)

    if wrapped is None:
        return decorator
    else:
        return decorator(wrapped=wrapped, stage=stage)
    ```

@GrahamDumpleton
Copy link
Owner

Okay, you replaced use of partial() with your own nested function. I guess that also works but will need to think about whether there is any downsides to doing that.

@GrahamDumpleton
Copy link
Owner

BTW, if the partial() one failed, or type checks using introspection were failing in some way in the editor and that was what you were trying to highlight, try again with:

    if wrapped is None:
        return wrapt.PartialCallableObjectProxy(public_api, stage=stage)

Just curious as to whether that makes a difference. The problem is that partial() does not preserve introspection properly, so I have my own version of partial() implemented using wrapt.

Since type hinting still not added in wrapt, all sorts of editor stuff like that may not work. I need to try and get onto adding type hints now that have dropped older Python versions, but can only do that sometime start of next year.

@NellyWhads
Copy link
Author

This was the solution - keeps it nice and clean

@NellyWhads
Copy link
Author

A docs update to the document link in question would probably help a future traveller

@GrahamDumpleton
Copy link
Owner

What was the actual problem though, was it just that the popup from the editor wasn't giving useful information about arguments and giving warnings about type mismatch, or was there an actual runtime error?

@NellyWhads
Copy link
Author

The signature was not being passed properly with partial.

With your recent suggestion of object proxy, I now get the correct signature, but a runtime error (the decorator logic simply does not execute).

@GrahamDumpleton
Copy link
Owner

So what I gave you as example was wrong. Should have been:

"""Test the decorator API."""

import functools
import inspect
import logging
import threading
from typing import Callable, Literal, Optional

import wrapt


def public_api(wrapped: Optional[Callable] = None, stage: Literal["alpha", "beta", "stable"] = "stable"):
    """Decorate public APIs with versioning.

    Parameters
    ----------
    stage : {'alpha', 'beta', 'stable'}, default='stable'
        Stage of the API.
    """
    if wrapped is None:
        return functools.partial(public_api, stage=stage)

    logger = logging.getLogger(__name__)
    _pub_api_tracker = threading.local()

    @wrapt.decorator
    def wrapper(wrapped, _instance, args, kwargs):
        if stage != "stable" and not hasattr(_pub_api_tracker, "used"):
            logger.info(
                f"{stage.title()} API: {wrapped.__module__}.{wrapped.__qualname__} "
                f"is in {stage} stage and may have breaking changes."
            )
            _pub_api_tracker.used = True

        if stage == "stable":
            # Verify that the function or method is fully type annotated
            sig = inspect.signature(wrapped)
            for param in sig.parameters.values():
                if param.annotation is param.empty:
                    raise TypeError(f"Parameter '{param.name}' in {wrapped.__name__} is missing a type annotation.")
            if sig.return_annotation is sig.empty:
                raise TypeError(f"Return type of {wrapped.__name__} is missing a type annotation.")
        return wrapped(*args, **kwargs)

    return wrapper(wrapped)


@public_api()
def my_function(x: int) -> int:
    """My function docstring."""
    return x + 1

Change the return statement to:

    return wrapper(wrapped)

@GrahamDumpleton
Copy link
Owner

Not sure if still need to use wrapt.PartialCallableObjectProxy.

@NellyWhads
Copy link
Author

NellyWhads commented Dec 5, 2024

Yes, PartialCallableObjectProxy provides the appropriate signature/inspection. Using the final example you posted with the proxy meets all requirements. To summarize for future users, this is the way:

def my_decorator(wrapped: Optional[Callable] = None, my_argument: str = "default_value"):
    if wrapped is None:
        return wrapt.PartialCallableObjectProxy(my_decorator, my_argument=my_argument)

    @wrapt.decorator
    def wrapper(wrapped, instance, args, kwargs):
        # ... logic which uses the "my_argument"
        return wrapped(*args, **kwargs)

    return wrapper(wrapped)


@my_decorator # or @my_decorator() or @my_decorator("new_value") or @my_decorator(my_argument="new_value")
def my_function(x: int) -> int:
    """My function docstring."""
    return x + 1

@GrahamDumpleton
Copy link
Owner

I am going to reopen this issue to remind me to look at updating the docs.

@NellyWhads
Copy link
Author

If it's just that one-liner, I'm more than happy to submit a small PR

@GrahamDumpleton
Copy link
Owner

Needs some explanation text about PartialCallableObjectProxy being similar to partial and why it exists.

So the two code examples can be changed and just add a paragraph after second code example in that section saying something like.

The PartialCallableObjectProxy object is an implementation of functools.partial() which uses wrapt and preserves introspection for the wrapped callable object.

If you want to create a PR for that would be much appreciated.

@NellyWhads
Copy link
Author

You'll most likely see one tomorrow!

@NellyWhads
Copy link
Author

Should be resolved by #278

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

No branches or pull requests

2 participants