NOAA-OWP/hydrotools

NWIS Client: Possible case sensitivity issue

jarq6c opened this issue · 7 comments

It appears the NWIS IV service is case-insensitive. For example, note the use of startDT and startdt in the two calls below:

https://waterservices.usgs.gov/nwis/iv/?format=json&sites=01646500&startDT=2021-11-01&endDT=2021-11-02&parameterCd=00060&siteStatus=all

https://waterservices.usgs.gov/nwis/iv/?format=json&sites=01646500&startdt=2021-11-01&enddt=2021-11-02&parameterCd=00060&siteStatus=all

This may be an issue for us because I think the current nwis_client validation is case-sensitive. This has caused some issues when using non-string datetime parameters. For example:

client = IVDataService()

# Ex1. This succeeds
obs = client.get(sites="01013500", startdt="2021-09-01T00:00Z")

# Ex2. This fails
obs = client.get(sites="01013500", startdt=pd.Timestamp("2021-09-01T00:00Z"))

# Ex.3 This suceeds
obs = client.get(sites="01013500", startDT=pd.Timestamp("2021-09-01T00:00Z"))

I'm hesitant to have the validator transform any arguments unexpectedly. However, it might be a good idea for the date converter/validator to be case-insensitive. So, if a user enters StArTdT as a parameter the key is passed onto NWIS un-altered, but the value is still treated the same way we currently treat startDT.

ping @aaraney

Hmm, so what it sounds like you are suggesting that we should normalize keyword argument keys? I am fine with dropping the case for keyword arguments. This seems like a feature that we would not advertise in effort to implicitly standardize user usage of the software, but I am all for supporting this. Something like this following should work:

#!/usr/bin/env python3

# typing imports
from typing import Callable, TypeVar

T = TypeVar("T")

def standardize_kwargs(fn: Callable[..., T]) -> Callable[..., T]:
    def wrapper(*args, **kwargs):
        normalized_kwargs = {k.lower(): v for k, v in kwargs.items()}
        
        return fn(*args, **normalized_kwargs)
    return wrapper

@standardize_kwargs
def foo(*, a: str):
    return a


def it_works():
    print(foo(A=2))

if __name__ == "__main__":
    it_works()

Actually I'm more partial to standardization. In which case, instead of dropping case-sensitivity, we could raise a helpful error message.

So, maybe something like this?

#!/usr/bin/env python3

# typing imports
from typing import Callable, TypeVar

T = TypeVar("T")

def improved_kwargs_error(fn: Callable[..., T]) -> Callable[..., T]:
    def wrapper(*args, **kwargs):
        fn_kwargs = set(fn.__annotations__)

        for k in kwargs.keys():
            if k not in fn_kwargs:
                error_message = (
                                              f"key word argument, {k}, not in accepted arguments.\n"
                                              f"accepted arguments are: {fn.__annotations__}"
                                             )
        
        return fn(*args, **kwargs)
    return wrapper

I like it. What do you think?

I like it. What do you think?

I also like it. I think it is more clear how to resolve an issue which is desirable given the intended audience.

A third option that we did not mention is removing **params from the method call and let python handle arguments that are incorrectly named. We could "forward" any applicable keyword arguments from get_raw to get's method signature. For example, the max_sites_per_request from get_raw could be added to get's method signature.

Im also open to a compromise solution that takes the standardization route that you mentioned, however we just check non-params arguments to verify that they have been passed using the correct case. For example s.get(startdt="01-01-2022", ...) would raise a warning and note that in future versions an exception will be raised rather than a warning. Then in future versions, raise an exceptions when this happens. What are your thoughts @jarq6c?