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

image attachment support #5

Closed
wants to merge 9 commits into from
Closed

Conversation

fracai
Copy link
Contributor

@fracai fracai commented Jan 24, 2018

Pushover today added support for image attachments.
This PR adds support for specifying the path to an image when calling send_message.

send_message(…, image_path="/path/to/the/image")

@codecov-io
Copy link

codecov-io commented Jan 24, 2018

Codecov Report

Merging #5 into develop will decrease coverage by 3.96%.
The diff coverage is 55.55%.

Impacted file tree graph

@@             Coverage Diff             @@
##           develop       #5      +/-   ##
===========================================
- Coverage      100%   96.03%   -3.97%     
===========================================
  Files            3        3              
  Lines          100      101       +1     
  Branches         7        8       +1     
===========================================
- Hits           100       97       -3     
- Misses           0        3       +3     
- Partials         0        1       +1
Impacted Files Coverage Δ
src/pushover_complete/pushover_api.py 95.34% <55.55%> (-4.66%) ⬇️

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 a9c15f3...d15f584. Read the comment docs.

:param session: A :class:`requests.Session` object to be used to send HTTP requests.
:type endpoint: str
:type url_parameter: str
:type payload: dict
:type files: dict
Copy link
Contributor

Choose a reason for hiding this comment

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

can you specify further what sort of dict this is? More specifically what are they key and value types?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Documentation from Pushover is here: https://pushover.net/api#attachments
And I used this page from the Requests documentation: http://docs.python-requests.org/en/latest/user/quickstart/#post-a-multipart-encoded-file

