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 typings for Payload #3294

Merged
merged 7 commits into from
Oct 1, 2018
Merged

Add typings for Payload #3294

merged 7 commits into from
Oct 1, 2018

Conversation

kornicameister
Copy link
Contributor

@kornicameister kornicameister commented Sep 26, 2018

What do these changes do?

Adds typing for the aiohttp/payload.py

Are there changes in behavior for the user?

No, these are just hints for the type checkers.

Related issue number

#1749

Checklist

  • I think the code is well written
  • Unit tests for the changes exist
  • Documentation reflects the changes
  • If you provide code modification, please add yourself to CONTRIBUTORS.txt
    • The format is <Name> <Surname>.
    • Please keep alphabetical order, the file is sorted by names.
  • Add a new news fragment into the CHANGES folder
    • name it <issue_id>.<type> for example (588.bugfix)
    • if you don't have an issue_id change it to the pr id after creating the pr
    • ensure type is one of the following:
      • .feature: Signifying a new feature.
      • .bugfix: Signifying a bug fix.
      • .doc: Signifying a documentation improvement.
      • .removal: Signifying a deprecation or removal of public API.
      • .misc: A ticket has been closed, but it is not of interest to users.
    • Make sure to use full sentences with correct case and punctuation, for example: "Fix issue with non-ascii contents in doctest text files."

