Repository: niconoe/pyinaturalist (python)

Issue:
Fix issue with @document_request_params and duplicate keyword arguments
Follow-up from PR #55 .

Example traceback:

```python
  File "/home/cookjo/workspace/pyinaturalist/pyinaturalist/rest_api.py", line 70, in <module>
    def get_observations(user_agent: str = None, **kwargs) -> Union[List, str]:
  File "/home/cookjo/workspace/pyinaturalist/pyinaturalist/forge_utils.py", line 59, in f
    func = copy_signatures(func, template_functions)
  File "/home/cookjo/workspace/pyinaturalist/pyinaturalist/forge_utils.py", line 121, in copy_signatures
    return revision(target_function)
ValueError: Received multiple parameters with name 'user_agent'
]
```

Pull request description:
Fix issue with @document_request_params and duplicate keyword arguments
Closes #58 

For reference: The `user_agent` param is automatically added to every endpoint function. Calling `document_request_params()` with a list object that happened to be reused, in this case  `_get_observations`, would add `user_agent` to that list during the first call without any errors. But since that was a mutable object reused by multiple functions (i.e., since those functions had that same list of parameters in common), `user_agent` would get added to the same object multiple times, causing the error:
`ValueError: Received multiple parameters with name 'user_agent'`.

The solution was to just copy the provided `template_functions` list in `document_request_params()` before modifying it.    

Also, I moved all the endpoint-specific groupings of request params from the `api_docs` module to the individual functions; it seems a bit easier to read that way.

Patch:
diff --git a/README.rst b/README.rst
index 452380b..22a76fd 100644
--- a/README.rst
+++ b/README.rst
@@ -77,7 +77,7 @@ Search observations:
 .. code-block:: python
 
     from pyinaturalist.node_api import get_all_observations
-    obs = get_all_observations(params={'user_id': 'niconoe'})
+    obs = get_all_observations(user_id='my_username')
 
 See `available parameters <https://api.inaturalist.org/v1/docs/#!/Observations/get_observations/>`_.
 
@@ -101,23 +101,23 @@ Create a new observation:
 .. code-block:: python
 
     from pyinaturalist.rest_api import create_observations
-    params = {'observation':
-                {'taxon_id': 54327,  # Vespa Crabro
-                 'observed_on_string': datetime.datetime.now().isoformat(),
-                 'time_zone': 'Brussels',
-                 'description': 'This is a free text comment for the observation',
-                 'tag_list': 'wasp, Belgium',
-                 'latitude': 50.647143,
-                 'longitude': 4.360216,
-                 'positional_accuracy': 50, # meters,
-
-                 # sets vespawatch_id (an observation field whose ID is 9613) to the value '100'.
-                 'observation_field_values_attributes':
-                    [{'observation_field_id': 9613,'value': 100}],
-                 },
+    params = {
+        'taxon_id': 54327,  # Vespa Crabro
+         'observed_on_string': datetime.datetime.now().isoformat(),
+         'time_zone': 'Brussels',
+         'description': 'This is a free text comment for the observation',
+         'tag_list': 'wasp, Belgium',
+         'latitude': 50.647143,
+         'longitude': 4.360216,
+         'positional_accuracy': 50, # meters,
+
+         # sets vespawatch_id (an observation field whose ID is 9613) to the value '100'.
+         'observation_field_values_attributes':
+            [{'observation_field_id': 9613,'value': 100}],
+         },
     }
 
-    r = create_observations(params=params, access_token=token)
+    r = create_observations(access_token=token, **params)
     new_observation_id = r[0]['id']
 
 Upload a picture for this observation:
@@ -125,18 +125,22 @@ Upload a picture for this observation:
 .. code-block:: python
 
     from pyinaturalist.rest_api import add_photo_to_observation
-    r = add_photo_to_observation(observation_id=new_observation_id,
-                                 file_object=open('/Users/nicolasnoe/vespa.jpg', 'rb'),
-                                 access_token=token)
+    r = add_photo_to_observation(
+        new_observation_id,
+        access_token=token,
+        photo='/Users/nicolasnoe/vespa.jpg',
+    )
 
 Update an existing observation of yours:
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 .. code-block:: python
 
         from pyinaturalist.rest_api import update_observation
