-
Notifications
You must be signed in to change notification settings - Fork 5
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
Conversation
Codecov Report
@@ 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
Continue to review full report at Codecov.
|
: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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 :)
There was a problem hiding this comment.
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]]
?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
- call send_message(..., image_path, ...) which then
- call _send_message(..., image_path, ...); which performs the open, constructs the dict, and then
- 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.
There was a problem hiding this comment.
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'))} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)}
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good point; will change
There was a problem hiding this comment.
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'} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Cool PR, @fracai. Excited to hear your feedback on my comments |
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. |
There was a problem hiding this 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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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'} |
There was a problem hiding this comment.
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)} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Also @fracai add yourself to |
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): |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Superseded by #9 |
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")