@@ -107,27 +126,27 @@ def __init__(self, value, *, headers=None, content_type=sentinel,
self._content_type = content_type

@property
def size(self):
def size(self) -> Optional[float]:
Copy link
Contributor Author

@kornicameister kornicameister Sep 26, 2018

Choose a reason for hiding this comment

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

All those Optional seem a little awkward, but honestly speaking that's how I read the logic. Might as well be that I got that wrong.

Copy link
Member

Choose a reason for hiding this comment

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

It is a pretty common case.
For example AsyncIterablePayload has no size but sends a payload chunk-by-chunk

Copy link
Contributor Author

@kornicameister kornicameister left a comment

Choose a reason for hiding this comment

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

@asvetlov this is very first attempt to do that. But I guess I am lacking some knowledge here, so any suggestion appreciated.

Firstly, let's maybe concentrate on:

aiohttp/payload.py:73: error: Incompatible default for argument "_CHAIN" (default has type "Type[chain[Any]]", argument has type "Callable[..., Tuple[Type[Payload], Any]]")
aiohttp/payload.py:167: error: Unsupported target for indexed assignment
aiohttp/payload.py:191: error: Too many arguments for "__init__" of "Payload"
aiohttp/payload.py:260: error: Too many arguments for "__init__" of "Payload"
aiohttp/payload.py:381: error: Too many arguments for "__init__" of "Payload"

I will try to dig up on my own regarding too many arguments. But I am all puzzled about the first error in the list. Have no idea how am I suppose to make that passing :(

Also, not sure if the typings all all correct for the payloads that have IO in the name.

And yeah, sorry that it goes like that. Just trying to play open cards ;-)

@asvetlov
Copy link
Member

Thanks for the PR!
Will find a time for review tomorrow.

@asvetlov
Copy link
Member

asvetlov commented Sep 27, 2018

aiohttp/payload.py:191: error: Too many arguments for "init" of "Payload"

Since Payload accepts the only value positional argument and the value is already passed in super().__init__() the *args should be dropped from BytesPayload constructor.
Perhaps the same for other similar errors.

def __init__(self, value, *, headers=None, content_type=sentinel,
filename=None, encoding=None, **kwargs):
_size = None # type: Optional[float]
_headers = None # type: Optional[LooseHeaders]
Copy link
Member

Choose a reason for hiding this comment

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

_headers has the concrete type Optional[CIMultiDict[str]].

LooseHeaders is used for passing any mapping-like type.
aiohttp client API converts LooseHeaders into CIMultiDict internally.

I'm not 100% sure but a payload should accept CIMultiDict objects only.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@asvetlov Given this documentation, I think that it won't hurt to write typing for __init__ to reflect all the possible types CIMultiDict can accept. I will push the next commit with that and you can check it out later on.

def get(self,
data: Any,
*args: Any,
_CHAIN: Callable[..., Tuple[Type['Payload'], Any]]=chain,
Copy link
Member

Choose a reason for hiding this comment

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

Use _CHAIN: Any here, it's ok to shut up the type checker for the argument.

@kornicameister
Copy link
Contributor Author

kornicameister commented Sep 28, 2018

Ok, so mypy no longer complains, but I am getting TypeError: __init__() got an unexpected keyword argument 'disposition' all over the test execution :/. Wonder what I've changed to cause that. I'd assume that adding types shouldn't have nothing to do with changing the actual functionality thus failing tests.


NVM, found that. Removed one extra **kwargs elsewhere.

@codecov-io
Copy link

codecov-io commented Sep 28, 2018

Codecov Report

Merging #3294 into master will decrease coverage by <.01%.
The diff coverage is 98.18%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #3294      +/-   ##
==========================================
- Coverage   98.03%   98.02%   -0.01%     
==========================================
  Files          43       43              
  Lines        8001     8018      +17     
  Branches     1354     1355       +1     
==========================================
+ Hits         7844     7860      +16     
  Misses         65       65              
- Partials       92       93       +1
Impacted Files Coverage Δ
aiohttp/typedefs.py 100% <100%> (ø) ⬆️
aiohttp/payload.py 98.59% <98.14%> (-0.39%) ⬇️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update da75122...0b234c0. Read the comment docs.

JSONEncoder = Callable[[Any], str]
JSONDecoder = Callable[[str], Any]
JSONObj = Dict[str, Any]
JSON = Union[
Copy link
Member

Choose a reason for hiding this comment

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

Technically JSON can contain a single integer, None or string.
Moreover, it can be any python object if there is python -> JSON converter passed by json.dums(obj, defaul=my_converter).
Let's use Any for json encoders/decoders.

super().__init__(
value.encode(encoding),
encoding=encoding, content_type=content_type, *args, **kwargs)
if encoding is None:
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, this bit I completely do not get. It's literally impossible for encoding to be None, based on the logic. But the typing says otherwise and we can't exactly redefine the value with mypy. We can't also do the

_encoding: str
_content_type: str

letting the assignment to come later, because tests on 3.5 complains. We could've do the typing.cast but not really sure if that's correct. Sounds a bit hacky to me.

@asvetlov any suggestions ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Alternatively, we can keep that raise ValueError and add some noqa there, but honestly that sounds bad as well to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh, and for the sake of discussion, this is the alternative:

class StringPayload(BytesPayload):

    def __init__(self,
                 value: str,
                 *args: Any,
                 encoding: Optional[str]=None,
                 content_type: Optional[str]=None,
                 **kwargs: Any) -> None:
        _encoding, _content_type = self._parse_args(encoding, content_type)
        super().__init__(
            value.encode(_encoding),
            encoding=_encoding,
            content_type=_content_type,
            *args,
            **kwargs)

    @staticmethod
    def _parse_args(
        encoding: Optional[str]=None,
        content_type: Optional[str]=None,
    ) -> Tuple[str, str]:
        if encoding and content_type:
            return encoding, content_type
        elif encoding is None and content_type is None:
            return 'utf-8', 'text/plain; charset=utf-8'
        elif encoding is None and content_type:
            mimetype = parse_mimetype(content_type)
            return (
                mimetype.parameters.get('charset', 'utf-8'),
                content_type,
            )
        else:
            return (
                cast(str, encoding),
                'text/plain; charset=%s' % cast(str, encoding),
            )

Copy link
Member

Choose a reason for hiding this comment

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

Revert back the change, drop if encoding is None check.
Use the following trick:

        if encoding is None:
            if content_type is None:
                 real_encoding = 'utf-8'
                 content_type = 'text/plain; charset=utf-8'
             else:
                 mimetype = parse_mimetype(content_type)
                 readl_encoding = mimetype.parameters.get('charset', 'utf-8')
         else:
            if content_type is None:
                content_type = 'text/plain; charset=%s' % encoding
            real_encoding = encoding

 super().__init__(
                value.encode(encoding),
                encoding=real_encoding,
                content_type=content_type,
                *args,
                **kwargs)

real_encoding variable has strict string type, not Optional[str].

I believe mypy understands it pretty well.


super().__init__(
dumps(value).encode(encoding),
content_type=content_type, encoding=encoding, *args, **kwargs)


if TYPE_CHECKING:
Copy link
Member

Choose a reason for hiding this comment

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

Please add # pragma: no cover comment to the line for hinting coverage tool

@asvetlov
Copy link
Member

asvetlov commented Oct 1, 2018

Please use another approach.
CIMultiDict is a generic class, strict mypy mode forbids unspecialized generics.
CIMultiDict[str] should be used instead.
But it produces a runtime exception, sure.
The easiest backward compatible way to solve it is forward declarations.
Just wrap the problematic annotation in quotes: 'CIMultiDict[str]' and Union[CIMultiDict[str], Dict[str, str]]. The later most likely can be collapsed into Mapping[str, str] though.

@kornicameister
Copy link
Contributor Author

kornicameister commented Oct 1, 2018

Doesnt it mean that _CIMultiDict should be removed and replaced with forward references ? I mean at all...not as a sugestiom for me. Given your comment I got the feeling I used sth I should not have 😎

@asvetlov asvetlov merged commit 4c4401a into aio-libs:master Oct 1, 2018
@asvetlov
Copy link
Member

asvetlov commented Oct 1, 2018

Thanks!
I've missed underscore in _CIMultiDict on review.
I discovered forward-declaration based solution after adding _CIMultiDict to typedefs.py.
Now I have no strong preference, both ways are acceptable.
Let's add type hints everywhere first. Maybe later we'll polish it, I don't care right now.

@kornicameister kornicameister deleted the add_typings_for_payload branch October 1, 2018 20:10
@kornicameister
Copy link
Contributor Author

Ok, working currently on web_response, but that is a huge beast. I am still trying to figure out all the bits and pieces.

@asvetlov
Copy link
Member

asvetlov commented Oct 1, 2018

tracing.py should be a relatively easy thing.


def __init__(self, value, *, headers=None, content_type=sentinel,
filename=None, encoding=None, **kwargs):
_size = None # type: Optional[float]
Copy link
Member

Choose a reason for hiding this comment

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

A bit late for the party, but why size is typed as float? This value is used for Content-Length header which defined number of bytes of payload size - it's hard to pass over HTTP some fraction of a byte. What was the case for float type?

Copy link
Member

Choose a reason for hiding this comment

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

@lock
Copy link

lock bot commented Jan 4, 2020

This thread has been automatically locked since there has not been
any recent activity after it was closed. Please open a new issue for
related bugs.

If you feel like there's important points made in this discussion,
please include those exceprts into that new issue.

@lock lock bot added the outdated label Jan 4, 2020
@lock lock bot locked as resolved and limited conversation to collaborators Jan 4, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants