Repository: niconoe/pyinaturalist (python)

Issue:
Add Controlled Terms endpoints from Node API
I'd like to add the two [Controlled Terms](https://api.inaturalist.org/v1/docs/#/Controlled_Terms) from the Node API.

- [x] `/controlled_terms`
- [x] `/controlled_terms/for_taxon `
- [x] Doctrings + type annotation
- [x] Error handling
- [x] Usage examples
- [x] Sample response data
- [x] Unit tests

**Note:**  `/controlled_terms/for_taxon ` appears to return a **422** if the specified taxon does not exist, which is odd; 404 would seem more appropriate. This should be wrapped in a custom exception; see #49 

Pull request description:
Add Controlled Terms endpoints from Node API
Closes #35 

Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d5b5451..1c44287 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -24,8 +24,9 @@ $ open docs/_build/index.html
 $ xdg-open docs/_build/index.html
 ```
 
-[Hosted documentation](https://pyinaturalist.readthedocs.io/) is automatically updated when code
-gets pushed to the `master` branch.
+Documentation is automatically built by ReadTheDocs whenever code is merged into either `master` or `dev`:
+* [Stable release (master branch)](https://pyinaturalist.readthedocs.io/en/stable/)
+* [Development pre-release (dev branch)](https://pyinaturalist.readthedocs.io/en/latest/)
 
 For any new or changed behavior, add a brief high-level summary to `HISTORY.md`.
 This isn't needed for internal changes (tests, other docs, refactoring, etc.).
@@ -33,17 +34,37 @@ This isn't needed for internal changes (tests, other docs, refactoring, etc.).
 ### Tests
 
 We use the [pytest](https://docs.pytest.org/en/latest/) framework for unit testing.
-
 Just run the `pytest` command to run locally.
 
-We also use [tox](https://tox.readthedocs.io/en/latest/) to test multiple python versions, as well as test coverage, style, and type checks:
-
-Use the `tox` command to run locally. This is also run by Travis CI on all pull requests.
+We also use [tox](https://tox.readthedocs.io/en/latest/) to test multiple python versions.
+Use the `tox` command to run locally. This is also run by GitHub Actions on all pull requests.
 
 ### Type Annotations
 
-All functions / methods should have parameters and return value type annotations.
-Those type annotations are checked by MyPy (`tox -e mypy`) and will appear in the documentation.
+All parameters and return values should have type annotations, which will be checked by `mypy` and
+will show up in the documentation.
+
+### Formatting, Linting, Type Checking, Etc.
+Code checking and formatting tools used include:
+* [black](https://github.com/psf/black)
+* [isort](https://pycqa.github.io/isort/)
+* [flake8](https://flake8.pycqa.org/en/latest/)
+* [mypy](https://mypy.readthedocs.io/en/stable/getting_started.html)
+
+All of these will be run by GitHub Actions on pull requests.
+
+#### Pre-Commit Hooks
+Optionally, there is included config to easily set these up to run as a
+[pre-commit hook](https://github.com/pre-commit/pre-commit):
+```bash
+pre-commit install --config .github/pre-commit.yml
+```
+
+This can save you some time in that it will show you errors immediately rather than waiting for CI
+jobs to complete. You can disable these hooks at any time with:
+```bash
+pre-commit uninstall
+```
 
 ### Pull Requests
 Here are some general guidelines for submitting a pull request:
@@ -56,7 +77,7 @@ Here are some general guidelines for submitting a pull request:
 ### Releases
 For maintainers:
 
-Releases are based on git tags. Travis CI will build and deploy packages to PyPi on tagged commits
+Releases are based on git tags. GitHub Actions will build and deploy packages to PyPi on tagged commits
 on the `master` branch. Release steps:
 
 - Update the version in `pyinaturalist/__init__.py`
@@ -93,7 +114,7 @@ official pyinaturalist docs, in docstrings, or even on the web in blog posts,
 articles, and such.
 
 ### Submit Feedback
-The best way to send feedback is to file an issue at https://github.com/niconoe/pyinaturalist/issues.
+The best way to send feedback is to [create an issue](https://github.com/niconoe/pyinaturalist/issues/new/choose)
 
 If you are proposing a feature:
 
diff --git a/README.md b/README.md
index 5ee4931..8e6018f 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,5 @@
 # pyinaturalist
 
-
 [![Build status](https://github.com/niconoe/pyinaturalist/workflows/Build/badge.svg)](https://github.com/niconoe/pyinaturalist/actions)
 [![Documentation Status (stable)](https://img.shields.io/readthedocs/pyinaturalist/stable?label=docs%20%28master%29)](https://pyinaturalist.readthedocs.io/en/stable/)
 [![Documentation Status (latest)](https://img.shields.io/readthedocs/pyinaturalist/latest?label=docs%20%28dev%29)](https://pyinaturalist.readthedocs.io/en/latest/)
@@ -9,8 +8,55 @@
 [![PyPI - Python Versions](https://img.shields.io/pypi/pyversions/pyinaturalist)](https://pypi.org/project/pyinaturalist)
 [![PyPI - Format](https://img.shields.io/pypi/format/pyinaturalist?color=blue)](https://pypi.org/project/pyinaturalist)
 
-Python client for the [iNaturalist APIs](https://www.inaturalist.org/pages/api+reference).
-See full documentation at https://pyinaturalist.readthedocs.io.
+Pyinaturalist is an iNaturalist API client for python.
+See full project documentation at https://pyinaturalist.readthedocs.io.
+
+* [Summary](#pyinaturalist)
+    + [Features](#features)
+    + [Installation](#installation)
+    + [Development Status](#development-status)
+* [Usage Examples](#usage-examples)
+    + [Observations](#observations)
+    + [Species](#species)
+* [Feedback](#feedback)
+
+## Features
+
+[iNaturalist](https://www.inaturalist.org) is a rich source of biodiversity data, and offers an
+extensive API to interact with nearly every aspect of its platform. 
+
+If you want to explore or analyze these data using python, then pyinaturalist can help.
+It adds a number of python-specific conveniences, including:
+
+* **Requests:** Simplified usage with python types and data structures rather than raw request parameter strings   
+* **Responses:** Type conversions from JSON primitives to things you would expect in python
+* **Typing:** Complete parameter definitions with type annotations, which significantly enhances
+  usability within an IDE, Jupyter notebook, or any other environment with type checking & autocompletion
+* **Messages:** Improved error handling, which means less time spent figuring out why an API call failed
+* **Docs:** Thorough documentation with example requests and responses
+* **Security:** Keyring integration for secure credential storage
+* **Testing:** A dry-run mode to preview your requests before potentially modifying data
+
+Many of the most relevant API endpoints are implemented, including:
+* **Searching for:**
+    * controlled terms
+    * observations
+    * observation fields
+    * observation species counts
+    * places
+    * projects
+    * species
+* **Text search autocompletion for:**
+    * places
+    * species
+* **Creating and updating:**
+    * observations
+    * observation fields
+    * observation photos
+
+See [Endpoints](https://pyinaturalist.readthedocs.io/en/latest/endpoints.html) for a complete list of
+implemented endpoints, and see [Reference](https://pyinaturalist.readthedocs.io/en/latest/reference.html)
+to skip straight to the API docs.
 
 ## Installation
 
@@ -44,48 +90,31 @@ $ pip install python-dateutil requests
 ```
 
 ## Development Status
