httpx icon indicating copy to clipboard operation
httpx copied to clipboard

Support Enum query params

Open Kludex opened this issue 2 years ago • 8 comments
trafficstars

Raised by https://github.com/mlrun/mlrun/pull/2629.

Kludex avatar Dec 26 '22 08:12 Kludex

Should we support auto-casting enums here, or should we raise a type error if you pass anything other than a boolean/int/float/None?

Option 1.

Explicit type error

import enum
import httpx

class Color(enum.Enum):
    RED = 1
    GREEN = 2
    BLUE = 3

u = httpx.URL("https://example.com", params={"color": Color.RED.value})
print(u)  # https://example.com?color=1

u = httpx.URL("https://example.com", params={"color": Color.RED})
# TypeError: Invalid type for params value. Expected str, bool, int, or float, got <enum 'Color'>: <Color.RED: 1>

Option 2.

Silent type coercion

import enum
import httpx

class Color(enum.Enum):
    RED = 1
    GREEN = 2
    BLUE = 3

u = httpx.URL("https://example.com", params={"color": Color.RED.value})
print(u)  # https://example.com?color=1

u = httpx.URL("https://example.com", params={"color": Color.RED})
print(u)  # https://example.com?color=1

lovelydinosaur avatar Dec 30 '22 16:12 lovelydinosaur

Currently both requests and httpx will perform silent type coercion to str here, but neither special-cases the enum type, so both give an awkward behaviour...

>>> r = requests.get("https://www.example.com", params={"a": Color.RED})
>>> r.request.url
'https://www.example.com/?a=Color.RED'

Edit: Ah okay, the behaviour there depends on if you're using Color(enum.Enum) or Color(str, enum.Enum).

lovelydinosaur avatar Dec 30 '22 16:12 lovelydinosaur

Should we support auto-casting enums here, or should we raise a type error if you pass anything other than a boolean/int/float/None?

Hmm... Maybe this would be better:

    if isinstance(value, enum.Enum):
        return primitive_value_to_str(value.value)

Then it would support all those cases. :thinking:

Kludex avatar Dec 30 '22 16:12 Kludex

Okay, I've some reluctance on this, which I'm more clear now on how to phrase.

It's about consistency.

class SiteURLs(str, enum.Enum):
    staging = "https://localhost"
    production = "https://www.example.com"

httpx.URL(SiteURLs.staging)
# Nope, not going to do what you want.
# You meant to pass `SiteURLs.staging.value`.

class ContentType(str, enum.Enum):
    json = "application/json"
    form = "application/x-www-form-urlencoded"

httpx.Headers({"Content-Type": ContentType.json})
# Nope, not going to do what you want.
# You meant to pass `ContentType.json.value`.

Same with, say json.dumps({...}) and other APIs.

It's not obvious to me that we should be special casing Enum in QueryParams, when the less ambiguous API usage is for the user to pass the .value from enum instances.

On the other hand... I can see that the less verbose style feels nicer. It's just that it's less precise and in other cases you might well end up with the __str__ representation.

Because the Testclient changed

I saw something along those lines mentioned in the referenced ticket... but I wasn't clear what it meant, and I couldn't replicated with requests. For example:

>>> r = requests.get("https://www.example.com", params={"a": Color.RED})
>>> r.request.url
'https://www.example.com/?a=Color.RED'  # Ooops you meant `Color.RED.value`. Same as with `httpx`.

lovelydinosaur avatar Dec 30 '22 18:12 lovelydinosaur

@tomchristie did your thoughts regarding this changed or should I close it?

Kludex avatar Jan 06 '23 12:01 Kludex

It's really not obvious what behaviour you should expect when using a StrEnum if you don't access .value. For example...

class CustomEnum(str, enum.Enum):
    A = "a"
    B = "b"

"%s" % CustomEnum.A
'CustomEnum.A'
"".join([CustomEnum.A])
'a'

😬

Anyways we probably just need to suck this weirdness up and deal with it...

def primitive_value_to_str(value: "PrimitiveData") -> str:
    """
    Coerce a primitive data type into a string value.
    Note that we prefer JSON-style 'true'/'false' for boolean values here.
    """
    if value is True:
        return "true"
    elif value is False:
        return "false"
    elif value is None:
        return ""
    elif isinstance(value, (str, int)):
        if isinstance(value, enum.Enum):
            # Typed enums have some pretty inconsistent
            # behaviour around expectations when passed directly.
            #
            # For example, see...
            # https://github.com/encode/httpx/pull/2515#issuecomment-1373705563
            #
            # We could either raise an exception here and force
            # developers to pass `.value` directly, or just coerce it.
            # We're being soft on ya here...
            return str(value.value)
        return str(value)

    # Passing other types here isn't type-correct against our API,
    # but we do currently automatically coerce them.
    #
    # There has been some discussion about removing this in favour
    # stricter typing. See this reverted pull request for more details...
    # https://github.com/encode/httpx/pull/2523
    return str(value)

Perhaps this is overkill on the commenting, but at least makes clear where the nasty edges are.

lovelydinosaur avatar Jan 06 '23 14:01 lovelydinosaur

Why only int and str?

Kludex avatar Jan 07 '23 16:01 Kludex

note also 3.11 brings backward breaking changes if used with a mixin int or str and the format funciton, might not faciitate the maintenance and deal with all cases

see https://blog.pecar.me/python-enum for a certainly better explanation

euri10 avatar Jan 07 '23 17:01 euri10

Closing as stale

lovelydinosaur avatar Mar 01 '24 15:03 lovelydinosaur