-        p = {'ignore_photos': 1,  # Otherwise existing pictures will be deleted
-             'observation': {'description': 'updated description !'}}
-        r = update_observation(observation_id=17932425, params=p, access_token=token)
+        r = update_observation(
+            17932425,
+            access_token=token,
+            description='updated description !',
+        )
 
 Get a list of all (globally available) observation fields:
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/pyinaturalist/api_docs.py b/pyinaturalist/api_docs.py
index e95c355..de1229f 100644
--- a/pyinaturalist/api_docs.py
+++ b/pyinaturalist/api_docs.py
@@ -3,7 +3,7 @@ Reusable template functions used for API documentation.
 Each template function contains a portion of an endpoint's request parameters, with corresponding
 type annotations and docstrings.
 """
-from typing import Any, Dict, List
+from typing import Any, Dict, List, Iterable
 
 from pyinaturalist.constants import (
     MultiInt,
@@ -11,6 +11,7 @@ from pyinaturalist.constants import (
     Date,
     DateTime,
     IntOrStr,
+    FileOrPath,
 )
 from pyinaturalist.request_params import MULTIPLE_CHOICE_PARAMS
 
@@ -20,7 +21,7 @@ from pyinaturalist.request_params import MULTIPLE_CHOICE_PARAMS
 
 
 # Params that are in most observation-related endpoints in both Node and REST APIs
-def _observation_params_common(
+def _observation_common(
     q: str = None,
     d1: Date = None,
     d2: Date = None,
@@ -57,7 +58,7 @@ def _observation_params_common(
 
 
 # Observation params that are only in the Node API
-def _observation_params_node_only(
+def _observation_node_only(
     acc: bool = None,
     captive: bool = None,
     endemic: bool = None,
@@ -197,7 +198,7 @@ def _observation_params_node_only(
 
 
 # Observation params that are only in the REST API
-def _observation_params_rest_only(
+def _observation_rest_only(
     has: MultiStr = None,
     on: Date = None,
     m1: Date = None,
@@ -224,8 +225,7 @@ def _observation_params_rest_only(
     """
 
 
