Repository: niconoe/pyinaturalist
Pull request #284 (2021-09-19 20:53:17)

Add basic support for uploading photo & sound files from URLs
Updates #280

This is not yet optimized for large files, since downloaded file contents will be stored in memory before uploading.


Patch:
diff --git a/HISTORY.md b/HISTORY.md
index 55db81e..7fb1ab6 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,9 +1,11 @@
 # History
 
 ## 0.15.1 (2021-TBD)
+* Add support for uploading photo & sound files from URLs in `upload()`, `create_observation()`, and `update_observation()`
 * Remove `pyinaturalist.user_agent` global variable and API function keyword args, and recommend
   setting on session object instead
 * Fix `count_only=True`/`per_page=0` to not run full query
+* Do not error on unrecognized `**kwargs`, for cases where the API may accept some additional undocumented parameters
 
 
 ## 0.15.0 (2021-09-07)
diff --git a/poetry.lock b/poetry.lock
index 785c7ff..5cf3d58 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -6,6 +6,25 @@ category = "main"
 optional = false
 python-versions = "*"
 
+[[package]]
+name = "anyio"
+version = "3.3.1"
+description = "High level compatibility layer for multiple asynchronous event loop implementations"
+category = "main"
+optional = false
+python-versions = ">=3.6.2"
+
+[package.dependencies]
+dataclasses = {version = "*", markers = "python_version < \"3.7\""}
+idna = ">=2.8"
+sniffio = ">=1.1"
+typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
+
+[package.extras]
+doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"]
+test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"]
+trio = ["trio (>=0.16)"]
+
 [[package]]
 name = "appdirs"
 version = "1.4.4"
@@ -41,7 +60,7 @@ name = "async-generator"
 version = "1.10"
 description = "Async generators and context managers for Python 3.5+"
 category = "main"
-optional = true
+optional = false
 python-versions = ">=3.5"
 
 [[package]]
@@ -199,6 +218,17 @@ python-versions = "*"
 [package.extras]
 test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"]
 
+[[package]]
+name = "contextvars"
+version = "2.4"
+description = "PEP 567 Backport"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+immutables = ">=0.9"
+
 [[package]]
 name = "coverage"
 version = "5.5"
@@ -312,9 +342,53 @@ sphinx = ">=4.0,<5.0"
 doc = ["myst-parser", "sphinx-copybutton", "sphinx-design", "sphinx-inline-tabs"]
 test = ["pytest", "pytest-cov", "pytest-xdist"]
 
+[[package]]
+name = "h11"
+version = "0.12.0"
+description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "httpcore"
+version = "0.13.7"
+description = "A minimal low-level HTTP client."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+anyio = ">=3.0.0,<4.0.0"
+h11 = ">=0.11,<0.13"
+sniffio = ">=1.0.0,<2.0.0"
+
+[package.extras]
+http2 = ["h2 (>=3,<5)"]
+
+[[package]]
+name = "httpx"
+version = "0.19.0"
+description = "The next generation HTTP client."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+async-generator = {version = "*", markers = "python_version < \"3.7\""}
+certifi = "*"
+charset-normalizer = "*"
+httpcore = ">=0.13.3,<0.14.0"
+rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]}
+sniffio = "*"
+
+[package.extras]
+brotli = ["brotlicffi", "brotli"]
+http2 = ["h2 (>=3,<5)"]
+
 [[package]]
 name = "identify"
-version = "2.2.14"
+version = "2.2.15"
 description = "File identification library for Python"
 category = "dev"
 optional = false
@@ -339,6 +413,20 @@ category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
+[[package]]
+name = "immutables"
+version = "0.16"
+description = "Immutable Collections"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""}
+
+[package.extras]
+test = ["flake8 (>=3.8.4,<3.9.0)", "pycodestyle (>=2.6.0,<2.7.0)", "mypy (>=0.910)", "pytest (>=6.2.4,<6.3.0)"]
+
 [[package]]
 name = "importlib-metadata"
 version = "4.8.1"
