8
\$\begingroup\$

I am working on an API wrapper for calls to a website. Each of several types of calls can take a large number of optional arguments, some of which contradict. In the interest of preventing the user from supplying incompatible parameters, I would like to verify that the arguments are correct before submitting the request.

However, the simplest technique to do so has resulted in the below nightmare, with several if-statements. I know this is not a best practice, but I am unsure what the best method to correct it would be. I know that using *kwargs would probably be involved, but I don't see how that would save me from verifying the arguments in much the same way as below. I have heard that I should probably wrap the arguments in a class, but I'm unsure how much of a better idea that is.

def list_tag_alias(
        self,
        name_matches: str = None,
        status: str = None,
        order: str = None,
        antecedent_tag: int = None,
        consequent_tag: int = None,
        limit: int = None,
        before_page: int = None,
        after_page: int = None,
        page: int = None,
    ):
        query_url = "tag_aliases.json?"
        arguments = []
        if name_matches != None:
            arguments.append("search[name_matches]={}".format(name_matches))
        if status != None:
            assert status.lower() in (
                "approved",
                "active",
                "pending",
                "deleted",
                "retired",
                "processing",
                "queued",
                "blank",
            ), "status must be one of: approved, active, pending, deleted, retired, processing, queued, or blank."
            arguments.append("search[status]={}".format(status.lower()))
        if order != None:
            assert order.lower() in (
                "status",
                "created_at",
                "updated_at",
                "name",
                "tag_count",
            ), "order must be one of status, created_at, updated_at, name, tag_count"
            arguments.append("search[order]={}".format(order.lower()))
        if antecedent_tag != None:
            assert 0 <= antecedent_tag <= 8
            arguments.append(
                "search[antecedent_tag][category]={}".format(str(antecedent_tag))
            )
        if consequent_tag != None:
            assert 0 <= consequent_tag <= 8
            arguments.append(
                "search[consequent_tag][category]={}".format(str(consequent_tag))
            )
        if limit != None:
            assert 0 <= limit <= 1000, "limit must be between 0 and 1000, inclusive"
            arguments.append("limit={}".format(str(limit)))
        if before_page != None:
            assert (
                after_page == None and page == None
            ), "only one of before_page, after_page, or page must be supplied."
            assert before_page >= 0, "before_page must be greater than 0"
            arguments.append("page=b{}".format(str(before_page)))
        if after_page != None:
            assert (
                before_page == None and page == None
            ), "only one of before_page, after_page, or page must be supplied."
            assert after_page >= 0, "after_page must be greater than 0"
            arguments.append("page=a{}".format(str(after_page)))
        if page != None:
            assert (
                before_page == None and after_page == None
            ), "only one of before_page, after_page, or page must be supplied."
            arguments.append("page={}".format(str(page)))

        query_url += "&".join(arguments)

        request = WebRequest(state_info=self.state_info)
        response = request.submit_request(query_url=query_url)
        return response.json()
```
\$\endgroup\$
10
\$\begingroup\$

Don't assert; raise exceptions

assert statements may be disabled by flag (python -O ...) or environment variable.

Use raise ValueError("status must be one of: approved...") instead.

Don't test for equality with None

None is a singleton value. You should use status is not None instead of status != None.

You can just test the value directly, None is a false value. So if status: can be used instead of if status != None: or if status is not None:. Note: that other things test as false, such as 0, "", and [], so this test is not exactly the same.

Use enums

Strings are hard to use. Should you pass "delete", "deleted", or "deleting", ... or maybe "removed"?

Enumerations give you named constants to use. Your IDE may even help you autocomplete the constant name.

from enum import Enum

class Status(Enum):
    APPROVED = "approved"
    ACTIVE = "active"
    ...

...
            if status:
                arguments.append("search[status]={}".format(status.value))

You can turn strings into enums like:

            if status is not None and not isinstance(status, Status):
                status = Status(status.lower())

So the caller could pass in "deleted" or "DELETED" or Status.DELETED and they would all work, but if they passed in "removed", you'd get a ValueError: 'removed' is not a valid Status exception. So you no longer have to manually test if the given status is a legal status word. (However, if not every status word is legal in a function, these would still need to be tested.)

Common functions

If you have several functions which take before_page, after_page and page arguments, you probably have the same validation requirements in each. You should call another method to validate these, something like:

            self._validate_pages(before_page, after_page, page, arguments)

Positional & keyword-only arguments

As it stands, a caller may try call obj.list_tag_alias(None, None, "approved") to list the approved tag aliases. But actually, that used "approved" as the order parameter; while status is the 3th argument, that includes self in the count.

The correct call would be either obj.list_tag_alias(None, "approved"), or better, obj.list_tag_alias(status="approved").

You can deny the positional argument variant by forcing the parameters to be keyword-only parameters, by adding a * to the argument list:

def list_tag_alias(
    self,
    name_matches: str = None,
    *,                         # remaining parameters are keyword-only
    status: str = None,
    order: str = None,
    ...

Now you can call obj.list_tag_alias("fred") or obj.list_tag_alias(status="approved"), but obj.list_tag_alias(None, "approved") is an error:

TypeError: list_tag_alias() takes 1 positional argument but 2 were given

Argument dictionary

    arguments = []
    ...
        arguments.append("search[name_matches]={}".format(name_matches))
    ...
    query_url += "&".join(arguments)

You are using "...={}".format(...) and .append() multiple times to create your argument list to produce the query string. Consider using a dictionary instead:

    arguments = {}
    ...
        arguments["search[name_matches]"] = name_matches
    ...
    query_url += "&".join(f"{key}={value}" for key, value in arguments.items())

Bonus: using f"...{value}" is interpolating the value into a string, so str() calls for limit, tag, and page parameters are unnecessary.

\$\endgroup\$
0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.