-# TODO: Are array params (e.g. `flickr_photos[]`) required to have "[]" in the param name?
-def _create_observations_params(
+def _create_observation(
     species_guess: str = None,
     taxon_id: int = None,
     observed_on_string: Date = None,
@@ -242,7 +242,7 @@ def _create_observations_params(
     flickr_photos: MultiInt = None,
     picasa_photos: MultiStr = None,
     facebook_photos: MultiStr = None,
-    local_photos: MultiStr = None,
+    local_photos: Iterable[FileOrPath] = None,
 ):
     """
     species_guess: Equivalent to the "What did you see?" field on the observation form.
@@ -268,19 +268,17 @@ def _create_observations_params(
         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.
-    local_photos: [NOT IMPLEMENTED] Fields containing uploaded photo data. Request must have a ``Content-Type``
-        of ``"multipart"``. We recommend that you use the ``POST /observation_photos`` endpoint
-        instead.
+    local_photos: Image files, file-like objects, and/or paths for local photos to upload
     """
 
 
-def _update_observation_params(
+def _update_observation(
     # _method: str = None,  # Exposed as a client-specific workaround; not needed w/ `requests`
-    ignore_photos: bool = False,
+    ignore_photos: bool = True,
 ):
     """
     ignore_photos
-        If photos exist on the observation but are missing in the request, simpy ignore them
+        If photos exist on the observation but are missing in the request, simply ignore them
         instead of deleting the missing observation photos
     """
 
@@ -418,7 +416,7 @@ def _legacy_params(params: Dict[str, Any] = None):
 
 def _minify(minify: str = None):
     """
-    minify: Condense each match into a single string containg taxon ID, rank, and name
+    minify: Condense each match into a single string containing taxon ID, rank, and name
     """
 
 
@@ -461,6 +459,14 @@ def _pagination(
     """
 
 
+_get_observations = [
+    _legacy_params,
+    _observation_common,
+    _observation_node_only,
+    _bounding_box,
+]
+
+
 # TODO: Remove deprecated `search_query` kwarg in 0.12
 def _search_query(q: str = None, search_query: str = None):
     """
@@ -475,47 +481,3 @@ def _format_param_choices():
 
 
 MULTIPLE_CHOICE_PARAM_DOCS = "**Multiple-Choice Parameters:**\n" + _format_param_choices()
-
-
-# Request param combinations for Node API endpoints
-# Note: user_agent param is added to all functions by default
-# ------------------------------------------------------------
-
-_get_observations = [
-    _legacy_params,
-    _observation_params_common,
-    _observation_params_node_only,
-    _bounding_box,
-]
-
-get_observations_params = _get_observations + [_pagination, _only_id]
-get_all_observations_params = _get_observations + [_only_id]
-get_observation_species_counts_params = _get_observations
-get_all_observation_species_counts_params = _get_observations
-get_geojson_observations_params = _get_observations + [_geojson_properties]
-get_places_nearby_params = [_bounding_box, _name]
-get_projects_params = [_projects_params, _pagination]
-get_taxa_params = [_taxon_params, _taxon_id_params]
-get_taxa_autocomplete_params = [_taxon_params, _minify]
-
-
-# Request param combinations for REST API endpoints
-# ------------------------------------------------------------
-
-get_observations_params_rest = [
-    _observation_params_common,
-    _observation_params_rest_only,
-    _bounding_box,
-    _pagination,
-]
-get_observation_fields_params = [_search_query, _page]
-get_all_observation_fields_params = [_search_query]
-create_observations_params = [_legacy_params, _access_token, _create_observations_params]
-update_observation_params = [
-    _observation_id,
-    _legacy_params,
-    _access_token,
-    _create_observations_params,
-    _update_observation_params,
-]
-delete_observation_params = [_observation_id, _access_token]
diff --git a/pyinaturalist/constants.py b/pyinaturalist/constants.py
index 842eed2..d277904 100644
--- a/pyinaturalist/constants.py
+++ b/pyinaturalist/constants.py
@@ -1,5 +1,5 @@
 from datetime import date, datetime
-from typing import Any, Callable, Dict, List, Union
+from typing import Any, BinaryIO, Callable, Dict, List, Union
 
 INAT_NODE_API_BASE_URL = "https://api.inaturalist.org/v1/"
 INAT_BASE_URL = "https://www.inaturalist.org"
@@ -15,8 +15,10 @@ WRITE_HTTP_METHODS = ["PATCH", "POST", "PUT", "DELETE"]
 # Type aliases
 Date = Union[date, datetime, str]
 DateTime = Union[date, datetime, str]
+FileOrPath = Union[BinaryIO, str]
 IntOrStr = Union[int, str]
 JsonResponse = Dict[str, Any]
+ListResponse = List[Dict[str, Any]]
 RequestParams = Dict[str, Any]
 MultiInt = Union[int, List[int]]
 MultiStr = Union[str, List[str]]
diff --git a/pyinaturalist/forge_utils.py b/pyinaturalist/forge_utils.py
index 13c78c7..3bd4e5b 100644
--- a/pyinaturalist/forge_utils.py
+++ b/pyinaturalist/forge_utils.py
@@ -48,7 +48,7 @@ def document_request_params(template_functions: List[TemplateFunction]):
         template_functions: Template functions containing docstrings and params to apply to the
             wrapped function
     """
-    template_functions += [_user_agent]
+    template_functions = [*template_functions, _user_agent]
 
     def f(func):
         # Modify docstring
diff --git a/pyinaturalist/node_api.py b/pyinaturalist/node_api.py
index 099a0b5..bfdffca 100644
--- a/pyinaturalist/node_api.py
+++ b/pyinaturalist/node_api.py
@@ -6,28 +6,19 @@ Most recent API version tested: 1.3.0
 """
 from logging import getLogger
 from time import sleep
-from typing import Dict, List
+from typing import List
 
 import requests
 from urllib.parse import urljoin
 
-from pyinaturalist.api_docs import (
-    get_observations_params,
-    get_all_observations_params,
-    get_observation_species_counts_params,
-    get_all_observation_species_counts_params,
-    get_geojson_observations_params,
-    get_places_nearby_params,
-    get_projects_params,
-    get_taxa_params,
-    get_taxa_autocomplete_params,
-)
+from pyinaturalist import api_docs as docs
 from pyinaturalist.constants import (
     INAT_NODE_API_BASE_URL,
     PER_PAGE_RESULTS,
     THROTTLING_DELAY,
     MultiInt,
     JsonResponse,
+    RequestParams,
 )
 from pyinaturalist.exceptions import ObservationNotFound
 from pyinaturalist.forge_utils import document_request_params
@@ -97,8 +88,10 @@ def get_observation(observation_id: int, user_agent: str = None) -> JsonResponse
     raise ObservationNotFound()
 
 
-@document_request_params(get_observations_params)
-def get_observations(params: Dict = None, user_agent: str = None, **kwargs) -> JsonResponse:
+@document_request_params([*docs._get_observations, docs._pagination, docs._only_id])
+def get_observations(
+    params: RequestParams = None, user_agent: str = None, **kwargs
+) -> JsonResponse:
     """Search observations.
 
     **API reference:** http://api.inaturalist.org/v1/docs/#!/Observations/get_observations
@@ -131,9 +124,9 @@ def get_observations(params: Dict = None, user_agent: str = None, **kwargs) -> J
     return r.json()
 
 
-@document_request_params(get_all_observations_params)
+@document_request_params([*docs._get_observations, docs._only_id])
 def get_all_observations(
-    params: Dict = None, user_agent: str = None, **kwargs
+    params: RequestParams = None, user_agent: str = None, **kwargs
 ) -> List[JsonResponse]:
     """Like :py:func:`get_observations()`, but handles pagination and returns all results in one
     call. Explicit pagination parameters will be ignored.
@@ -179,7 +172,7 @@ def get_all_observations(
         id_above = results[-1]["id"]
 
 
-# @document_request_params(get_observation_species_counts_params)
+@document_request_params([*docs._get_observations, docs._pagination])
 def get_observation_species_counts(user_agent: str = None, **kwargs) -> JsonResponse:
     """Get all species (or other "leaf taxa") associated with observations matching the search
     criteria, and the count of observations they are associated with.
@@ -208,7 +201,7 @@ def get_observation_species_counts(user_agent: str = None, **kwargs) -> JsonResp
     return r.json()
 
 
-# @document_request_params(get_all_observation_species_counts_params)
+@document_request_params(docs._get_observations)
 def get_all_observation_species_counts(user_agent: str = None, **kwargs) -> List[JsonResponse]:
     """Like :py:func:`get_observation_species_counts()`, but handles pagination and returns all
     results in one call. Explicit pagination parameters will be ignored.
@@ -258,7 +251,7 @@ def get_all_observation_species_counts(user_agent: str = None, **kwargs) -> List
         page += 1
 
 
-@document_request_params(get_geojson_observations_params)
+@document_request_params([*docs._get_observations, docs._geojson_properties])
 def get_geojson_observations(properties: List[str] = None, **kwargs) -> JsonResponse:
     """Get all observation results combined into a GeoJSON ``FeatureCollection``.
     By default this includes some basic observation properties as GeoJSON ``Feature`` properties.
@@ -329,7 +322,7 @@ def get_places_by_id(place_id: MultiInt, user_agent: str = None) -> JsonResponse
     return response
 
 
-@document_request_params(get_places_nearby_params)
+@document_request_params([docs._bounding_box, docs._name])
 def get_places_nearby(user_agent: str = None, **kwargs) -> JsonResponse:
     """
     Given an bounding box, and an optional name query, return standard iNaturalist curator approved
@@ -388,7 +381,7 @@ def get_places_autocomplete(q: str, user_agent: str = None) -> JsonResponse:
 # --------------------
 
 
-@document_request_params(get_projects_params)
+@document_request_params([docs._projects_params, docs._pagination])
 def get_projects(user_agent: str = None, **kwargs) -> JsonResponse:
     """Given zero to many of following parameters, get projects matching the search criteria.
 
@@ -478,7 +471,7 @@ def get_projects_by_id(
 # --------------------
 
 
-@document_request_params(get_taxa_params)
+@document_request_params([docs._taxon_params, docs._taxon_id_params])
 def get_taxa(user_agent: str = None, **kwargs) -> JsonResponse:
     """Given zero to many of following parameters, get taxa matching the search criteria.
 
@@ -539,7 +532,7 @@ def get_taxa_by_id(taxon_id: MultiInt, user_agent: str = None) -> JsonResponse:
     return r.json()
 
 
-@document_request_params(get_taxa_autocomplete_params)
+@document_request_params([docs._taxon_params, docs._minify])
 def get_taxa_autocomplete(user_agent: str = None, **kwargs) -> JsonResponse:
     """Given a query string, returns taxa with names starting with the search term
 
diff --git a/pyinaturalist/request_params.py b/pyinaturalist/request_params.py
index b7f23c8..c1e6fe7 100644
--- a/pyinaturalist/request_params.py
+++ b/pyinaturalist/request_params.py
@@ -1,11 +1,15 @@
 """Helper functions for processing and validating request parameters"""
 from datetime import date, datetime
-from typing import Any, Dict, Iterable, List, Optional
+from io import BytesIO
+from logging import getLogger
+from os.path import abspath, expanduser
+from typing import Any, BinaryIO, Dict, Iterable, Optional
 import warnings
 
 from dateutil.parser import parse as parse_timestamp
 from dateutil.tz import tzlocal
-from pyinaturalist.constants import RequestParams
+from pyinaturalist.constants import FileOrPath, RequestParams
+
 
 # Basic observation attributes to include by default in geojson responses
 DEFAULT_OBSERVATION_ATTRS = [
@@ -140,6 +144,8 @@ MULTIPLE_CHOICE_ERROR_MSG = (
     "Parameter '{}' must have one of the following values: {}\n\tValue provided: {}"
 )
 
+logger = getLogger(__name__)
+
 
 def preprocess_request_params(params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
     """ Perform type conversions, sanity checks, etc. on request parameters """
@@ -187,7 +193,8 @@ def convert_datetime_params(params: RequestParams) -> RequestParams:
     API behavior note: params that take date but not time info will accept a full timestamp and
     just ignore the time, so it's safe to parse both date and datetime strings into timestamps
 
-    :raises: :py:exc:`dateutil.parser._parser.ParserError` if a date/datetime format is invalid
+    Raises:
+        :py:exc:`dateutil.parser._parser.ParserError` if a date/datetime format is invalid
     """
     for k, v in params.items():
         if isinstance(v, datetime) or isinstance(v, date):
@@ -198,11 +205,26 @@ def convert_datetime_params(params: RequestParams) -> RequestParams:
     return params
 
 
-def ensure_list(values):
+def ensure_file_obj(photo: FileOrPath) -> BinaryIO:
+    """Given a file objects or path, read it into a file-like object if it's a path"""
+    if isinstance(photo, str):
+        file_path = abspath(expanduser(photo))
+        logger.info("Reading from file: {}".format(file_path))
+        with open(file_path, "rb") as f:
+            return BytesIO(f.read())
+    return photo
+
+
+def ensure_file_objs(photos: Iterable[FileOrPath]) -> Iterable[BinaryIO]:
+    """Given one or more file objects and/or paths, read any paths into a file-like object"""
+    return [ensure_file_obj(photo) for photo in ensure_list(photos)]
+
+
+def ensure_list(values: Any):
     """If the value is a string or comma-separated list of values, convert it into a list"""
     if not values:
-        return []
-    if isinstance(values, str) and "," in values:
+        values = []
+    elif isinstance(values, str) and "," in values:
         values = values.split(",")
     elif not isinstance(values, list):
         values = [values]
diff --git a/pyinaturalist/rest_api.py b/pyinaturalist/rest_api.py
index cd402fc..17565c6 100644
--- a/pyinaturalist/rest_api.py
+++ b/pyinaturalist/rest_api.py
@@ -3,19 +3,19 @@ Code used to access the (read/write, but slow) Rails based API of iNaturalist
 See: https://www.inaturalist.org/pages/api+reference
 """
 from time import sleep
-from typing import Dict, Any, List, BinaryIO, Union
+from typing import Dict, Any, List, Union
 
 from urllib.parse import urljoin
 
-from pyinaturalist.api_docs import (
-    get_observations_params_rest as get_observations_params,
-    get_observation_fields_params,
-    get_all_observation_fields_params,
-    create_observations_params,
-    update_observation_params,
-    delete_observation_params,
+from pyinaturalist import api_docs as docs
+from pyinaturalist.constants import (
+    THROTTLING_DELAY,
+    INAT_BASE_URL,
+    FileOrPath,
+    JsonResponse,
+    ListResponse,
+    RequestParams,
 )
-from pyinaturalist.constants import THROTTLING_DELAY, INAT_BASE_URL
 from pyinaturalist.exceptions import AuthenticationError, ObservationNotFound
 from pyinaturalist.api_requests import delete, get, post, put
 from pyinaturalist.forge_utils import document_request_params
@@ -23,6 +23,8 @@ from pyinaturalist.request_params import (
     OBSERVATION_FORMATS,
     REST_OBS_ORDER_BY_PROPERTIES,
     check_deprecated_params,
+    ensure_file_obj,
+    ensure_file_objs,
     validate_multiple_choice_param,
 )
 from pyinaturalist.response_format import convert_lat_long_to_float
@@ -66,7 +68,14 @@ def get_access_token(
         raise AuthenticationError("Authentication error, please check credentials.")
 
 
-@document_request_params(get_observations_params)
+@document_request_params(
+    [
+        docs._observation_common,
+        docs._observation_rest_only,
+        docs._bounding_box,
+        docs._pagination,
+    ]
+)
 def get_observations(user_agent: str = None, **kwargs) -> Union[List, str]:
     """Get observation data, optionally in an alternative format. Also see
     :py:func:`.get_geojson_observations` for GeoJSON format (not included here because it wraps
@@ -136,8 +145,8 @@ def get_observations(user_agent: str = None, **kwargs) -> Union[List, str]:
         return response.text
 
 
-@document_request_params(get_observation_fields_params)
-def get_observation_fields(user_agent: str = None, **kwargs) -> List[Dict[str, Any]]:
+@document_request_params([docs._search_query, docs._page])
+def get_observation_fields(user_agent: str = None, **kwargs) -> ListResponse:
     """Search observation fields. Observation fields are basically typed data fields that
     users can attach to observation.
 
@@ -156,7 +165,7 @@ def get_observation_fields(user_agent: str = None, **kwargs) -> List[Dict[str, A
     Returns:
         Observation fields as a list of dicts
     """
-    kwargs = check_deprecated_params(kwargs)
+    kwargs = check_deprecated_params(**kwargs)
     response = get(
         "{base_url}/observation_fields.json".format(base_url=INAT_BASE_URL),
         params=kwargs,
@@ -165,8 +174,8 @@ def get_observation_fields(user_agent: str = None, **kwargs) -> List[Dict[str, A
     return response.json()
 
 
-@document_request_params(get_all_observation_fields_params)
-def get_all_observation_fields(**kwargs) -> List[Dict[str, Any]]:
+@document_request_params([docs._search_query])
+def get_all_observation_fields(**kwargs) -> ListResponse:
     """
     Like :py:func:`.get_observation_fields()`, but handles pagination for you.
 
@@ -198,7 +207,7 @@ def put_observation_field_values(
     value: Any,
     access_token: str,
     user_agent: str = None,
-) -> Dict[str, Any]:
+) -> JsonResponse:
     # TODO: Also implement a put_or_update_observation_field_values() that deletes then recreates the field_value?
     # TODO: Write example use in docstring.
     # TODO: Return some meaningful exception if it fails because the field is already set.
@@ -258,11 +267,11 @@ def put_observation_field_values(
 
 
 # TODO: Implement `observation_field_values_attributes`, and simplify nested data structures
-# TODO: implement `local_photos` and support both local file path(s) and object(s)
-@document_request_params(create_observations_params)
+# TODO: more thorough usage example
+@document_request_params([docs._legacy_params, docs._access_token, docs._create_observation])
 def create_observations(
-    params: Dict[str, Dict[str, Any]], access_token: str, user_agent: str = None, **kwargs
-) -> List[Dict[str, Any]]:
+    params: RequestParams = None, access_token: str = None, user_agent: str = None, **kwargs
+) -> ListResponse:
     """Create one or more observations.
 
     **API reference:** https://www.inaturalist.org/pages/api+reference#post-observations
@@ -272,6 +281,7 @@ def create_observations(
         >>> create_observations(
         >>>     access_token=token,
         >>>     species_guess='Pieris rapae',
+        >>>     local_photos='~/observation_photos/2020_09_01_14003156.jpg',
         >>> )
 
         .. admonition:: Example Response
@@ -298,16 +308,17 @@ def create_observations(
     TODO investigate: according to the doc, we should be able to pass multiple observations (in an array, and in
     renaming observation to observations, but as far as I saw they are not created (while a status of 200 is returned)
     """
-    # This is the one Boolean parameter that's specified as an int, for some reason
-    if "ignore_photos" in kwargs:
-        kwargs["ignore_photos"] = int(kwargs["ignore_photos"])
-    kwargs = check_deprecated_params(kwargs)
-    if "observation" not in kwargs:
-        kwargs = {"observation": kwargs}
+    # Accept either top-level params (like most other endpoints)
+    # or nested params (like the iNat API actually accepts)
+    if "observation" in kwargs:
+        kwargs.update(kwargs.pop("observation"))
+    kwargs = check_deprecated_params(params, **kwargs)
+    if "local_photos" in kwargs:
+        kwargs["local_photos"] = ensure_file_objs(kwargs["local_photos"])
 
     response = post(
         url="{base_url}/observations.json".format(base_url=INAT_BASE_URL),
-        json=params,
+        json={"observation": kwargs},
         access_token=access_token,
         user_agent=user_agent,
     )
@@ -315,22 +326,40 @@ def create_observations(
     return response.json()
 
 
-@document_request_params(update_observation_params)
+@document_request_params(
+    [
+        docs._observation_id,
+        docs._legacy_params,
+        docs._access_token,
+        docs._create_observation,
+        docs._update_observation,
+    ]
+)
 def update_observation(
-    observation_id: int, params: Dict[str, Any], access_token: str, user_agent: str = None, **kwargs
-) -> List[Dict[str, Any]]:
+    observation_id: int,
+    params: RequestParams = None,
+    access_token: str = None,
+    user_agent: str = None,
+    **kwargs
+) -> ListResponse:
     """
     Update a single observation.
 
     **API reference:** https://www.inaturalist.org/pages/api+reference#put-observations-id
 
+    .. note::
+
+        Unlike the underlying REST API endpoint, this function will **not** delete any existing
+        photos from your observation if not specified in ``local_photos``. If you want this to
+        behave the same as the REST API and you do want to delete photos, call with
+        ``ignore_photos=False``.
+
     Example:
 
         >>> token = get_access_token('...')
         >>> update_observation(
         >>>     17932425,
         >>>     access_token=token,
-        >>>     ignore_photos=1,
         >>>     description="updated description!",
         >>> )
 
@@ -347,15 +376,24 @@ def update_observation(
         :py:exc:`requests.HTTPError`, if the call is not successful. iNaturalist returns an
             error 410 if the observation doesn't exists or belongs to another user.
     """
+    # Accept either top-level params (like most other endpoints)
+    # or nested params (like the iNat API actually accepts)
+    if "observation" in kwargs:
+        kwargs.update(kwargs.pop("observation"))
+    kwargs = check_deprecated_params(params, **kwargs)
+    if "local_photos" in kwargs:
+        kwargs["local_photos"] = ensure_file_objs(kwargs["local_photos"])
+
+    # This is the one Boolean parameter that's specified as an int, for some reason.
+    # Also, set it to True if not specified, which seems like much saner default behavior.
     if "ignore_photos" in kwargs:
         kwargs["ignore_photos"] = int(kwargs["ignore_photos"])
-    kwargs = check_deprecated_params(kwargs)
-    if "observation" not in kwargs:
-        kwargs = {"observation": kwargs}
+    else:
+        kwargs["ignore_photos"] = 1
 
     response = put(
         url="{base_url}/observations/{id}.json".format(base_url=INAT_BASE_URL, id=observation_id),
-        json=params,
+        json={"observation": kwargs},
         access_token=access_token,
         user_agent=user_agent,
     )
@@ -363,22 +401,24 @@ def update_observation(
     return response.json()
 
 
-# TODO: Support both local file path(s) and object(s)
 def add_photo_to_observation(
     observation_id: int,
-    file_object: BinaryIO,
+    photo: FileOrPath,
     access_token: str,
     user_agent: str = None,
 ):
-    """Upload a picture and assign it to an existing observation.
+    """Upload a local photo and assign it to an existing observation.
 
     **API reference:** https://www.inaturalist.org/pages/api+reference#post-observation_photos
 
     Example:
 
         >>> token = get_access_token('...')
-        >>> with open('~/observation_photos/2020_09_01_14003156.jpg', 'rb') as f:
-        >>>     add_photo_to_observation(1234, f, access_token=token)
+        >>> add_photo_to_observation(
+        >>>     1234,
+        >>>     '~/observation_photos/2020_09_01_14003156.jpg',
+        >>>     access_token=token,
+        >>> )
 
         .. admonition:: Example Response
             :class: toggle
@@ -388,29 +428,26 @@ def add_photo_to_observation(
 
     Args:
         observation_id: the ID of the observation
-        file_object: a file-like object for the picture
+        photo: An image file, file-like object, or path
         access_token: the access token, as returned by :func:`get_access_token()`
         user_agent: a user-agent string that will be passed to iNaturalist.
 
     Returns:
         Information about the newly created photo
     """
-    data = {"observation_photo[observation_id]": observation_id}
-    file_data = {"file": file_object}
-
     response = post(
         url="{base_url}/observation_photos".format(base_url=INAT_BASE_URL),
         access_token=access_token,
+        data={"observation_photo[observation_id]": observation_id},
+        files={"file": ensure_file_obj(photo)},
         user_agent=user_agent,
-        data=data,
-        files=file_data,
     )
 
     return response.json()
 
 
 # TODO: test this (success case, wrong_user/403 case)
-@document_request_params(delete_observation_params)
+@document_request_params([docs._observation_id, docs._access_token])
 def delete_observation(observation_id: int, access_token: str = None, user_agent: str = None):
     """
     Delete an observation.
@@ -420,7 +457,7 @@ def delete_observation(observation_id: int, access_token: str = None, user_agent
     Example:
 
         >>> token = get_access_token('...')
-        >>> delete_observation(17932425, access_token=token)
+        >>> delete_observation(17932425, token)
 
     Returns:
         If successful, no response is returned from this endpoint


Test patch:
diff --git a/test/test_request_params.py b/test/test_request_params.py
index ccc204f..343880a 100644
--- a/test/test_request_params.py
+++ b/test/test_request_params.py
@@ -1,6 +1,8 @@
 import pytest
 from datetime import date, datetime
 from dateutil.tz import gettz
+from io import BytesIO
+from tempfile import NamedTemporaryFile
 from unittest.mock import patch
 
 from pyinaturalist.request_params import (
@@ -8,6 +10,7 @@ from pyinaturalist.request_params import (
     convert_bool_params,
     convert_datetime_params,
     convert_list_params,
+    ensure_file_obj,
     strip_empty_params,
     validate_ids,
     validate_multiple_choice_param,
@@ -62,6 +65,20 @@ def test_convert_list_params():
     assert params["rank"] == "phylum,class"
 
 
+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
+
+
 def test_strip_empty_params():
     params = strip_empty_params(TEST_PARAMS)
     assert len(params) == 5
diff --git a/test/test_rest_api.py b/test/test_rest_api.py
index cad4692..0fb640d 100644
--- a/test/test_rest_api.py
+++ b/test/test_rest_api.py
@@ -1,5 +1,6 @@
 from datetime import datetime, timedelta
 from io import BytesIO
+from unittest.mock import patch
 
 import pytest
 from requests import HTTPError
@@ -176,9 +177,9 @@ def test_update_observation(requests_mock):
 
     params = {
         "ignore_photos": 1,
-        "observation": {"description": "updated description v2 !"},
+        "description": "updated description v2 !",
     }
-    r = update_observation(observation_id=17932425, access_token="valid token", params=params)
+    r = update_observation(17932425, access_token="valid token", **params)
 
     # If all goes well we got a single element representing the updated observation, enclosed in a list.
     assert len(r) == 1
@@ -186,6 +187,16 @@ def test_update_observation(requests_mock):
     assert r[0]["description"] == "updated description v2 !"
 
 
+@patch("pyinaturalist.rest_api.ensure_file_objs")
+@patch("pyinaturalist.rest_api.put")
+def test_update_observation__local_photo(put, ensure_file_objs):
+    update_observation(1234, access_token="token", local_photos="photo.jpg")
+
+    # Make sure local_photos is replaced with the output of ensure_file_objs
+    called_params = put.call_args[1]["json"]["observation"]
+    assert called_params["local_photos"] == ensure_file_objs.return_value
+
+
 def test_update_nonexistent_observation(requests_mock):
     """When we try to update a non-existent observation, iNat returns an error 410 with "obs does not longer exists". """
     requests_mock.put(
@@ -196,11 +207,11 @@ def test_update_nonexistent_observation(requests_mock):
 
     params = {
         "ignore_photos": 1,
-        "observation": {"description": "updated description v2 !"},
+        "description": "updated description v2 !",
     }
 
     with pytest.raises(HTTPError) as excinfo:
-        update_observation(observation_id=999999999, access_token="valid token", params=params)
+        update_observation(999999999, access_token="valid token", **params)
     assert excinfo.value.response.status_code == 410
     assert excinfo.value.response.json() == {"error": "Cette observation n’existe plus."}
 
@@ -215,15 +226,11 @@ def test_update_observation_not_mine(requests_mock):
 
     params = {
         "ignore_photos": 1,
-        "observation": {"description": "updated description v2 !"},
+        "description": "updated description v2 !",
     }
 
     with pytest.raises(HTTPError) as excinfo:
-        update_observation(
-            observation_id=16227955,
-            access_token="valid token for another user",
-            params=params,
-        )
+        update_observation(16227955, access_token="valid token for another user", **params)
     assert excinfo.value.response.status_code == 410
     assert excinfo.value.response.json() == {"error": "Cette observation n’existe plus."}
 
@@ -235,26 +242,28 @@ def test_create_observation(requests_mock):
         status_code=200,
     )
 
-    params = {
-        "observation": {"species_guess": "Pieris rapae"},
-    }
-
-    r = create_observations(params=params, access_token="valid token")
+    r = create_observations(species_guess="Pieris rapae", access_token="valid token")
     assert len(r) == 1  # We added a single one
-    assert (
-        r[0]["latitude"] is None
-    )  # We have the field, but it's none since we didn't submitted anything
+    assert r[0]["latitude"] is None
     assert r[0]["taxon_id"] == 55626  # Pieris Rapae @ iNaturalist
 
 
+@patch("pyinaturalist.rest_api.ensure_file_objs")
+@patch("pyinaturalist.rest_api.post")
+def test_create_observation__local_photo(post, ensure_file_objs):
+    create_observations(access_token="token", local_photos="photo.jpg")
+
+    # Make sure local_photos is replaced with the output of ensure_file_objs
+    called_params = post.call_args[1]["json"]["observation"]
+    assert called_params["local_photos"] == ensure_file_objs.return_value
+
+
 def test_create_observation_fail(requests_mock):
     params = {
-        "observation": {
-            "species_guess": "Pieris rapae",
-            # Some invalid data so the observation is rejected...
-            "observed_on_string": (datetime.now() + timedelta(days=1)).isoformat(),
-            "latitude": 200,
-        }
+        "species_guess": "Pieris rapae",
+        # Some invalid data so the observation is rejected...
+        "observed_on_string": (datetime.now() + timedelta(days=1)).isoformat(),
+        "latitude": 200,
     }
 
     requests_mock.post(
@@ -264,7 +273,7 @@ def test_create_observation_fail(requests_mock):
     )
 
     with pytest.raises(HTTPError) as excinfo:
-        create_observations(params=params, access_token="valid token")
+        create_observations(access_token="valid token", **params)
     assert excinfo.value.response.status_code == 422
     assert "errors" in excinfo.value.response.json()  # iNat also give details about the errors