@@ -1223,6 +1311,31 @@ requests = ">=2.20,<3.0"
 [package.extras]
 docs = ["furo (==2021.8.17b43)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx (>=4.0.2,<5.0.0)", "sphinx-autodoc-typehints (>=1.11,<2.0)", "sphinx-copybutton (>=0.3,<0.5)"]
 
+[[package]]
+name = "requests-toolbelt"
+version = "0.9.1"
+description = "A utility belt for advanced users of python-requests"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+requests = ">=2.0.1,<3.0.0"
+
+[[package]]
+name = "rfc3986"
+version = "1.5.0"
+description = "Validating URI References per RFC 3986"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+idna = {version = "*", optional = true, markers = "extra == \"idna2008\""}
+
+[package.extras]
+idna2008 = ["idna"]
+
 [[package]]
 name = "rich"
 version = "10.10.0"
@@ -1261,6 +1374,17 @@ category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 
+[[package]]
+name = "sniffio"
+version = "1.2.0"
+description = "Sniff out which async library your code is running under"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[package.dependencies]
+contextvars = {version = ">=2.1", markers = "python_version < \"3.7\""}
+
 [[package]]
 name = "snowballstemmer"
 version = "2.1.0"
@@ -1643,13 +1767,17 @@ docs = ["furo", "ipython", "linkify-it-py", "myst-parser", "nbsphinx", "sphinx",
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.6.2"
-content-hash = "bb270b3e6df16c06c4793cb1b33e280a1858b8f3ada8670699d12b27d2f43b52"
+content-hash = "44183e1cb4a9b17187ba20febb0ef9ef1ebb1c5dd48ef4f6e7538d4b651f3fc2"
 
 [metadata.files]
 alabaster = [
     {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"},
     {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"},
 ]
+anyio = [
+    {file = "anyio-3.3.1-py3-none-any.whl", hash = "sha256:d7c604dd491eca70e19c78664d685d5e4337612d574419d503e76f5d7d1590bd"},
+    {file = "anyio-3.3.1.tar.gz", hash = "sha256:85913b4e2fec030e8c72a8f9f98092eeb9e25847a6e00d567751b77e34f856fe"},
+]
 appdirs = [
     {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
     {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
@@ -1765,6 +1893,9 @@ commonmark = [
     {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"},
     {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"},
 ]
+contextvars = [
+    {file = "contextvars-2.4.tar.gz", hash = "sha256:f38c908aaa59c14335eeea12abea5f443646216c4e29380d7bf34d2018e2c39e"},
+]
 coverage = [
     {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"},
     {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"},
@@ -1874,9 +2005,21 @@ furo = [
     {file = "furo-2021.9.8-py3-none-any.whl", hash = "sha256:182d2ac70118191cb750caa763b997974a2c19f6f5fb7dfce458ce518dce80ae"},
     {file = "furo-2021.9.8.tar.gz", hash = "sha256:eeaca6997c3a91240c5841d4f743d43196c4ef464b1236ff9b192d25f3e0d2ab"},
 ]
+h11 = [
+    {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
+    {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
+]
+httpcore = [
+    {file = "httpcore-0.13.7-py3-none-any.whl", hash = "sha256:369aa481b014cf046f7067fddd67d00560f2f00426e79569d99cb11245134af0"},
+    {file = "httpcore-0.13.7.tar.gz", hash = "sha256:036f960468759e633574d7c121afba48af6419615d36ab8ede979f1ad6276fa3"},
+]
+httpx = [
+    {file = "httpx-0.19.0-py3-none-any.whl", hash = "sha256:9bd728a6c5ec0a9e243932a9983d57d3cc4a87bb4f554e1360fce407f78f9435"},
+    {file = "httpx-0.19.0.tar.gz", hash = "sha256:92ecd2c00c688b529eda11cedb15161eaf02dee9116712f621c70d9a40b2cdd0"},
+]
 identify = [
-    {file = "identify-2.2.14-py2.py3-none-any.whl", hash = "sha256:113a76a6ba614d2a3dd408b3504446bcfac0370da5995aa6a17fd7c6dffde02d"},
-    {file = "identify-2.2.14.tar.gz", hash = "sha256:32f465f3c48083f345ad29a9df8419a4ce0674bf4a8c3245191d65c83634bdbf"},
+    {file = "identify-2.2.15-py2.py3-none-any.whl", hash = "sha256:de83a84d774921669774a2000bf87ebba46b4d1c04775f4a5d37deff0cf39f73"},
+    {file = "identify-2.2.15.tar.gz", hash = "sha256:528a88021749035d5a39fe2ba67c0642b8341aaf71889da0e1ed669a429b87f0"},
 ]
 idna = [
     {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"},
@@ -1886,6 +2029,35 @@ imagesize = [
     {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"},
     {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"},
 ]
+immutables = [
+    {file = "immutables-0.16-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:acbfa79d44228d96296279068441f980dc63dbed52522d9227ff9f4d96c6627e"},
+    {file = "immutables-0.16-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c9ed003eacb92e630ef200e31f47236c2139b39476894f7963b32bd39bafa3"},
+    {file = "immutables-0.16-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a396314b9024fa55bf83a27813fd76cf9f27dce51f53b0f19b51de035146251"},
+    {file = "immutables-0.16-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4a2a71678348fb95b13ca108d447f559a754c41b47bd1e7e4fb23974e735682d"},
+    {file = "immutables-0.16-cp36-cp36m-win32.whl", hash = "sha256:064001638ab5d36f6aa05b6101446f4a5793fb71e522bc81b8fc65a1894266ff"},
+    {file = "immutables-0.16-cp36-cp36m-win_amd64.whl", hash = "sha256:1de393f1b188740ca7b38f946f2bbc7edf3910d2048f03bbb8d01f17a038d67c"},
+    {file = "immutables-0.16-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fcf678a3074613119385a02a07c469ec5130559f5ea843c85a0840c80b5b71c6"},
+    {file = "immutables-0.16-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a307eb0984eb43e815dcacea3ac50c11d00a936ecf694c46991cd5a23bcb0ec0"},
+    {file = "immutables-0.16-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7a58825ff2254e2612c5a932174398a4ea8fbddd8a64a02c880cc32ee28b8820"},
+    {file = "immutables-0.16-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:798b095381eb42cf40db6876339e7bed84093e5868018a9e73d8e1f7ab4bb21e"},
+    {file = "immutables-0.16-cp37-cp37m-win32.whl", hash = "sha256:19bdede174847c2ef1292df0f23868ab3918b560febb09fcac6eec621bd4812b"},
+    {file = "immutables-0.16-cp37-cp37m-win_amd64.whl", hash = "sha256:9ccf4c0e3e2e3237012b516c74c49de8872ccdf9129739f7a0b9d7444a8c4862"},
+    {file = "immutables-0.16-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:d59beef203a3765db72b1d0943547425c8318ecf7d64c451fd1e130b653c2fbb"},
+    {file = "immutables-0.16-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0020aaa4010b136056c20a46ce53204e1407a9e4464246cb2cf95b90808d9161"},
+    {file = "immutables-0.16-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edd9f67671555af1eb99ad3c7550238487dd7ac0ac5205b40204ed61c9a922ac"},
+    {file = "immutables-0.16-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:298a301f85f307b4c056a0825eb30f060e64d73605e783289f3df37dd762bab8"},
+    {file = "immutables-0.16-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b779617f5b94486bfd0f22162cd72eb5f2beb0214a14b75fdafb7b2c908ed0cb"},
+    {file = "immutables-0.16-cp38-cp38-win32.whl", hash = "sha256:511c93d8b1bbbf103ff3f1f120c5a68a9866ce03dea6ac406537f93ca9b19139"},
+    {file = "immutables-0.16-cp38-cp38-win_amd64.whl", hash = "sha256:b651b61c1af6cda2ee201450f2ffe048a5959bc88e43e6c312f4c93e69c9e929"},
+    {file = "immutables-0.16-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:aa7bf572ae1e006104c584be70dc634849cf0dc62f42f4ee194774f97e7fd17d"},
+    {file = "immutables-0.16-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:50793a44ba0d228ed8cad4d0925e00dfd62ea32f44ddee8854f8066447272d05"},
+    {file = "immutables-0.16-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:799621dcdcdcbb2516546a40123b87bf88de75fe7459f7bd8144f079ace6ec3e"},
+    {file = "immutables-0.16-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7bcf52aeb983bd803b7c6106eae1b2d9a0c7ab1241bc6b45e2174ba2b7283031"},
+    {file = "immutables-0.16-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:734c269e82e5f307fb6e17945953b67659d1731e65309787b8f7ba267d1468f2"},
+    {file = "immutables-0.16-cp39-cp39-win32.whl", hash = "sha256:a454d5d3fee4b7cc627345791eb2ca4b27fa3bbb062ccf362ecaaa51679a07ed"},
+    {file = "immutables-0.16-cp39-cp39-win_amd64.whl", hash = "sha256:2505d93395d3f8ae4223e21465994c3bc6952015a38dc4f03cb3e07a2b8d8325"},
+    {file = "immutables-0.16.tar.gz", hash = "sha256:d67e86859598eed0d926562da33325dac7767b7b1eff84e232c22abea19f4360"},
+]
 importlib-metadata = [
     {file = "importlib_metadata-4.8.1-py3-none-any.whl", hash = "sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15"},
     {file = "importlib_metadata-4.8.1.tar.gz", hash = "sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1"},
@@ -2309,6 +2481,14 @@ requests-ratelimiter = [
     {file = "requests-ratelimiter-0.2.1.tar.gz", hash = "sha256:9c9271c9435eea9e50b87837c32c23dd6f421c8303cbf615e0b52ee5e640015a"},
     {file = "requests_ratelimiter-0.2.1-py3-none-any.whl", hash = "sha256:67ddb03c86c12fd41c3c8c204ccaf72553459ba37ab73a2b99312447fcd1e34a"},
 ]
+requests-toolbelt = [
+    {file = "requests-toolbelt-0.9.1.tar.gz", hash = "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"},
+    {file = "requests_toolbelt-0.9.1-py2.py3-none-any.whl", hash = "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f"},
+]
+rfc3986 = [
+    {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"},
+    {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"},
+]
 rich = [
     {file = "rich-10.10.0-py3-none-any.whl", hash = "sha256:0b8cbcb0b8d476a7f002feaed9f35e51615f673c6c291d76ddf0c555574fd3c7"},
     {file = "rich-10.10.0.tar.gz", hash = "sha256:bacf58b25fea6b920446fe4e7abdc6c7664c4530c4098e7a1bc79b16b8551dfa"},
@@ -2321,6 +2501,10 @@ six = [
     {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
     {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
 ]
+sniffio = [
+    {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"},
+    {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"},
+]
 snowballstemmer = [
     {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"},
     {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"},
diff --git a/pyinaturalist/converters.py b/pyinaturalist/converters.py
index 7197967..3a72926 100644
--- a/pyinaturalist/converters.py
+++ b/pyinaturalist/converters.py
@@ -1,10 +1,7 @@
 """Type conversion utilities"""
 from datetime import date, datetime, timedelta
-from io import BytesIO
 from logging import getLogger
-from os.path import abspath, expanduser
-from pathlib import Path
-from typing import IO, Any, Dict, List, MutableSequence, Optional, Union
+from typing import Any, Dict, List, MutableSequence, Optional, Union
 from warnings import catch_warnings, simplefilter
 
 from dateutil.parser import UnknownTimezoneWarning  # type: ignore  # (missing from type stubs)
@@ -12,7 +9,6 @@ from dateutil.parser import parse as parse_date
 from dateutil.tz import tzlocal, tzoffset
 
 from pyinaturalist.constants import (
-    AnyFile,
     Coordinates,
     Dimensions,
     HistogramResponse,
@@ -30,6 +26,7 @@ OBSERVATION_TIME_FIELDS = (
     'observed_time_zone',
     'time_zone_offset',
 )
+
 logger = getLogger(__name__)
 
 
@@ -280,16 +277,6 @@ def ensure_list(
         return [value]
 
 
-def ensure_file_obj(value: AnyFile) -> IO:
-    """Given a file object or path, read it into a file-like object if it's a path"""
-    if isinstance(value, (str, Path)):
-        file_path = abspath(expanduser(value))
-        logger.info(f'Reading from file: {file_path}')
-        with open(file_path, 'rb') as f:
-            return BytesIO(f.read())
-    return value
-
-
 # Formatting functions
 # --------------------
 
diff --git a/pyinaturalist/docs/templates.py b/pyinaturalist/docs/templates.py
index 10a842b..06ad76d 100644
--- a/pyinaturalist/docs/templates.py
+++ b/pyinaturalist/docs/templates.py
@@ -373,8 +373,8 @@ def _create_observation(
         their Picasa and iNat accounts connected, and the user must own the photo(s) on Picasa.
     facebook_photos: Facebook photo IDs to add as photos for this observation. User must have
         their Facebook and iNat accounts connected, and the user must own the photo on Facebook.
-    photos: Image files, file-like objects, and/or paths
-    sounds: Sound files, file-like objects, and/or paths
+    photos: One or more image files, file-like objects, file paths, or URLs
+    sounds: One or more sound files, file-like objects, file paths, or URLs
     """
 
 
diff --git a/pyinaturalist/request_params.py b/pyinaturalist/request_params.py
index 386484e..5feb31d 100644
--- a/pyinaturalist/request_params.py
+++ b/pyinaturalist/request_params.py
@@ -41,6 +41,7 @@ INTERVALS: Dict[str, Union[timedelta, relativedelta]] = {
     'year': relativedelta(years=1),
 }
 
+
 logger = getLogger(__name__)
 
 
diff --git a/pyinaturalist/session.py b/pyinaturalist/session.py
index b79eab8..34caf85 100644
--- a/pyinaturalist/session.py
+++ b/pyinaturalist/session.py
@@ -1,8 +1,12 @@
 """Session class and related functions for preparing and sending API requests"""
+import re
 import threading
+from io import BytesIO
 from logging import getLogger
 from os import getenv
-from typing import Dict
+from os.path import abspath, expanduser
+from pathlib import Path
+from typing import IO, Dict
 from unittest.mock import Mock
 from warnings import warn
 
@@ -27,11 +31,11 @@ from pyinaturalist.constants import (
     REQUESTS_PER_SECOND,
     RETRY_BACKOFF,
     WRITE_HTTP_METHODS,
+    AnyFile,
     FileOrPath,
     MultiInt,
     RequestParams,
 )
-from pyinaturalist.converters import ensure_file_obj
 from pyinaturalist.formatters import format_request
 from pyinaturalist.request_params import (
     convert_url_ids,
@@ -43,6 +47,9 @@ from pyinaturalist.request_params import (
 MOCK_RESPONSE = Mock(spec=Response)
 MOCK_RESPONSE.json.return_value = {'results': [], 'total_results': 0, 'access_token': ''}
 
+# Extremely simplified URL regex, just enough to differentiate from local paths
+URL_PATTERN = re.compile(r'^https?://.+')
+
 logger = getLogger('pyinaturalist')
 thread_local = threading.local()
 
@@ -151,7 +158,7 @@ def request(
         url: Request URL
         access_token: access_token: the access token, as returned by :func:`get_access_token()`
         dry_run: Just log the request instead of sending a real request
-        files: File path or object to upload
+        files: File object, path, or URL to upload
         headers: Request headers
         ids: One or more integer IDs used as REST resource(s) to request
         json: JSON request body
@@ -210,14 +217,20 @@ def prepare_request(
     if access_token:
         headers['Authorization'] = f'Bearer {access_token}'
 
-    # Convert any datetimes in request body, and read any files for uploading
+    # Convert any datetimes in request body, and read any files or URLs for uploading
     json = preprocess_request_body(json)
     if files:
-        files = {'file': ensure_file_obj(files)}  # type: ignore
+        files = {'file': ensure_file_obj(files, session)}  # type: ignore
 
     # Convert into a PreparedRequest
     request = Request(
-        method=method, url=url, files=files, headers=headers, json=json, params=params, **kwargs
+        method=method,
+        url=url,
+        files=files,
+        headers=headers,
+        json=json,
+        params=params,
+        **kwargs,
     )
     return session.prepare_request(request)
 
@@ -246,6 +259,24 @@ def put(url: str, **kwargs) -> Response:
     return request('PUT', url, **kwargs)
 
 
+def ensure_file_obj(value: AnyFile, session: Session = None) -> IO:
+    """Given a file path or URL, load data into a file-like object"""
+    # Load from URL
+    if isinstance(value, str) and URL_PATTERN.match(value):
+        session = session or get_local_session()
+        return session.get(value).raw
+
+    # Load from local file path
+    if isinstance(value, (str, Path)):
+        file_path = abspath(expanduser(value))
+        logger.info(f'Reading from file: {file_path}')
+        with open(file_path, 'rb') as f:
+            return BytesIO(f.read())
+
+    # Otherwise, assume it's already a file or file-like object
+    return value
+
+
 def env_to_bool(environment_variable: str) -> bool:
     """Translate an environment variable to a boolean value, accounting for minor
     variations (case, None vs. False, etc.)
@@ -254,6 +285,20 @@ def env_to_bool(environment_variable: str) -> bool:
     return bool(env_value) and str(env_value).lower() not in ['false', 'none']
 
 
+def get_local_session(**kwargs) -> Session:
+    """Get a thread-local Session object with default settings. This will be reused across requests
+    to take advantage of connection pooling and (optionally) caching. If used in a multi-threaded
+    context (for example, a :py:class:`~concurrent.futures.ThreadPoolExecutor`), this will create
+    and store a separate session object for each thread.
+
+    Args:
+        kwargs: Keyword arguments for :py:func:`.ClientSession`
+    """
+    if not hasattr(thread_local, 'session'):
+        thread_local.session = ClientSession(**kwargs)
+    return thread_local.session
+
+
 # TODO: Drop support for global variables in version 0.16
 def is_dry_run_enabled(method: str) -> bool:
     """A wrapper to determine if dry-run (aka test mode) has been enabled via either
@@ -273,17 +318,3 @@ def is_dry_run_enabled(method: str) -> bool:
             dry_run_enabled or pyinaturalist.DRY_RUN_WRITE_ONLY or env_to_bool('DRY_RUN_WRITE_ONLY')
         )
     return dry_run_enabled
-
-
-def get_local_session(**kwargs) -> Session:
-    """Get a thread-local Session object with default settings. This will be reused across requests
-    to take advantage of connection pooling and (optionally) caching. If used in a multi-threaded
-    context (for example, a :py:class:`~concurrent.futures.ThreadPoolExecutor`), this will create
-    and store a separate session object for each thread.
-
-    Args:
-        kwargs: Keyword arguments for :py:func:`.ClientSession`
-    """
-    if not hasattr(thread_local, 'session'):
-        thread_local.session = ClientSession(**kwargs)
-    return thread_local.session
diff --git a/pyinaturalist/v1/observations.py b/pyinaturalist/v1/observations.py
index ff8fc32..054e6fd 100644
--- a/pyinaturalist/v1/observations.py
+++ b/pyinaturalist/v1/observations.py
@@ -422,8 +422,8 @@ def upload(
 
     Args:
         observation_id: the ID of the observation
-        photos: One or more image files, file-like objects, or paths
-        sounds: One or more audio files, file-like objects, or paths
+        photos: One or more image files, file-like objects, file paths, or URLs
+        sounds: One or more audio files, file-like objects, file paths, or URLs
         access_token: Access token for user authentication, as returned by :func:`get_access_token()`
 
     Returns:
diff --git a/pyproject.toml b/pyproject.toml
index ccfde40..60177b0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -49,7 +49,7 @@ sphinx                      = {optional=true, version="^4.2.0" }
 sphinx-automodapi           = {optional=true, version="^0.13" }
 sphinx-autodoc-typehints    = {optional=true, version="^1.11" }
 sphinx-copybutton           = {optional=true, version=">=0.3,<0.5" }
-sphinx-inline-tabs          = {optional=true, version="^2021.4.11-beta.9", python=">=3.8"}
+sphinx-inline-tabs          = {optional=true, version="^2021.8.17b10", python=">=3.8"}
 sphinx-panels               = {optional=true, version="^0.6.0"}
 sphinxcontrib-apidoc        = {optional=true, version="^0.3" }
 


Test patch:
diff --git a/test/test_converters.py b/test/test_converters.py
index e78bd10..4492a07 100644
--- a/test/test_converters.py
+++ b/test/test_converters.py
@@ -1,6 +1,4 @@
 from datetime import datetime
-from io import BytesIO
-from tempfile import NamedTemporaryFile
 
 import pytest
 from dateutil.tz import tzoffset
@@ -10,7 +8,6 @@ from pyinaturalist.converters import (
     convert_lat_long,
     convert_observation_timestamps,
     convert_offset,
-    ensure_file_obj,
     ensure_list,
     format_dimensions,
     format_file_size,
@@ -37,20 +34,6 @@ def test_convert_lat_long(input, expected_output):
     assert convert_lat_long(input) == expected_output
 
 
-def test_ensure_file_obj__file_path():
-    with NamedTemporaryFile() as temp:
-        temp.write(b'test content')
-        temp.seek(0)
-
-        file_obj = ensure_file_obj(temp.name)
-        assert file_obj.read() == b'test content'
-
-
-def test_ensure_file_obj__file_obj():
-    file_obj = BytesIO(b'test content')
-    assert ensure_file_obj(file_obj) == file_obj
-
-
 @pytest.mark.parametrize(
     'input, expected_output',
     [
diff --git a/test/test_session.py b/test/test_session.py
index b3ff686..ab4f884 100644
--- a/test/test_session.py
+++ b/test/test_session.py
@@ -1,3 +1,5 @@
+from io import BytesIO
+from tempfile import NamedTemporaryFile
 from unittest.mock import MagicMock, patch
 
 import pytest
@@ -7,6 +9,7 @@ from pyinaturalist.session import (
     MOCK_RESPONSE,
     ClientSession,
     delete,
+    ensure_file_obj,
     get,
     post,
     put,
@@ -150,3 +153,25 @@ def test_session__send(mock_session):
     request = MagicMock(method='GET', url='http://test.com')
     session.send(request)
     mock_session.assert_called_with(request, timeout=10)
+
+
+def test_ensure_file_obj__obj():
+    file_obj = ensure_file_obj(BytesIO(b'test content'))
+    assert file_obj.read() == b'test content'
+
+
+def test_ensure_file_obj__path():
+    with NamedTemporaryFile() as temp:
+        temp.write(b'test content')
+        temp.seek(0)
+
+        file_obj = ensure_file_obj(temp.name)
+        assert file_obj.read() == b'test content'
+
+
+def test_ensure_file_obj__url():
+    session = MagicMock()
+    session.get().raw = BytesIO(b'test content')
+
+    file_obj = ensure_file_obj('https://example.com/file.mp3', session)
+    assert file_obj.read() == b'test content'