+Pyinaturalist is under active development. More endpoints and features will continue to be added as
+they are needed or requested.
 
-Pyinaturalist is under active development. Currently, a handful of the most relevant API endpoints
-are implemented, including:
-
-* Searching, creating, and updating observations and observation fields
-* Searching for places, projects, species, and species counts
-* Text search autocompletion for species and places
-
-See below for some examples,
-see [Endpoints](https://pyinaturalist.readthedocs.io/en/latest/endpoints.html) for a complete list of implemented endpoints, and
-see [Issues](https://github.com/niconoe/pyinaturalist/issues) for planned & proposed features.
-
-More endpoints will continue to be added as they are needed.
-Please **create an issue** if there is an endpoint you would like to have added, and **PRs are welcome!**
+* See [History](https://github.com/niconoe/pyinaturalist/blob/dev/HISTORY.md) for details on past and current releases
+* See [Issues](https://github.com/niconoe/pyinaturalist/issues) for planned & proposed features
+* [Create an issue](https://github.com/niconoe/pyinaturalist/issues/new/choose) if there is an endpoint
+  or feature you would like to have added
+* **PRs are welcome!**
 
-**Note:**
-
-The two iNaturalist APIs expose a combined total of 103 endpoints\*. Some of these are generally
-useful and could potentially be added to pyinaturalist, but many others are primarily for
-internal use by the iNaturalist web application and mobile apps, and are unlikely to be added
-unless there are specific use cases for them.
-
-\*As of 2020-10-01: 37 in REST API, 65 in Node API, and 1 undocumented
-
-## Examples
+## Usage Examples
+Following are usage examples for some of the most commonly used basic features.
 
 ### Observations
 
 #### Search observations
-
+There are [numerous fields you can search on](https://pyinaturalist.readthedocs.io/en/latest/modules/pyinaturalist.node_api.html#pyinaturalist.node_api.get_observations).
+An obvious search to start with would be getting all of your own observations:
 ```python
 from pyinaturalist.node_api import get_all_observations
 obs = get_all_observations(user_id='my_username')
 ```
 
-See [available parameters](https://api.inaturalist.org/v1/docs/#!/Observations/get_observations/)
-
 #### Get an access token
-
 For authenticated API calls (creating/updating/deleting data), you first need to obtain an access token.
 This requires creating an [iNaturalist app](https://www.inaturalist.org/oauth/applications/new).
-
 ```python
 from pyinaturalist.auth import get_access_token
 token = get_access_token(
@@ -123,18 +152,18 @@ new_observation_id = response[0]['id']
 ```python
 from pyinaturalist.rest_api import add_photo_to_observation
 
-r = add_photo_to_observation(
+add_photo_to_observation(
     new_observation_id,
     access_token=token,
     photo='/Users/nicolasnoe/vespa.jpg',
 )
 ```
 
-#### Update an existing observation of yours
+#### Update an existing observation
 ```python
 from pyinaturalist.rest_api import update_observation
 
-r = update_observation(
+update_observation(
     17932425,
     access_token=token,
     description='updated description !',
@@ -144,42 +173,45 @@ r = update_observation(
 #### Get a list of all (globally available) observation fields
 ```python
 from pyinaturalist.rest_api import get_all_observation_fields
-r = get_all_observation_fields(search_query="DNA")
+response = get_all_observation_fields(search_query="DNA")
 ```
 
-#### Set an observation field value on an existing observation
+#### Set an observation field on an existing observation
+[Observation Fields](https://www.inaturalist.org/pages/extra_fields_nz) are a way to add extra information
+to your observations. They are similar to tags, but with a typed value.
 ```python
-from pyinaturalist.rest_api import put_observation_field_values
+from pyinaturalist.rest_api import get_observation_fields, put_observation_field_values
+
+# First find an observation field by name, if the ID is unknown
+response = get_observation_fields('vespawatch_id')
+observation_field_id = response[0]['id']
 
 put_observation_field_values(
     observation_id=7345179,
-    observation_field_id=9613,
+    observation_field_id=observation_field_id,
     value=250,
     access_token=token,
 )
 ```
 
 #### Get observation data in alternative formats
-A separate endpoint can provide other data formats, including Darwin Core, KML, and CSV:
-
+A [separate endpoint](https://pyinaturalist.readthedocs.io/en/latest/modules/pyinaturalist.rest_api.html#pyinaturalist.rest_api.get_observations)
+can provide other data formats, including Darwin Core, KML, and CSV:
 ```python
 from pyinaturalist.rest_api import get_observations
 obs = get_observations(user_id='niconoe', response_format='dwc')
 ```
 
-See [available parameters and formats](https://www.inaturalist.org/pages/api+reference#get-observations)
-
 #### Get observation species counts
-There is an additional endpoint to get counts of observations by species.
-On the iNaturalist web UI, this information can be found on the 'Species' tab of search results.
+You can also get counts of observations by species. On the iNaturalist web UI,
+this information can be found on the 'Species' tab of search results.
 For example, to get the counts of all your own research-grade observations:
-
 ```python
 from pyinaturalist.node_api import get_observation_species_counts
 obs_counts = get_observation_species_counts(user_id='my_username', quality_grade='research')
 ```
 
-### Taxonomy
+### Species
 
 #### Search species and other taxa
 Let's say you partially remember either a genus or family name that started with **'vespi'**-something:
@@ -201,7 +233,7 @@ from the results above:
 {343248: "Polistinae", 84738: "Vespinae", 119344: "Eumeninae", 121511: "Masarinae", ...}
 ```
 
-### Get a species by ID
+#### Get a species by ID
 Let's find out more about this 'Polistinae' genus. We could search for it by name or by ID,
 but since we already know the ID from the previous search, let's use that:
 
@@ -269,3 +301,15 @@ info. For example:
 >>> first_result["matched_term"]
 "Zygocactus truncatus"  # An older synonym for Schlumbergera
 ```
+
+### ...And much more!
+Check out the [Reference](https://pyinaturalist.readthedocs.io/en/latest/reference.html) section to
+see what else you can do with pyinaturalist.
+
+## Feedback
+If you have any problems, suggestions, or questions about pyinaturalist, please let us know!
+Just [create an issue here](https://github.com/niconoe/pyinaturalist/issues/new/choose).
+ 
+**Note:** pyinaturalist is not directly affiliated with iNaturalist.org or the
+California Academy of Sciences. If you have non-python-specific questions about iNaturalist, the
+[iNaturalist Community Forum](https://forum.inaturalist.org/) is going to be your best resource.
diff --git a/docs/endpoints.rst b/docs/endpoints.rst
index 9bd70e7..7ab0b01 100644
--- a/docs/endpoints.rst
+++ b/docs/endpoints.rst
@@ -1,3 +1,5 @@
+.. _endpoints:
+
 Implemented Endpoints
 =====================
 
@@ -24,5 +26,14 @@ All iNaturalist endpoints
 ----------------------------------------
 .. Writing the table in markdown because markdown table syntax is much more sane than rst
 
+.. note::
+
+    The two iNaturalist APIs expose a combined total of 103 endpoints\*. Some of these are generally
+    useful and could potentially be added to pyinaturalist, but many others are primarily for
+    internal use by the iNaturalist web application and mobile apps, and are unlikely to be added
+    unless there are specific use cases for them.
+
+    \*As of 2020-10-01: 37 in REST API, 65 in Node API, and 1 undocumented
+
 .. mdinclude:: endpoints_table.md
     :start-line: 1
diff --git a/docs/endpoints_table.md b/docs/endpoints_table.md
index 8b52db2..a1b89ee 100644
--- a/docs/endpoints_table.md
+++ b/docs/endpoints_table.md
@@ -14,8 +14,8 @@ DELETE            | /votes/unvote/annotation/{id}               |
 POST              | /comments                                   |
 DELETE            | /comments/{id}                              |
 PUT               | /comments/{id}                              |
-GET               | /controlled_terms                           |
-GET               | /controlled_terms/for_taxon                 |
+GET               | /controlled_terms                           | yes
+GET               | /controlled_terms/for_taxon                 | yes
 POST              | /flags                                      |
 DELETE            | /flags/{id}                                 |
 PUT               | /flags/{id}                                 |
diff --git a/docs/general_usage.rst b/docs/general_usage.rst
index 88fc926..fd343de 100644
--- a/docs/general_usage.rst
+++ b/docs/general_usage.rst
@@ -205,3 +205,9 @@ In the rare cases where you want to use multiple user agents in your script, you
     get_observation(observation_id=16227955, user_agent='AnotherUserAgent')
 
 All functions that communicate with the API accept the `user_agent` optional parameter. If you don't configure the user agent, `Pyinaturalist/<VERSION>` will be used.
+
+
+API Recommended Practices
+-------------------------
+See `API Recommended Practices <https://www.inaturalist.org/pages/api+recommended+practices>`_
+on iNaturalist for more general usage information and notes.
diff --git a/docs/index.rst b/docs/index.rst
index f6dd396..40809b5 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -5,7 +5,7 @@
     :end-line: 11
 
 .. mdinclude:: ../README.md
-    :start-line: 13
+    :start-line: 12
 
 
 Contents
@@ -20,12 +20,3 @@ Contents
    contributing
    authors
    history
-
-Feedback
-========
-
-If you have any suggestions or questions about **pyinaturalist** feel free to email me
-at nicolas@niconoe.eu.
-
-If you encounter any errors or problems with **pyinaturalist**, please let me know!
-Open an Issue at the GitHub http://github.com/niconoe/pyinaturalist main repository.
diff --git a/docs/reference.rst b/docs/reference.rst
index 716fb06..c5fd406 100644
--- a/docs/reference.rst
+++ b/docs/reference.rst
@@ -13,6 +13,7 @@ iNaturalist actually provides two APIs:
   fewer features.
 
 Pyinaturalist provides functions to use both of those APIs.
+Also see :ref:`endpoints` for an overview of which iNaturalist endpoints are currently implemented.
 
 .. Note: Source files for module docs are generated by sphinx-apidoc
 .. toctree::
diff --git a/pyinaturalist/exceptions.py b/pyinaturalist/exceptions.py
index b3d7344..1a2d6e5 100644
--- a/pyinaturalist/exceptions.py
+++ b/pyinaturalist/exceptions.py
@@ -7,3 +7,7 @@ class AuthenticationError(HTTPError):
 
 class ObservationNotFound(HTTPError):
     pass
+
+
+class TaxonNotFound(HTTPError):
+    pass
diff --git a/pyinaturalist/node_api.py b/pyinaturalist/node_api.py
index d2adf71..bb73860 100644
--- a/pyinaturalist/node_api.py
+++ b/pyinaturalist/node_api.py
@@ -28,7 +28,7 @@ from pyinaturalist.constants import (
     JsonResponse,
     MultiInt,
 )
-from pyinaturalist.exceptions import ObservationNotFound
+from pyinaturalist.exceptions import ObservationNotFound, TaxonNotFound
 from pyinaturalist.forge_utils import document_request_params
 from pyinaturalist.request_params import (
     DEFAULT_OBSERVATION_ATTRS,
@@ -57,6 +57,84 @@ def make_inaturalist_api_get_call(endpoint: str, **kwargs) -> requests.Response:
     return get(urljoin(INAT_NODE_API_BASE_URL, endpoint), **kwargs)
 
 
+# Controlled Terms
+# --------------------
+
+
+def get_controlled_terms(taxon_id: int = None, user_agent: str = None) -> JsonResponse:
+    """List controlled terms and their possible values.
+    A taxon ID can optionally be provided to show only terms that are valid for that taxon.
+    Otherwise, all controlled terms will be returned.
+
+    **API reference:**
+
+    * https://api.inaturalist.org/v1/docs/#!/Controlled_Terms/get_controlled_terms
+    * https://api.inaturalist.org/v1/docs/#!/Controlled_Terms/get_controlled_terms_for_taxon
+
+    Example:
+
+        >>> response = get_controlled_terms()
+        >>> # Show a condensed list of terms and values
+        >>> for term in response['results']:
+        >>>     values = [f'    {value["id"]}: {value["label"]}' for value in term['values']]
+        >>>     print(f'{term["id"]}: {term["label"]}' + '\\n'.join(values))
+        1: Life Stage
+            2: Adult
+            3: Teneral
+            4: Pupa
+            5: Nymph
+            6: Larva
+            7: Egg
+            8: Juvenile
+            16: Subimago
+        9: Sex
+            10: Female
+            11: Male
+            20: Cannot Be Determined
+        12: Plant Phenology
+            13: Flowering
+            14: Fruiting
+            15: Flower Budding
+            21: No Evidence of Flowering
+        17: Alive or Dead
+            18: Alive
+            19: Dead
+            20: Cannot Be Determined
+
+        .. admonition:: Example Response (all terms)
+            :class: toggle
+
+            .. literalinclude:: ../sample_data/get_controlled_terms.json
+                :language: JSON
+
+        .. admonition:: Example Response (for a specific taxon)
+            :class: toggle
+
+            .. literalinclude:: ../sample_data/get_controlled_terms_for_taxon.json
+                :language: JSON
+    Args:
+        taxon_id: ID of taxon to get controlled terms for
+        user_agent: a user-agent string that will be passed to iNaturalist.
+
+    Returns:
+        A dict containing details on controlled terms and their values
+
+    Raises:
+        :py:exc:`.TaxonNotFound` If an invalid taxon_id is specified
+    """
+    # This is actually two endpoints, but they are so similar it seems best to combine them
+    endpoint = "controlled_terms/for_taxon" if taxon_id else "controlled_terms"
+    response = make_inaturalist_api_get_call(
+        endpoint, params={"taxon_id": taxon_id}, user_agent=user_agent
+    )
+
+    # controlled_terms/for_taxon returns a 422 if the specified taxon does not exist
+    if response.status_code in (404, 422):
+        raise TaxonNotFound
+    response.raise_for_status()
+    return response.json()
+
+
 # Observations
 # --------------------
 
@@ -84,7 +162,7 @@ def get_observation(observation_id: int, user_agent: str = None) -> JsonResponse
         A dict with details on the observation
 
     Raises:
-        :py:exc:`.ObservationNotFound`
+        :py:exc:`.ObservationNotFound` If an invalid observation is specified
     """
 
     r = get_observations(id=observation_id, user_agent=user_agent)
@@ -127,6 +205,7 @@ def get_observations(user_agent: str = None, **params) -> JsonResponse:
     return r.json()
 
 
+# TODO: Should use a requests Session here for several subsequent requests
 @document_request_params([*docs._get_observations, docs._only_id])
 def get_all_observations(user_agent: str = None, **params) -> List[JsonResponse]:
     """Like :py:func:`get_observations()`, but handles pagination and returns all results in one


Test patch:
diff --git a/test/sample_data/get_controlled_terms.json b/test/sample_data/get_controlled_terms.json
new file mode 100644
index 0000000..bd0a6b3
--- /dev/null
+++ b/test/sample_data/get_controlled_terms.json
@@ -0,0 +1,292 @@
+{
+  "total_results": 4,
+  "page": 1,
+  "per_page": 30,
+  "results": [
+    {
+      "id": 12,
+      "ontology_uri": "",
+      "uri": "",
+      "is_value": false,
+      "multivalued": true,
+      "uuid": "e997f5f1-1e22-4f1e-bb51-425d70d07b1a",
+      "values": [
+        {
+          "id": 21,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": true,
+          "uuid": "943f8dac-764a-4736-86b5-2f419bbd368b",
+          "taxon_ids": [
+            47125
+          ],
+          "label": "No Evidence of Flowering"
+        },
+        {
+          "id": 13,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "uuid": "84aa676e-2921-42cf-ae01-034a98425b5c",
+          "taxon_ids": [
+            47125
+          ],
+          "label": "Flowering"
+        },
+        {
+          "id": 14,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "uuid": "503cef8b-770b-4c2d-a0b0-e0ea9f7df465",
+          "taxon_ids": [
+            47125
+          ],
+          "label": "Fruiting"
+        },
+        {
+          "id": 15,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "uuid": "9c9d28a1-4ad3-408e-9f2b-30af803f5eb9",
+          "taxon_ids": [
+            47125
+          ],
+          "label": "Flower Budding"
+        }
+      ],
+      "taxon_ids": [
+        47126
+      ],
+      "label": "Plant Phenology"
+    },
+    {
+      "id": 9,
+      "ontology_uri": "",
+      "uri": "",
+      "is_value": false,
+      "multivalued": false,
+      "uuid": "6a659546-c3aa-45ff-8f6c-0706f2361cfe",
+      "values": [
+        {
+          "id": 10,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "uuid": "3288a7bd-6ca7-413e-9890-5c4c541331de",
+          "label": "Female"
+        },
+        {
+          "id": 11,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "uuid": "207a8f33-ee23-44c9-88a5-e6a036a7d950",
+          "label": "Male"
+        },
+        {
+          "id": 20,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "uuid": "1e75ade7-f1c2-4ef7-994b-c5b07d52713e",
+          "label": "Cannot Be Determined"
+        }
+      ],
+      "excepted_taxon_ids": [
+        47170,
+        333586,
+        63081,
+        47113
+      ],
+      "label": "Sex"
+    },
+    {
+      "id": 17,
+      "ontology_uri": "",
+      "uri": "",
+      "is_value": false,
+      "multivalued": false,
+      "values": [
+        {
+          "id": 18,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "label": "Alive"
+        },
+        {
+          "id": 19,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "label": "Dead"
+        },
+        {
+          "id": 20,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "label": "Cannot Be Determined"
+        }
+      ],
+      "taxon_ids": [
+        1
+      ],
+      "label": "Alive or Dead"
+    },
+    {
+      "id": 1,
+      "ontology_uri": "",
+      "uri": "",
+      "is_value": false,
+      "multivalued": false,
+      "uuid": "f895e9ce-fb2d-4a63-a486-ac424f497e02",
+      "values": [
+        {
+          "id": 2,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "uuid": "48722062-360d-4b6e-b72c-129c7066b3fa",
+          "taxon_ids": [
+            1
+          ],
+          "label": "Adult"
+        },
+        {
+          "id": 3,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "uuid": "b3b6e6d0-cbce-4886-bb2b-55bd99d44586",
+          "taxon_ids": [
+            47792,
+            50190
+          ],
+          "label": "Teneral"
+        },
+        {
+          "id": 4,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "uuid": "c883d7b5-4eb5-4ad1-8cbc-56f5e2555869",
+          "taxon_ids": [
+            47208,
+            48763,
+            49369,
+            47157,
+            47822,
+            47201,
+            62164,
+            47864,
+            83204,
+            83202,
+            47794
+          ],
+          "label": "Pupa"
+        },
+        {
+          "id": 5,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "uuid": "0f0c03c2-4741-46db-a19a-7cac8a96d667",
+          "taxon_ids": [
+            47793,
+            81769,
+            56834,
+            47744,
+            47792,
+            47651,
+            47504,
+            48011,
+            48112,
+            53795,
+            47198,
+            83187,
+            83201,
+            83200
+          ],
+          "label": "Nymph"
+        },
+        {
+          "id": 6,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "uuid": "8ff477bd-0949-4a7a-9c67-64a8e3786876",
+          "taxon_ids": [
+            20978,
+            47208,
+            47822,
+            47201,
+            47157,
+            49369,
+            47864,
+            48763,
+            47794,
+            83204,
+            83202,
+            62164
+          ],
+          "label": "Larva"
+        },
+        {
+          "id": 7,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "uuid": "fe58aedb-4375-4096-a61f-1cd718cf319f",
+          "taxon_ids": [
+            43233,
+            47120,
+            47114,
+            47459,
+            85497,
+            47178,
+            20978,
+            3,
+            49231,
+            47273,
+            60450,
+            49099,
+            26036
+          ],
+          "label": "Egg"
+        },
+        {
+          "id": 8,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "uuid": "3245d167-ffff-44ce-a366-cfc8f711d73f",
+          "taxon_ids": [
+            1
+          ],
+          "excepted_taxon_ids": [
+            184884
+          ],
+          "label": "Juvenile"
+        },
+        {
+          "id": 16,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "uuid": "6ff4f9f8-7bc0-4d50-bee5-7072d11a04ef",
+          "taxon_ids": [
+            48011
+          ],
+          "label": "Subimago"
+        }
+      ],
+      "taxon_ids": [
+        1
+      ],
+      "label": "Life Stage"
+    }
+  ]
+}
diff --git a/test/sample_data/get_controlled_terms_for_taxon.json b/test/sample_data/get_controlled_terms_for_taxon.json
new file mode 100644
index 0000000..2603948
--- /dev/null
+++ b/test/sample_data/get_controlled_terms_for_taxon.json
@@ -0,0 +1,48 @@
+{
+  "total_results": 1,
+  "page": 1,
+  "per_page": 30,
+  "results": [
+    {
+      "id": 9,
+      "ontology_uri": "",
+      "uri": "",
+      "is_value": false,
+      "multivalued": false,
+      "uuid": "6a659546-c3aa-45ff-8f6c-0706f2361cfe",
+      "values": [
+        {
+          "id": 10,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "uuid": "3288a7bd-6ca7-413e-9890-5c4c541331de",
+          "label": "Female"
+        },
+        {
+          "id": 11,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "uuid": "207a8f33-ee23-44c9-88a5-e6a036a7d950",
+          "label": "Male"
+        },
+        {
+          "id": 20,
+          "ontology_uri": "",
+          "uri": "",
+          "blocking": false,
+          "uuid": "1e75ade7-f1c2-4ef7-994b-c5b07d52713e",
+          "label": "Cannot Be Determined"
+        }
+      ],
+      "excepted_taxon_ids": [
+        47170,
+        333586,
+        63081,
+        47113
+      ],
+      "label": "Sex"
+    }
+  ]
+}
diff --git a/test/test_node_api.py b/test/test_node_api.py
index 4adc9f1..8b2bd17 100644
--- a/test/test_node_api.py
+++ b/test/test_node_api.py
@@ -9,6 +9,7 @@ from pyinaturalist.exceptions import ObservationNotFound
 from pyinaturalist.node_api import (
     get_all_observation_species_counts,
     get_all_observations,
+    get_controlled_terms,
     get_geojson_observations,
     get_observation,
     get_observation_species_counts,
@@ -22,9 +23,50 @@ from pyinaturalist.node_api import (
     get_taxa_autocomplete,
     get_taxa_by_id,
 )
-from pyinaturalist.rest_api import get_access_token
 from test.conftest import load_sample_data
 
+# Controlled Terms
+# --------------------
+
+
+def test_get_controlled_terms(requests_mock):
+    requests_mock.get(
+        urljoin(INAT_NODE_API_BASE_URL, "controlled_terms"),
+        json=load_sample_data("get_controlled_terms.json"),
+        status_code=200,
+    )
+    response = get_controlled_terms()
+    assert len(response["results"]) == 4
+    first_result = response["results"][0]
+
+    assert first_result["id"] == 12
+    assert first_result["multivalued"] is True
+    assert first_result["label"] == "Plant Phenology"
+    assert len(first_result["values"]) == 4
+    assert first_result["values"][0]
+    assert first_result["values"][0]["id"] == 21
+    assert first_result["values"][0]["label"] == "No Evidence of Flowering"
+
+
+def test_get_controlled_terms_for_taxon(requests_mock):
+    requests_mock.get(
+        urljoin(INAT_NODE_API_BASE_URL, "controlled_terms/for_taxon"),
+        json=load_sample_data("get_controlled_terms_for_taxon.json"),
+        status_code=200,
+    )
+    response = get_controlled_terms(47651)
+    assert len(response["results"]) == 1
+    first_result = response["results"][0]
+
+    assert first_result["id"] == 9
+    assert first_result["multivalued"] is False
+    assert first_result["label"] == "Sex"
+    assert len(first_result["values"]) == 3
+    assert first_result["values"][0]
+    assert first_result["values"][0]["id"] == 10
+    assert first_result["values"][0]["label"] == "Female"
+
+
 # Observations
 # --------------------