Repository: niconoe/pyinaturalist
Pull request #154 (2021-05-31 01:24:17)

Add POST /observation_sounds endopoint and related updates
* Add `POST /observation_sounds` endpoint from v0 API
* Shorten function names to 'upload_photos' and 'upload_sounds' (keeping 'add_photo_to_observation' for backwards-compatibility)
* Update `upload_sounds()` and `upload_photos()` to accept multiple files


Patch:
diff --git a/HISTORY.md b/HISTORY.md
index 64c887d..dccf1b9 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -3,13 +3,24 @@
 ## 0.14 (2021-TBD)
 [See all Issues & PRs for 0.14](https://github.com/niconoe/pyinaturalist/milestone/5?closed=1)
 
-* Set up pyinaturalist-notebook to [runnable with Binder](https://mybinder.org/v2/gh/niconoe/pyinaturalist/main?filepath=examples)
-* Added models for all API response types, to support working with typed python objects instead of JSON
+### New Endpoints
+* Added new function for **Observation sounds** endpoint: `upload_sounds()`
+### Modified Endpoints
+* Added a `photos` parameter `create_observation()` and `update_observation()` to upload photos
+* Added a `sounds` parameter `create_observation()` and `update_observation()` to upload sounds
+* Renamed `add_photo_to_observation()` to `upload_photos()`
+  * The alias `rest_api.add_photo_to_observation()` is still available for backwards-compatibility
+* Updated `upload_photos()` to take accept either a single photo or a list of photos, and return a list of responses
+* Updated `upload_sounds()` to take accept either a single sound or a list of sounds, and return a list of responses
+* Updated upload_photos()
 * Moved API functions into separate modules by API version and resource type.
     * All can still be imported via `from pyinaturalist import *`
-    * Imports from `pyinaturalist.rest_api` and `pyinaturalist.node_api` will still work, but are
-      deprecated and will be removed in a future release
-* Update `create_observation()` and `update_observation()` to use `POST /observation_photos` endpoint to upload photos
+    * Added aliases for backwards-compatibility, so imports from `pyinaturalist.rest_api` and
+      `pyinaturalist.node_api` will still work, but these are deprecated and will be removed in a future release
+
+### Other Changes
+* Set up pyinaturalist-notebook to [runnable with Binder](https://mybinder.org/v2/gh/niconoe/pyinaturalist/main?filepath=examples)
+* Added data models for all API response types, to support working with typed python objects instead of JSON
 
 -----
 ## 0.13 (2021-05-22)
diff --git a/README.md b/README.md
index d932fe5..feea0ab 100644
--- a/README.md
+++ b/README.md
@@ -143,28 +143,21 @@ response = create_observation(
     longitude=4.360216,
     positional_accuracy=50, # GPS accuracy in meters
     access_token=token,
+    photos=['~/observations/wasp1.jpg', '~/observations/wasp2.jpg']
 )
 
 # Save the new observation ID
 new_observation_id = response[0]['id']
 ```
 
-Next we can [upload a picture](https://pyinaturalist.readthedocs.io/en/stable/modules/pyinaturalist.rest_api.html#pyinaturalist.rest_api.add_photo_to_observation)
-for this observation:
-```python
-add_photo_to_observation(
-    new_observation_id,
-    access_token=token,
-    photo='/Users/nicolasnoe/vespa.jpg',
-)
-```
-
-And then [update the observation](https://pyinaturalist.readthedocs.io/en/stable/modules/pyinaturalist.rest_api.html#pyinaturalist.rest_api.update_observation):
+We can then [update the observation](https://pyinaturalist.readthedocs.io/en/stable/modules/pyinaturalist.rest_api.html#pyinaturalist.rest_api.update_observation) information, photos, or sounds:
 ```python
 update_observation(
     17932425,
     access_token=token,
     description='updated description !',
+    photos='~/observations/wasp_nest.jpg',
+    sounds='~/observations/wasp_nest.mp3',
 )
 ```
 
diff --git a/docs/endpoints.rst b/docs/endpoints.rst
index d12c42d..20213dc 100644
--- a/docs/endpoints.rst
+++ b/docs/endpoints.rst
@@ -14,6 +14,7 @@ API v1
 .. automodsumm:: pyinaturalist.v1
     :functions-only:
     :nosignatures:
+    :skip: get_v1
 
 API v0
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
diff --git a/docs/endpoints_table.md b/docs/endpoints_table.md
index fa32bf3..17e1ef8 100644
--- a/docs/endpoints_table.md
+++ b/docs/endpoints_table.md
@@ -2,7 +2,7 @@
 Below is a complete list of iNaturalist API endpoints, and the subset of them that have been
 implemented in pyinaturalist.
 
-### Node-based API
+### v1 API
 For full documentation, see: http://api.inaturalist.org/v1/docs/
 
 Method            | Endpoint                                    | Implemented
@@ -112,7 +112,7 @@ GET               | /points/{zoom}/{x}/{y}.grid.json            |
 POST              | /photos                                     |
 
 
-### Rails-Based API
+### v2 API
 For full documentation, see: https://www.inaturalist.org/pages/api+reference
 
 Method            | Endpoint                                    | Implemented
@@ -140,6 +140,7 @@ POST              | /observation_field_values                   |
 PUT               | /observation_field_values/{id}              | yes
 DELETE            | /observation_field_values/{id}              |
 POST              | /observation_photos                         | yes
+POST              | /observation_sounds                         | yes
 GET               | /places                                     |
 GET               | /projects                                   |
 GET               | /projects/{id}                              |
diff --git a/pyinaturalist/api_docs.py b/pyinaturalist/api_docs.py
index 6ce9342..3cabc30 100644
--- a/pyinaturalist/api_docs.py
+++ b/pyinaturalist/api_docs.py
@@ -331,6 +331,8 @@ def _create_observation(
     picasa_photos: MultiStr = None,
     facebook_photos: MultiStr = None,
     local_photos: Iterable[FileOrPath] = None,
+    photos: Iterable[FileOrPath] = None,
+    sounds: Iterable[FileOrPath] = None,
 ):
     """
     species_guess: Equivalent to the 'What did you see?' field on the observation form.
@@ -356,7 +358,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.
-    local_photos: Image files, file-like objects, and/or paths for local photos to upload
+    photos: Image files, file-like objects, and/or paths
+    sounds: Sound files, file-like objects, and/or pathss
     """
 
 
diff --git a/pyinaturalist/rest_api.py b/pyinaturalist/rest_api.py
index 24d4298..3906a89 100644
--- a/pyinaturalist/rest_api.py
+++ b/pyinaturalist/rest_api.py
@@ -16,3 +16,7 @@ def get_all_observation_fields(**params) -> ListResponse:
     msg = "get_all_observation_fields() is deprecated; please use get_observation_fields(page='all') instead"
     warn(DeprecationWarning(msg))
     return paginate_all(get_observation_fields, method='page', **params)['results']
+
+
+def add_photo_to_observation(*args, **kwargs):
+    return upload_photos(*args, **kwargs)
diff --git a/pyinaturalist/v0/__init__.py b/pyinaturalist/v0/__init__.py
index b580f16..149b5b7 100644
--- a/pyinaturalist/v0/__init__.py
+++ b/pyinaturalist/v0/__init__.py
@@ -14,9 +14,10 @@ Functions
 
 from pyinaturalist.v0.observation_fields import get_observation_fields, put_observation_field_values
 from pyinaturalist.v0.observations import (
-    add_photo_to_observation,
     create_observation,
     delete_observation,
     get_observations,
     update_observation,
+    upload_photos,
+    upload_sounds,
 )
diff --git a/pyinaturalist/v0/observations.py b/pyinaturalist/v0/observations.py
index 231e889..3f3c3b5 100644
--- a/pyinaturalist/v0/observations.py
+++ b/pyinaturalist/v0/observations.py
@@ -1,3 +1,4 @@
+# TODO: Decide on consistent terminology for POST/PUT endpoints, rename, and add DeprecationWarnings to previous names
 from logging import getLogger
 from typing import List, Union
 
@@ -141,8 +142,10 @@ def create_observation(access_token: str, **params) -> ListResponse:
         params.update(params.pop('observation'))
     params = convert_observation_fields(params)
 
-    # Split out photos to upload separately
+    # Split out photos and sounds to upload separately
     photos = ensure_list(params.pop('local_photos', None))
+    photos.extend(ensure_list(params.pop('photos', None)))  # Alias for 'local_photos'
+    sounds = ensure_list(params.pop('sounds', None))
 
     response = post(
         url=f'{API_V0_BASE_URL}/observations.json',
@@ -153,8 +156,10 @@ def create_observation(access_token: str, **params) -> ListResponse:
     response_json = response.json()
     observation_id = response_json[0]['id']
 
-    for photo in photos:
-        add_photo_to_observation(observation_id, photo=photo, access_token=access_token)
+    if photos:
+        upload_photos(observation_id, photos, access_token)
+    if sounds:
+        upload_sounds(observation_id, sounds, access_token)
     return response_json
 
 
@@ -211,8 +216,10 @@ def update_observation(
         params.update(params.pop('observation'))
     params = convert_observation_fields(params)
 
-    # Split out photos to upload separately
+    # Split out photos and sounds to upload separately
     photos = ensure_list(params.pop('local_photos', None))
+    photos.extend(ensure_list(params.pop('photos', None)))  # Alias for 'local_photos'
+    sounds = ensure_list(params.pop('sounds', None))
 
     # Note: this is the only Boolean parameter that's specified as an int
     if 'ignore_photos' in params:
@@ -227,34 +234,38 @@ def update_observation(
     )
     response.raise_for_status()
 
-    for photo in photos:
-        add_photo_to_observation(observation_id, photo=photo, access_token=access_token)
+    if photos:
+        upload_photos(observation_id, photos, access_token)
+    if sounds:
+        upload_sounds(observation_id, sounds, access_token)
     return response.json()
 
 
-def add_photo_to_observation(
+def upload_photos(
     observation_id: int,
-    photo: FileOrPath,
+    photos: FileOrPath,
     access_token: str,
     user_agent: str = None,
-):
+) -> ListResponse:
     """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()
-        >>> add_photo_to_observation(
+        >>> upload_photos(1234, '~/observations/2020_09_01_14003156.jpg', access_token=token)
+
+        Multiple photos can be uploaded at once:
+
+        >>> upload_photos(
         >>>     1234,
-        >>>     '~/observation_photos/2020_09_01_14003156.jpg',
+        >>>     ['~/observations/2020_09_01_14003156.jpg', '~/observations/2020_09_01_14004223.jpg'],
         >>>     access_token=token,
         >>> )
 
         .. admonition:: Example Response
             :class: toggle
 
-            .. literalinclude:: ../sample_data/add_photo_to_observation.json
+            .. literalinclude:: ../sample_data/post_observation_photos_list.json
                 :language: javascript
 
     Args:
@@ -264,17 +275,74 @@ def add_photo_to_observation(
         user_agent: a user-agent string that will be passed to iNaturalist.
 
     Returns:
-        Information about the newly created photo
+        Information about the uploaded photo(s)
     """
-    response = post(
-        url=f'{API_V0_BASE_URL}/observation_photos',
-        access_token=access_token,
-        data={'observation_photo[observation_id]': observation_id},
-        files={'file': ensure_file_obj(photo)},
-        user_agent=user_agent,
-    )
+    responses = []
 
-    return response.json()
+    for photo in ensure_list(photos):
+        response = post(
+            url=f'{API_V0_BASE_URL}/observation_photos',
+            access_token=access_token,
+            data={'observation_photo[observation_id]': observation_id},
+            files={'file': ensure_file_obj(photo)},
+            user_agent=user_agent,
+        )
+        responses.append(response)
+
+    return [response.json() for response in responses]
+
+
+def upload_sounds(
+    observation_id: int,
+    sounds: FileOrPath,
+    access_token: str,
+    user_agent: str = None,
+) -> ListResponse:
+    """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()
+        >>> upload_sounds(1234, '~/observations/2020_09_01_14003156.mp3', access_token=token)
+
+        .. admonition:: Example Response
+            :class: toggle
+
+            .. literalinclude:: ../sample_data/post_observation_sounds_list.json
+                :language: javascript
+
+        Multiple sounds can be uploaded at once:
+
+        >>> upload_sounds(
+        >>>     1234,
+        >>>     ['~/observations/2020_09_01_14003156.mp3', '~/observations/2020_09_01_14004223.wav'],
+        >>>     access_token=token,
+        >>> )
+
+    Args:
+        observation_id: the ID of the observation
+        sound: An audio 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 uploaded sound(s)
+    """
+    responses = []
+
+    for sound in ensure_list(sounds):
+        response = post(
+            url=f'{API_V0_BASE_URL}/observation_sounds',
+            access_token=access_token,
+            data={'observation_sounds[observation_id]': observation_id},
+            files={'file': ensure_file_obj(sound)},
+            user_agent=user_agent,
+        )
+        responses.append(response)
+
+    return [response.json() for response in responses]
 
 
 @document_request_params([docs._observation_id, docs._access_token])


Test patch:
diff --git a/scripts/observation_crud_test.py b/scripts/observation_crud_test.py
index a80c54b..dfb9b78 100644
--- a/scripts/observation_crud_test.py
+++ b/scripts/observation_crud_test.py
@@ -20,13 +20,13 @@ from os.path import join
 from pprint import pprint
 
 from pyinaturalist import (
-    add_photo_to_observation,
     create_observation,
     delete_observation,
     get_access_token,
     get_observation,
     put_observation_field_values,
     update_observation,
+    upload_photos,
 )
 from pyinaturalist.constants import SAMPLE_DATA_DIR
 
@@ -71,7 +71,7 @@ def create_test_obs(token):
 
 
 def update_test_obs(test_obs_id, token):
-    response = add_photo_to_observation(
+    response = upload_photos(
         test_obs_id,
         photo=SAMPLE_PHOTO,
         access_token=token,
diff --git a/test/sample_data/add_photo_to_observation.json b/test/sample_data/post_observation_photos.json
similarity index 100%
rename from test/sample_data/add_photo_to_observation.json
rename to test/sample_data/post_observation_photos.json
diff --git a/test/sample_data/post_observation_photos_list.json b/test/sample_data/post_observation_photos_list.json
new file mode 100644
index 0000000..352651b
--- /dev/null
+++ b/test/sample_data/post_observation_photos_list.json
@@ -0,0 +1,37 @@
+[
+  {
+    "id": 1234,
+    "observation_id": 1234,
+    "photo_id": 1234,
+    "position": null,
+    "created_at": "2020-09-24T21:06:16.964-05:00",
+    "updated_at": "2020-09-24T21:06:16.964-05:00",
+    "old_uuid": null,
+    "uuid": "f4ec883f-e835-4590-ab3e-97900bd454a5",
+    "created_at_utc": "2020-09-25T02:06:16.964Z",
+    "updated_at_utc": "2020-09-25T02:06:16.964Z",
+    "photo": {
+      "id": 1234,
+      "user_id": 1234,
+      "native_photo_id": "1234",
+      "square_url": null,
+      "thumb_url": null,
+      "small_url": null,
+      "medium_url": null,
+      "large_url": null,
+      "created_at": "2020-09-24T21:06:15.212-05:00",
+      "updated_at": "2020-09-24T21:06:15.212-05:00",
+      "native_page_url": null,
+      "native_username": "username",
+      "native_realname": "Firstname Lastname",
+      "license": 2,
+      "subtype": null,
+      "native_original_image_url": null,
+      "uuid": "632dddc1-85ad-4664-8438-de6e0afd7746",
+      "license_name": "Creative Commons Attribution-NonCommercial License",
+      "license_url": "http://creativecommons.org/licenses/by-nc/4.0/",
+      "attribution": "(c) Firstname Lastname, some rights reserved (CC BY-NC)",
+      "type": "LocalPhoto"
+    }
+  }
+]
diff --git a/test/sample_data/post_observation_sounds.json b/test/sample_data/post_observation_sounds.json
new file mode 100644
index 0000000..ba35008
--- /dev/null
+++ b/test/sample_data/post_observation_sounds.json
@@ -0,0 +1,18 @@
+{
+  "id": 233946,
+  "uuid": "d77d4725-5498-4145-8e2b-6e6580df88c6",
+  "created_at": "2021-05-30T17:36:40.286-05:00",
+  "updated_at": "2021-05-30T17:36:40.286-05:00",
+  "sound": {
+    "id": 239936,
+    "license_code": "cc-by-nc",
+    "attribution": "(c) Jordan Cook, some rights reserved (CC BY-NC)",
+    "native_sound_id": null,
+    "secret_token": null,
+    "file_url": "https://static.inaturalist.org/sounds/239936.mp3?1622414199",
+    "file_content_type": "audio/mpeg",
+    "play_local": true,
+    "subtype": null,
+    "flags": []
+  }
+}
diff --git a/test/sample_data/post_observation_sounds_list.json b/test/sample_data/post_observation_sounds_list.json
new file mode 100644
index 0000000..2a07fab
--- /dev/null
+++ b/test/sample_data/post_observation_sounds_list.json
@@ -0,0 +1,20 @@
+[
+  {
+    "id": 233946,
+    "uuid": "d77d4725-5498-4145-8e2b-6e6580df88c6",
+    "created_at": "2021-05-30T17:36:40.286-05:00",
+    "updated_at": "2021-05-30T17:36:40.286-05:00",
+    "sound": {
+      "id": 239936,
+      "license_code": "cc-by-nc",
+      "attribution": "(c) Jordan Cook, some rights reserved (CC BY-NC)",
+      "native_sound_id": null,
+      "secret_token": null,
+      "file_url": "https://static.inaturalist.org/sounds/239936.mp3?1622414199",
+      "file_content_type": "audio/mpeg",
+      "play_local": true,
+      "subtype": null,
+      "flags": []
+    }
+  }
+]
diff --git a/test/test_compat.py b/test/test_compat.py
index ce8a052..101cbea 100644
--- a/test/test_compat.py
+++ b/test/test_compat.py
@@ -13,6 +13,7 @@ def test_v0_imports():
         get_observations,
         put_observation_field_values,
         update_observation,
+        upload_photos,
     )
 
 
diff --git a/test/v0/test_observations_v0.py b/test/v0/test_observations_v0.py
index 8ac492c..814fdba 100644
--- a/test/v0/test_observations_v0.py
+++ b/test/v0/test_observations_v0.py
@@ -10,11 +10,12 @@ from pyinaturalist.constants import API_V0_BASE_URL
 from pyinaturalist.exceptions import ObservationNotFound
 from pyinaturalist.request_params import OBSERVATION_FORMATS
 from pyinaturalist.v0 import (
-    add_photo_to_observation,
     create_observation,
     delete_observation,
     get_observations,
     update_observation,
+    upload_photos,
+    upload_sounds,
 )
 from test.conftest import load_sample_data
 
@@ -67,13 +68,22 @@ def test_create_observation(requests_mock):
     assert response[0]['taxon_id'] == 55626
 
 
-@patch('pyinaturalist.v0.observations.add_photo_to_observation')
+@patch('pyinaturalist.v0.observations.upload_photos')
 @patch('pyinaturalist.v0.observations.post')
-def test_create_observation__with_photos(post, mock_add_photo):
-    create_observation(access_token='token', local_photos=['photo_1.jpg', 'photo_2.jpg'])
+def test_create_observation__with_photos(post, mock_upload_photos):
+    create_observation(access_token='token', photos=['photo_1.jpg', 'photo_2.jpg'])
 
     assert 'local_photos' not in post.call_args[1]['json']['observation']
-    assert mock_add_photo.call_count == 2
+    mock_upload_photos.assert_called_once()
+
+
+@patch('pyinaturalist.v0.observations.upload_sounds')
+@patch('pyinaturalist.v0.observations.post')
+def test_create_observation__with_sounds(post, mock_upload_sounds):
+    create_observation(access_token='token', sounds=['sound_1.mp3', 'sound_2.wav'])
+
+    assert 'sounds' not in post.call_args[1]['json']['observation']
+    mock_upload_sounds.assert_called_once()
 
 
 def test_create_observation_fail(requests_mock):
@@ -115,13 +125,22 @@ def test_update_observation(requests_mock):
     assert response[0]['description'] == 'updated description v2 !'
 
 
-@patch('pyinaturalist.v0.observations.add_photo_to_observation')
+@patch('pyinaturalist.v0.observations.upload_photos')
 @patch('pyinaturalist.v0.observations.put')
-def test_update_observation__local_photo(put, mock_add_photo):
-    update_observation(1234, access_token='token', local_photos='photo.jpg')
+def test_update_observation__with_photos(put, mock_upload_photos):
+    update_observation(1234, access_token='token', photos='photo.jpg')
 
     assert 'local_photos' not in put.call_args[1]['json']['observation']
-    assert mock_add_photo.call_count == 1
+    mock_upload_photos.assert_called_once()
+
+
+@patch('pyinaturalist.v0.observations.upload_sounds')
+@patch('pyinaturalist.v0.observations.put')
+def test_update_observation__with_sounds(put, mock_upload_sounds):
+    update_observation(1234, access_token='token', sounds='photo.jpg')
+
+    assert 'sounds' not in put.call_args[1]['json']['observation']
+    mock_upload_sounds.assert_called_once()
 
 
 def test_update_nonexistent_observation(requests_mock):
@@ -164,17 +183,30 @@ def test_update_observation_not_mine(requests_mock):
     assert excinfo.value.response.json() == {'error': 'Cette observation n’existe plus.'}
 
 
-def test_add_photo_to_observation(requests_mock):
+def test_upload_photos(requests_mock):
     requests_mock.post(
         f'{API_V0_BASE_URL}/observation_photos',
-        json=load_sample_data('add_photo_to_observation.json'),
+        json=load_sample_data('post_observation_photos.json'),
+        status_code=200,
+    )
+
+    response = upload_photos(1234, BytesIO(), access_token='token')
+    assert response[0]['id'] == 1234
+    assert response[0]['created_at'] == '2020-09-24T21:06:16.964-05:00'
+    assert response[0]['photo']['native_username'] == 'username'
+
+
+def test_upload_sounds(requests_mock):
+    requests_mock.post(
+        f'{API_V0_BASE_URL}/observation_sounds',
+        json=load_sample_data('post_observation_sounds.json'),
         status_code=200,
     )
 
-    response = add_photo_to_observation(1234, BytesIO(), access_token='token')
-    assert response['id'] == 1234
-    assert response['created_at'] == '2020-09-24T21:06:16.964-05:00'
-    assert response['photo']['native_username'] == 'username'
+    response = upload_sounds(233946, BytesIO(), access_token='token')
+    assert response[0]['id'] == 233946
+    assert response[0]['created_at'] == '2021-05-30T17:36:40.286-05:00'
+    assert response[0]['sound']['file_content_type'] == 'audio/mpeg'
 
 
 def test_delete_observation(requests_mock):