Basically, the dict is of the form:
{'attachment': (file_name, open(file_path, 'rb'))}
key must be 'attachment'
value is a tuple of the name of the file (doesn't seem to actually be used by Pushover) and the file object
You can also specify the Content-Type of the attachment, but it's not required and made the code simpler where I didn't need to determine what that would be. Though there's probably a very easy way to determine that for a given file.

Copy link
Contributor

@cthoyt cthoyt Jan 24, 2018

Choose a reason for hiding this comment

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

so maybe better dict[str,tuple[str,BytesIO]] or whatever the appropriate type is for BytesIO since that definitely isn't it. PyCharm introspects on these and gives nice type hints :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks.
type(open(file_path, 'rb')) returns <type 'file'> so: dict[str,tuple[str,file]]?

Copy link
Contributor

Choose a reason for hiding this comment

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

It depends on how requests is implemented - the file class is a much more specific thing that also acts like an IO, so I'm not sure. Maybe look into the requests documentation? This is sort of a nit-picking thing so it's not that big of a deal

Copy link
Contributor Author

Choose a reason for hiding this comment

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

good point. Another acceptable form of the dictionary is to just pass in a string as the file content instead of using open().
Requests specifies it like this:

    :param files: (optional) Dictionary of ``'name': file-like-objects`` (or ``{'name': file-tuple}``) for multipart encoding upload.
        ``file-tuple`` can be a 2-tuple ``('filename', fileobj)``, 3-tuple ``('filename', fileobj, 'content_type')``
        or a 4-tuple ``('filename', fileobj, 'content_type', custom_headers)``, where ``'content-type'`` is a string
        defining the content type of the given file and ``custom_headers`` a dict-like object containing additional headers
        to add for the file.

I suppose I could just copy that.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Owner

Choose a reason for hiding this comment

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

I would actually be happy to pass in some sort of iterable of file name(s) (or perhaps just a str/Path and only allow one file?) and leave the creation of the dict and the opening of the file to inside the _generic_post function. Forcing the user to pass in a dict with a specific key just seems like extra work for them that could be easily abstracted away by the library.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Only a single file can be attached to a given message, so I don't think it makes sense to use an iterable.
Also, the user would:

  1. call send_message(..., image_path, ...) which then
  2. call _send_message(..., image_path, ...); which performs the open, constructs the dict, and then
  3. call _generic_post(..., files_dict)

Doesn't that keep the user from passing in a dict? It seemed like _send_message was the right place to put the dict construction.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've cleaned up the param and type descriptions. It's still a little awkward, if anyone has suggestions for better wording I'm eagerly listening.


file = {}
if image_path is not None and os.path.isfile(image_path):
file = {'attachment': (image_path, open(image_path, 'rb'))}
Copy link
Contributor

Choose a reason for hiding this comment

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

this doesn't handle file cleanup. Would using a context manager and calling read() be an appropriate alternative?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point. I whipped this together last night because I wanted to start pushing images. I didn't spend a ton of time thinking about the right way to do it.
I'll have to investigate though, Requests wants the file object and that would be cleaned up before the following call to post. It's probably enough to just put that call inside the with-open block.

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe this, then?

if image_path is not None and os.path.isfile(image_path):
    with open(image_path, 'rb') as f:
           file = {'attachment': (image_path, f)}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's what I was thinking:

if image_path is not None and os.path.isfile(image_path):
    with open(image_path, 'rb') as f:
           file = {'attachment': (image_path, f)}
           return self._generic_post('messages.json', payload=payload, session=session, files=file)

Copy link
Owner

Choose a reason for hiding this comment

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

Moving the open call inside _generic_post will handle this issue as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

for now I still have the open call and dict construction inside _send_message. If you would prefer it move inside _generic_post I can do that. It seems to me to make more sense where it is, but I'm open.

@@ -173,7 +183,7 @@ def send_message(self, user, message, device=None, title=None, url=None, url_tit
:returns: Response body interpreted as JSON
:rtype: dict
"""
return self._send_message(user, message, device, title, url, url_title, priority, retry, expire, callback_url,
return self._send_message(user, message, device, title, url, url_title, image_path, priority, retry, expire, callback_url,
Copy link
Contributor

Choose a reason for hiding this comment

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

not specifically aimed at your PR, but I'd rather see kwargs used in this long call. it would make reading diffs easier too!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

True, I briefly considered that, but didn't want to complicate the PR. I'd suggest a general issue tracking that across the whole project.

Copy link
Contributor

Choose a reason for hiding this comment

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

@scolby33 is going to go crazy and add type annotations so its 36+ only next time he does this, so be careful what you wish for :p

Copy link
Owner

Choose a reason for hiding this comment

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

I'm still unsure on this. It seems to make discovery of the possible parameters more difficult...but I do admit that this signature is atrocious.

@@ -162,6 +171,7 @@ def send_message(self, user, message, device=None, title=None, url=None, url_tit
:type title: str
:type url: str
:type url_title: str
:type image_path: str
Copy link
Contributor

Choose a reason for hiding this comment

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

Should be str or pathlib.Path

Copy link
Contributor Author

Choose a reason for hiding this comment

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

good point; will change

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

@@ -68,20 +71,19 @@ def _generic_post(self, endpoint, url_parameter=None, payload=None, session=None
if payload is None:
payload = {}
payload['token'] = self.token
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm curious: why was this here, and why is it no longer needed?

@scolby33 thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The documentation for (Pushing Messages)[https://pushover.net/api#messages] specifies that Content-Type in the example. Sending without an image seemed to work whether it was there or not. When sending the image attachment though the Content-Type needs to be multipart/form-data and that is added automatically. I think it must be the default when specify the data payload.
If the headers includes the urlencoded Content-Type though, it overrides the form-data specification and causes the post to fail.

Copy link
Owner

Choose a reason for hiding this comment

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

I think I added this after doing some testing of the Pushover API with an external REST client that didn't add the application/x-www-form-urlencoded value. If Requests adds this by default as a guarantee, I'd love to see this line go. If not, we should have an if/else that puts the right header on depending on if there's an attachment or not. I should add sent header verification to the tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll double check how the call performs with and without the file parameter, but I think the header is indeed not required here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've tested with specifying a file and without and the application/x-www-form-urlencoded header is not needed.

Copy link
Owner

Choose a reason for hiding this comment

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

If it works, I'm happy to have this removed! I've opened #7 to remind myself to add tests later just in case requests changes it's behavior someday.

@cthoyt
Copy link
Contributor

cthoyt commented Jan 24, 2018

Cool PR, @fracai. Excited to hear your feedback on my comments

@fracai
Copy link
Contributor Author

fracai commented Jan 24, 2018

Thanks, I've been asking for images to be supported for a while now and was excited to see that they've turned it on. I'm using your library to push ZoneMinder alerts and this makes them much more useful.
I should be able to get to these changes this evening.

Copy link
Owner

@scolby33 scolby33 left a comment

Choose a reason for hiding this comment

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

If these changes can get made, I'll merge this tonight/immediately.

I'll handle writing tests for the changes and make a minor release then, too.

@@ -4,6 +4,7 @@
from urlparse import urljoin

import requests
import os
Copy link
Owner

Choose a reason for hiding this comment

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

I tend to keep imports in order of stdlib, then packages, then internal imports. Could you change to

import os
try: 
    ...
except:
    ...

import requests

Copy link
Contributor

Choose a reason for hiding this comment

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

Disagree. It is standard to have the try/except stuff at the end of imports

Copy link
Contributor Author

Choose a reason for hiding this comment

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

defering to the owner for now

@@ -68,20 +71,19 @@ def _generic_post(self, endpoint, url_parameter=None, payload=None, session=None
if payload is None:
payload = {}
payload['token'] = self.token
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
Copy link
Owner

Choose a reason for hiding this comment

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

If it works, I'm happy to have this removed! I've opened #7 to remind myself to add tests later just in case requests changes it's behavior someday.


if image_path is not None and os.path.isfile(image_path):
with open(image_path, 'rb') as f:
file = {'attachment': (image_path, f)}
Copy link
Owner

@scolby33 scolby33 Jan 28, 2018

Choose a reason for hiding this comment

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

I see in the requests docs that you can use a "file tuple" but it's not explained why the filename could be important. Do we need (image_path, f) or do we just want 'attachment': f?

The other thing to check is if image_path is a relative or absolute path, does requests want/deal with the full /Users/foo/whatever.png or does it really want just whatever.png? If the latter we should do something with os.path to extract just the part we want.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I assume the file name is used to specify a different name for downloading, but it doesn't look like Pushover is using this for anything; it is required in the case of the tuple. I suppose the tuple could be avoided and just pass in the file object.

Alternatively, os.path.split(image_path)[1] would be a way to just pass in the file name.

I like the idea of dropping the tuple to be honest.

if image_path is not None and os.path.isfile(image_path):
with open(image_path, 'rb') as f:
file = {'attachment': (image_path, f)}
return self._generic_post('messages.json', payload=payload, session=session, files=file)
Copy link
Owner

Choose a reason for hiding this comment

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

I really hate the need for an if here but I can't see a more elegant way to do it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I could drop the conditional and just rely on exception handling, but that would still be:

try:
    with open( image_path, 'rb') as f :
        file = {'attachment': f}
        return self._generic_post('messages.json', payload=payload, session=session, files=file)
except IOError:
    pass
return self._generic_post('messages.json', payload=payload, session=session)

@@ -173,7 +183,7 @@ def send_message(self, user, message, device=None, title=None, url=None, url_tit
:returns: Response body interpreted as JSON
:rtype: dict
"""
return self._send_message(user, message, device, title, url, url_title, priority, retry, expire, callback_url,
return self._send_message(user, message, device, title, url, url_title, image_path, priority, retry, expire, callback_url,
Copy link
Owner

Choose a reason for hiding this comment

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

I'm gonna request a semi-major change here: can the argument be changed to image and we allow either str or pathlib.Path or a BytesIO object? That would allow an image to be created in memory by the application and sent without ever writing it to disk. It would also allow the caller to manage the lifetime of the file handle.

This is just a change to the signature and docs of send_message but would require a bit more logic in _send_message to decide whether to open the file or just pass it along.

Shouldn't change _generic_post at all, I think.

Copy link
Contributor

Choose a reason for hiding this comment

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

what's the best way to do this and keep the code clean? Have two versions of the function that handle the file argument differently then the handler picks which strategy based on the type of the input?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, yes. I can do that, but I probably won't get to it until this evening. I've pushed the other changes for now and I'll get to this as soon as I can.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I found the time to put it together right now. That said, I haven't tested it beyond passing in a string still works.

@scolby33
Copy link
Owner

Also @fracai add yourself to AUTHORS.rst too!

file = {'attachment': f}
return self._generic_post('messages.json', payload=payload, session=session, files=file)
if image is not None:
if isinstance(image, file) or isinstance(image, io.BytesIO):
Copy link
Contributor

@cthoyt cthoyt Jan 28, 2018

Choose a reason for hiding this comment

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

breaks python2 compatibility. Use six to fix this import

if isinstance(image, file) or isinstance(image, io.BytesIO):
attachment = {'attachment': image}
return self._generic_post('messages.json', payload=payload, session=session, files=attachment)
if isinstance(image, str) and os.path.isfile(image):
Copy link
Contributor

Choose a reason for hiding this comment

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

use six.string_types for python2/3 compatibility

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What part breaks? I tested this with python2. I'm looking in to six as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, str is only python 3? Strange that it worked for me. I'll add in six usage.

@scolby33
Copy link
Owner

Superseded by #9

@scolby33 scolby33 closed this Feb 22, 2018
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

Successfully merging this pull request may close these issues.

4 participants