Repository: niconoe/pyinaturalist (python)

Issue:
Add GET /users endpoints
Add the two basic GET [Users endpoints](https://api.inaturalist.org/v1/docs/#/Users) from the Node (v1) API.

- [x] ` /users/{id}`
- [x] `/users/autocomplete`
- [x] Doctrings + type annotations
- [x] Usage examples
- [x] Sample response data
- [x] Unit tests

Pull request:
Add Users endpoints
Closes #80

Patch:
diff --git a/HISTORY.md b/HISTORY.md
index b42b1e7..72a5a50 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -4,6 +4,7 @@
 
 ### New Endpoints
 * Added new functions for **Identifications** endpoints: `get_identifications()` and `get_identifications_by_id()`
+* Added new functions for **Users** endpoints: `get_user_by_id()` and `get_users_autocomplete()`
 
 ### Modified Endpoints
 * Added undocumented `ident_user_id` parameter to `get_observations()`
diff --git a/docs/endpoints_table.md b/docs/endpoints_table.md
index 715afc1..9752438 100644
--- a/docs/endpoints_table.md
+++ b/docs/endpoints_table.md
@@ -90,10 +90,10 @@ GET               | /search                                     |
 GET               | /taxa/{id}                                  | yes
 GET               | /taxa                                       | yes
 GET               | /taxa/autocomplete                          | yes
-GET               | /users/{id}                                 |
+GET               | /users/{id}                                 | yes
 PUT               | /users/{id}                                 |
 GET               | /users/{id}/projects                        |
-GET               | /users/autocomplete                         |
+GET               | /users/autocomplete                         | yes
 GET               | /users/me                                   |
 DELETE            | /users/{id}/mute                            |
 POST              | /users/{id}/mute                            |
diff --git a/pyinaturalist/api_docs.py b/pyinaturalist/api_docs.py
index b978bc2..0e288c5 100644
--- a/pyinaturalist/api_docs.py
+++ b/pyinaturalist/api_docs.py
@@ -511,6 +511,12 @@ def _observation_id(observation_id: int):
     """
 
 
+def _project_id(observation_id: int = None):
+    """
+    project_id: Only show users who are members of this project
+    """
+
+
 def _pagination(
     page: int = None,
     per_page: int = None,
diff --git a/pyinaturalist/node_api.py b/pyinaturalist/node_api.py
index 30a619f..a13cd0f 100644
--- a/pyinaturalist/node_api.py
+++ b/pyinaturalist/node_api.py
@@ -36,6 +36,7 @@ from pyinaturalist.response_format import (
     convert_all_coordinates,
     convert_all_place_coordinates,
     convert_all_timestamps,
+    convert_generic_timestamps,
     convert_observation_timestamps,
     format_histogram,
 )
@@ -762,7 +763,7 @@ def get_taxa_by_id(taxon_id: MultiInt, user_agent: str = None) -> JsonResponse:
 
 @document_request_params([docs._taxon_params])
 def get_taxa_autocomplete(**params) -> JsonResponse:
-    """Given a query string, returns taxa with names starting with the search term
+    """Given a query string, return taxa with names starting with the search term
 
     **API reference:** https://api.inaturalist.org/v1/docs/#!/Taxa/get_taxa_autocomplete
 
@@ -801,3 +802,62 @@ def get_taxa_autocomplete(**params) -> JsonResponse:
     r = node_api_get('taxa/autocomplete', params=params)
     r.raise_for_status()
     return r.json()
+
+
+# Users
+# --------------------
+
+
+def get_user_by_id(user_id: int, user_agent: str = None) -> JsonResponse:
+    """Get a user by ID.
+
+    **API reference:** https://api.inaturalist.org/v1/docs/#!/Users/get_users_id
+
+    Args:
+        user_id: Get the user with this ID. Only a single ID is allowed per request.
+
+    Example:
+
+        >>> response = get_user_by_id(123456)
+
+        .. admonition:: Example Response
+            :class: toggle
+
+            .. literalinclude:: ../sample_data/get_user_by_id.py
+
+    Returns:
+        Response dict containing user record
+    """
+    r = node_api_get('users', ids=[user_id], user_agent=user_agent)
+    r.raise_for_status()
+    results = r.json()['results']
+    if not results:
+        return {}
+    return convert_generic_timestamps(results[0])
+
+
+@document_request_params([docs._search_query, docs._project_id, docs._pagination])
+def get_users_autocomplete(q: str, **params) -> JsonResponse:
+    """Given a query string, return users with names or logins starting with the search term
+
+    **API reference:** https://api.inaturalist.org/v1/docs/#!/Users/get_users_autocomplete
+
+    Note: Pagination is supported; default page size is 6, and max is 100.
+
+    Example:
+
+        >>> response = get_taxa_autocomplete(q='my_userna')
+
+        .. admonition:: Example Response
+            :class: toggle
+
+            .. literalinclude:: ../sample_data/get_users_autocomplete.py
+
+    Returns:
+        Response dict containing user records
+    """
+    r = node_api_get('users/autocomplete', params={'q': q, **params})
+    r.raise_for_status()
+    users = r.json()
+    users['results'] = convert_all_timestamps(users['results'])
+    return users


Test patch:
diff --git a/test/sample_data/get_user_by_id.json b/test/sample_data/get_user_by_id.json
new file mode 100644
index 0000000..9aa1472
--- /dev/null
+++ b/test/sample_data/get_user_by_id.json
@@ -0,0 +1,32 @@
+{
+  "total_results": 1,
+  "page": 1,
+  "per_page": 1,
+  "results": [
+    {
+      "id": 1,
+      "login": "kueda",
+      "spam": false,
+      "suspended": false,
+      "created_at": "2008-03-20T21:15:42+00:00",
+      "login_autocomplete": "kueda",
+      "login_exact": "kueda",
+      "name": "Ken-ichi Ueda",
+      "name_autocomplete": "Ken-ichi Ueda",
+      "orcid": "https://orcid.org/0000-0003-0145-6846",
+      "icon": "https://static.inaturalist.org/attachments/users/icons/1/thumb.jpg?1475527316",
+      "observations_count": 40947,
+      "identifications_count": 95624,
+      "journal_posts_count": 86,
+      "activity_count": 136657,
+      "species_count": 8831,
+      "universal_search_rank": 40947,
+      "roles": [
+        "admin",
+        "curator"
+      ],
+      "site_id": 1,
+      "icon_url": "https://static.inaturalist.org/attachments/users/icons/1/medium.jpg?1475527316"
+    }
+  ]
+}
diff --git a/test/sample_data/get_user_by_id.py b/test/sample_data/get_user_by_id.py
new file mode 100644
index 0000000..bac5a3c
--- /dev/null
+++ b/test/sample_data/get_user_by_id.py
@@ -0,0 +1,25 @@
+from datetime import datetime
+from dateutil.tz import tzutc
+
+{
+    'id': 1,
+    'login': 'kueda',
+    'spam': False,
+    'suspended': False,
+    'created_at': datetime(2008, 3, 20, 21, 15, 42, tzinfo=tzutc()),
+    'login_autocomplete': 'kueda',
+    'login_exact': 'kueda',
+    'name': 'Ken-ichi Ueda',
+    'name_autocomplete': 'Ken-ichi Ueda',
+    'orcid': 'https://orcid.org/0000-0003-0145-6846',
+    'icon': 'https://static.inaturalist.org/attachments/users/icons/1/thumb.jpg?1475527316',
+    'observations_count': 40947,
+    'identifications_count': 95624,
+    'journal_posts_count': 86,
+    'activity_count': 136657,
+    'species_count': 8831,
+    'universal_search_rank': 40947,
+    'roles': ['admin', 'curator'],
+    'site_id': 1,
+    'icon_url': 'https://static.inaturalist.org/attachments/users/icons/1/medium.jpg?1475527316',
+}
diff --git a/test/sample_data/get_users_autocomplete.json b/test/sample_data/get_users_autocomplete.json
new file mode 100644
index 0000000..39d261c
--- /dev/null
+++ b/test/sample_data/get_users_autocomplete.json
@@ -0,0 +1,71 @@
+{
+  "total_results": 3,
+  "page": 1,
+  "per_page": 3,
+  "results": [
+    {
+      "id": 886482,
+      "login": "niconoe",
+      "spam": false,
+      "suspended": false,
+      "created_at": "2018-04-23T17:11:14+00:00",
+      "login_autocomplete": "niconoe",
+      "login_exact": "niconoe",
+      "name": "Nicolas Noé",
+      "name_autocomplete": "Nicolas Noé",
+      "orcid": "https://orcid.org/0000-0002-9503-4750",
+      "icon": "https://static.inaturalist.org/attachments/users/icons/886482/thumb.jpg?1529671435",
+      "observations_count": 928,
+      "identifications_count": 118,
+      "journal_posts_count": 0,
+      "activity_count": 1046,
+      "species_count": 396,
+      "universal_search_rank": 928,
+      "roles": [],
+      "site_id": 1,
+      "icon_url": "https://static.inaturalist.org/attachments/users/icons/886482/medium.jpg?1529671435"
+    },
+    {
+      "id": 2909130,
+      "login": "niconoerbo",
+      "spam": false,
+      "suspended": false,
+      "created_at": "2020-05-05T06:28:32+00:00",
+      "login_autocomplete": "niconoerbo",
+      "login_exact": "niconoerbo",
+      "name": null,
+      "name_autocomplete": null,
+      "orcid": null,
+      "icon": null,
+      "observations_count": 6,
+      "identifications_count": 0,
+      "journal_posts_count": 0,
+      "activity_count": 6,
+      "universal_search_rank": 6,
+      "roles": [],
+      "site_id": 1,
+      "icon_url": null
+    },
+    {
+      "id": 3358478,
+      "login": "nicono",
+      "spam": false,
+      "suspended": false,
+      "created_at": "2020-07-20T18:07:44+00:00",
+      "login_autocomplete": "nicono",
+      "login_exact": "nicono",
+      "name": null,
+      "name_autocomplete": null,
+      "orcid": null,
+      "icon": null,
+      "observations_count": 0,
+      "identifications_count": 0,
+      "journal_posts_count": 0,
+      "activity_count": 0,
+      "universal_search_rank": 0,
+      "roles": [],
+      "site_id": 1,
+      "icon_url": null
+    }
+  ]
+}
diff --git a/test/sample_data/get_users_autocomplete.py b/test/sample_data/get_users_autocomplete.py
new file mode 100644
index 0000000..59657e9
--- /dev/null
+++ b/test/sample_data/get_users_autocomplete.py
@@ -0,0 +1,74 @@
+from datetime import datetime
+from dateutil.tz import tzutc
+
+{
+    'total_results': 3,
+    'page': 1,
+    'per_page': 3,
+    'results': [
+        {
+            'id': 886482,
+            'login': 'niconoe',
+            'spam': False,
+            'suspended': False,
+            'created_at': datetime(2018, 4, 23, 17, 11, 14, tzinfo=tzutc()),
+            'login_autocomplete': 'niconoe',
+            'login_exact': 'niconoe',
+            'name': 'Nicolas Noé',
+            'name_autocomplete': 'Nicolas Noé',
+            'orcid': 'https://orcid.org/0000-0002-9503-4750',
+            'icon': 'https://static.inaturalist.org/attachments/users/icons/886482/thumb.jpg?1529671435',
+            'observations_count': 928,
+            'identifications_count': 118,
+            'journal_posts_count': 0,
+            'activity_count': 1046,
+            'species_count': 396,
+            'universal_search_rank': 928,
+            'roles': [],
+            'site_id': 1,
+            'icon_url': 'https://static.inaturalist.org/attachments/users/icons/886482/medium.jpg?1529671435',
+        },
+        {
+            'id': 2909130,
+            'login': 'niconoerbo',
+            'spam': False,
+            'suspended': False,
+            'created_at': datetime(2020, 5, 5, 6, 28, 32, tzinfo=tzutc()),
+            'login_autocomplete': 'niconoerbo',
+            'login_exact': 'niconoerbo',
+            'name': None,
+            'name_autocomplete': None,
+            'orcid': None,
+            'icon': None,
+            'observations_count': 6,
+            'identifications_count': 0,
+            'journal_posts_count': 0,
+            'activity_count': 6,
+            'universal_search_rank': 6,
+            'roles': [],
+            'site_id': 1,
+            'icon_url': None,
+        },
+        {
+            'id': 3358478,
+            'login': 'nicono',
+            'spam': False,
+            'suspended': False,
+            'created_at': datetime(2020, 7, 20, 18, 7, 44, tzinfo=tzutc()),
+            'login_autocomplete': 'nicono',
+            'login_exact': 'nicono',
+            'name': None,
+            'name_autocomplete': None,
+            'orcid': None,
+            'icon': None,
+            'observations_count': 0,
+            'identifications_count': 0,
+            'journal_posts_count': 0,
+            'activity_count': 0,
+            'universal_search_rank': 0,
+            'roles': [],
+            'site_id': 1,
+            'icon_url': None,
+        },
+    ],
+}
diff --git a/test/test_node_api.py b/test/test_node_api.py
index ede53d7..567d169 100644
--- a/test/test_node_api.py
+++ b/test/test_node_api.py
@@ -27,6 +27,8 @@ from pyinaturalist.node_api import (
     get_taxa,
     get_taxa_autocomplete,
     get_taxa_by_id,
+    get_user_by_id,
+    get_users_autocomplete,
 )
 from test.conftest import load_sample_data
 
@@ -553,6 +555,40 @@ def test_get_taxa_autocomplete(requests_mock):
     assert len(first_result['ancestor_ids']) == 11
 
 
+# Users
+# --------------------
+
+
+def test_get_user_by_id(requests_mock):
+    user_id = 1
+    requests_mock.get(
+        f'{API_V1_BASE_URL}/users/{user_id}',
+        json=load_sample_data('get_user_by_id.json'),
+        status_code=200,
+    )
+
+    user = get_user_by_id(user_id)
+    assert user['id'] == user_id
+    assert user['created_at'] == datetime(2008, 3, 20, 21, 15, 42, tzinfo=tzutc())
+    assert user['spam'] is False
+
+
+def test_get_users_autocomplete(requests_mock):
+    requests_mock.get(
+        f'{API_V1_BASE_URL}/users/autocomplete',
+        json=load_sample_data('get_users_autocomplete.json'),
+        status_code=200,
+    )
+
+    response = get_users_autocomplete(q='niconoe')
+    first_result = response['results'][0]
+
+    assert len(response['results']) == response['total_results'] == 3
+    assert first_result['id'] == 886482
+    assert first_result['login'] == 'niconoe'
+    assert first_result['created_at'] == datetime(2018, 4, 23, 17, 11, 14, tzinfo=tzutc())
+
+
 @patch.dict(os.environ, {'GITHUB_REF': 'refs/heads/master'}, clear=True)
 def test_user_agent(requests_mock):
     requests_mock.get(