Repository: niconoe/pyinaturalist (python)

Issue:
Add more response formatting functions
The current (optional) formatting functions for observations and taxa are handy for previewing response data. These aren't used by the API functions, but make it easier to show condensed data in examples, etc. I'd like to make these a little more consistent, move them to a separate `formatters` module, and add some more for other common response types.

It would also be convenient to have all of these functions accept any of the following.

* A JSON response
* A list of response objects
* A single response object

Pull request description:
Add more response formatting functions
Closes #129

Add response formatting functions for controller terms, identifications, places, projects, and users, and update docs to use those in examples.

Patch:
diff --git a/HISTORY.md b/HISTORY.md
index c805e93..f1036b3 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -28,18 +28,28 @@
   * `rest_api.get_all_observation_fields()`
 * Removed `node_api.get_all_observation_species_counts()`, since this was only added recently
 * Updated `rest_api.get_observation_fields()` to return a dict with `'results'` for consistency with other endpoints
-* Removed `minify` option to `get_taxa_autocomplete`, and instead show a similar example in docstring
+
+### Response Formatting
+* Removed `minify` option from `get_taxa_autocomplete`
+* Added the following response formatting functions to `pyinaturalist.formatters`:
+    * `format_controlled_terms()`
+    * `format_identifications()`
+    * `format_observations()`
+    * `format_places()`
+    * `format_projects()`
+    * `format_species_counts()`
+    * `format_taxa()`
+    * `format_users()`
+    * `simplify_observations()`
 
 ### Other Changes
 * Published [pyinaturalist on conda-forge](https://anaconda.org/conda-forge/pyinaturalist)
 * Added global rate-limiting to stay within the rates suggested in
   [API Recommended Practices](https://www.inaturalist.org/pages/api+recommended+practices)
   (per second, minute, and day)
-* Added new module `pyinaturalist.response_utils` for optional formatting functions
 * Moved `Dockerfile` and `docker-compose.yml` to a separate repo
   ([inaturalist-notebook](https://github.com/reclosedev/requests-cache/issues/new/choose))
   and published on Docker Hub
-* Add some optional response formatting functions to `pyinaturalist.formatters`
 
 -----
 ### 0.12.1 (2021-03-07)
diff --git a/README.md b/README.md
index 90e2904..7c51a56 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@ Many of the most relevant API endpoints are included:
 * **Searching for:**
     * controlled terms
     * identifications
-    * observations
+    * observations (multiple formats)
     * observation fields
     * observation species counts
     * places
@@ -45,6 +45,7 @@ Many of the most relevant API endpoints are included:
 * **Text search autocompletion for:**
     * places
     * species
+    * users
 * **Creating and updating:**
     * observations
     * observation fields
diff --git a/docs/general_usage.rst b/docs/general_usage.rst
index efd5b9b..07567b6 100644
--- a/docs/general_usage.rst
+++ b/docs/general_usage.rst
@@ -2,6 +2,24 @@ General Usage
 =============
 This page contains usage information that applies to most or all pyinaturalist functions.
 
+Installation
+------------
+
+Install the latest stable version with pip::
+
+    pip install pyinaturalist
+
+Or with Conda, if you prefer::
+
+    conda install -c conda-forge pyinaturalist
+
+If you would like to use the latest development (pre-release) version::
+
+    pip install --pre pyinaturalist
+
+See :ref:`contributing` for details on setup for local development.
+
+
 Pagination
 ----------
 Most endpoints support pagination, using the parameters:
diff --git a/docs/user_guide.rst b/docs/user_guide.rst
deleted file mode 100644
index 744f699..0000000
--- a/docs/user_guide.rst
+++ /dev/null
@@ -1,20 +0,0 @@
-User Guide
-==========
-
-Installation
-------------
-
-Install the latest stable version with pip::
-
-    pip install pyinaturalist
-
-Or with Conda, if you prefer::
-
-    conda install -c conda-forge pyinaturalist
-
-If you would like to use the latest development (pre-release) version::
-
-    pip install --pre pyinaturalist
-
-See :ref:`contributing` for details on setup for local development.
-
diff --git a/examples/observations_to_gpx.py b/examples/observations_to_gpx.py
index 23b4817..89f733a 100644
--- a/examples/observations_to_gpx.py
+++ b/examples/observations_to_gpx.py
@@ -10,9 +10,9 @@ from logging import getLogger
 from gpxpy.gpx import GPX, GPXTrack, GPXTrackPoint, GPXTrackSegment, GPXWaypoint
 
 from pyinaturalist.constants import JsonResponse, List
+from pyinaturalist.formatters import format_observations
 from pyinaturalist.node_api import get_all_observations, get_observations  # noqa
 from pyinaturalist.response_format import convert_observation_timestamps
-from pyinaturalist.response_utils import format_observation
 
 logger = getLogger(__name__)
 
@@ -70,7 +70,7 @@ def observation_to_gpx_point(observation: JsonResponse, track: bool = True):
         latitude=lat,
         longitude=long,
         time=convert_observation_timestamps(observation),
-        comment=format_observation(observation),
+        comment=format_observations(observation),
     )
     point.description = observation["description"]
     point.link = link
diff --git a/pyinaturalist/formatters.py b/pyinaturalist/formatters.py
index 824c8bb..f1bbb44 100644
--- a/pyinaturalist/formatters.py
+++ b/pyinaturalist/formatters.py
@@ -5,44 +5,97 @@ These functions will accept any of the following:
 * A JSON response
 * A list of response objects
 * A single response object
+
+They will also accept the option ``align=True`` to align values where possible.
 """
+# TODO: Use tabulate library for aligning values?
 from copy import deepcopy
 from logging import getLogger
-from typing import List, Sequence
+from typing import Any, Callable, List, Sequence
 
 from pyinaturalist.constants import ResponseObject, ResponseOrObject
 
-__all__ = ['format_observations', 'format_species_counts', 'format_taxa', 'simplify_observations']
+__all__ = [
+    'format_controlled_terms',
+    'format_identifications',
+    'format_observations',
+    'format_places',
+    'format_projects',
+    'format_species_counts',
+    'format_taxa',
+    'format_users',
+    'simplify_observations',
+]
 logger = getLogger(__name__)
 
 
+def format_controlled_terms(terms: ResponseOrObject, align: bool = False) -> str:
+    """Format controlled term results into a condensed list of terms and values"""
+    return _format_objects(terms, align, _format_controlled_term)
+
+
+def _format_controlled_term(term: ResponseObject, **kwargs) -> str:
+    term_values = [f'    {value["id"]}: {value["label"]}' for value in term['values']]
+    return f'{term["id"]}: {term["label"]}\n' + '\n'.join(term_values)
+
+
+def format_identifications(identifications: ResponseOrObject, align: bool = False) -> str:
+    """Format identification results into a condensed summary: id, what, when, and who"""
+    return _format_objects(identifications, align, _format_identification)
+
+
+def _format_identification(ident: ResponseObject, align: bool = False) -> str:
+    ident_id = pad(ident['id'], 8, align)
+    taxon = _format_taxon(ident['taxon'], align=align)
+    category = pad(ident['category'], 10, align)
+    return f"[{ident_id}] {taxon} ({category}) added on {ident['created_at']} by {ident['user']['login']}"
+
+
 def format_observations(observations: ResponseOrObject, align: bool = False) -> str:
-    """Format observation results into a condensed summary: id, what, who, when, and where"""
-    obs_strings = [_format_observation(o, align=align) for o in _ensure_list(observations)]
-    return '\n'.join(obs_strings)
-
-
-def _format_observation(observation: ResponseObject, align: bool = False) -> str:
-    taxon_str = _format_taxon(observation.get('taxon') or {}, align=align)
-    location = observation.get('place_guess') or observation.get('location')
-    if align:
-        return (
-            f"[{observation['id']:>8}] {taxon_str}"
-            f"\n    observed by {observation['user']['login']} "
-            f"on {observation['observed_on']} at {location}"
-        )
-    else:
-        return (
-            f"[{observation['id']}] {taxon_str} "
-            f"observed by {observation['user']['login']} "
-            f"on {observation['observed_on']} at {location}"
-        )
+    """Format observation results into a condensed summary: id, what, when, who, and where"""
+    return _format_objects(observations, align, _format_observation)
+
+
+def _format_observation(obs: ResponseObject, align: bool = False) -> str:
+    taxon_str = _format_taxon(obs.get('taxon') or {}, align=align)
+    location = obs.get('place_guess') or obs.get('location')
+    obs_id = f"{obs['id']:>8}" if align else f"{obs['id']}"
+    separator = '\n    ' if align else ' '
+
+    return (
+        f"[{obs_id}] {taxon_str}{separator}"
+        f"observed on {obs['observed_on']} by {obs['user']['login']} at {location}"
+    )
+
+
+def format_places(places: ResponseOrObject, align: bool = False) -> str:
+    """Format place results into a condensed list of IDs and names"""
+    return _format_objects(places, align, _format_place)
+
+
+def _format_place(place: ResponseObject, align: bool = False) -> str:
+    if 'standard' in place and 'community' in place:
+        standard_places = format_places(place['standard'], align)
+        community_places = format_places(place['community'], align)
+        return f'Standard:\n{standard_places}\n\nCommunity:\n{community_places}'
+
+    place_id = pad(place['id'], 8, align)
+    return f"[{place_id}] {place['name']}"
+
+
+def format_projects(projects: ResponseOrObject, align: bool = False) -> str:
+    """Format project results into a condensed list of IDs and titles"""
+    return _format_objects(projects, align, _format_project)
+
+
+def _format_project(project: ResponseObject, align: bool = False) -> str:
+    project_id = pad(project['id'], 8, align)
+    return f"[{project_id}] {project['title']}"
 
 
 def format_species_counts(species_counts: ResponseOrObject, align: bool = False) -> str:
-    """Format observation species counts"""
-    count_strings = [_format_species_count(t, align=align) for t in _ensure_list(species_counts)]
-    return '\n'.join(count_strings)
+    """Format observation species counts into a condensed list of names and # of observations"""
+    return _format_objects(species_counts, align, _format_species_count)
 
 
 def _format_species_count(species_count: ResponseObject, align: bool = False) -> str:
@@ -54,28 +107,49 @@ def format_taxa(taxa: ResponseOrObject, align: bool = False) -> str:
     """Format taxon results into a single string containing taxon ID, rank, and name
     (including common name, if available).
     """
-    taxon_strings = [_format_taxon(t, align=align) for t in _ensure_list(taxa)]
-    return '\n'.join(taxon_strings)
+    return _format_objects(taxa, align, _format_taxon)
 
 
 def _format_taxon(taxon: ResponseObject, align: bool = False) -> str:
     if not taxon:
         return 'unknown taxon'
+    if 'name' not in taxon:
+        return _format_taxon_rank_id(taxon, align=align)
+
+    taxon_id = pad(taxon["id"], 8, align)
+    rank = pad(taxon['rank'].title(), 12, align)
     common_name = taxon.get('preferred_common_name')
     name = f"{taxon['name']}" + (f' ({common_name})' if common_name else '')
-    rank = taxon['rank'].title()
 
-    # Visually align taxon IDs (< 7 chars) and ranks (< 11 chars)
-    if align:
-        return f"[{taxon['id']:>8}] {rank:>12} {name:>55}"
-    else:
-        return f"[{taxon['id']}] {rank}: {name}"
+    return f'[{taxon_id}] {rank}: {name}'
+
+
+def _format_taxon_rank_id(taxon: ResponseObject, align: bool = False) -> str:
+    """Format a taxon that only has a rank and ID"""
+    rank = pad(taxon['rank'].title(), 12, align)
+    taxon_id = pad(taxon['id'], 8, align)
+    return f'{rank}: {taxon_id}'
+
+
+def format_users(users: ResponseOrObject, align: bool = False) -> str:
+    """Format user results into a condensed list of IDs, usernames, and real names"""
+    return _format_objects(users, align, _format_user)
+
+
+def _format_user(user: ResponseObject, align: bool = False) -> str:
+    # Response object may contain a nested 'user' object
+    if 'user' in user:
+        user = user['user']
+
+    user_id = pad(user['id'], 8, align)
+    real_name = f" ({user['name']})" if user.get('name') else ''
+    return f"[{user_id}] {user['login']}{real_name}"
 
 
 def simplify_observations(
     observations: ResponseOrObject, align: bool = False
 ) -> List[ResponseObject]:
-    """Flatten out some nested data structures within observation recorda:
+    """Flatten out some nested data structures within observation records:
 
     * annotations
     * comments
@@ -105,8 +179,18 @@ def _simplify_observation(obs):
 
 def _ensure_list(obj: ResponseOrObject) -> List:
     if isinstance(obj, dict) and 'results' in obj:
-        return obj['results']
-    elif isinstance(obj, Sequence):
+        obj = obj['results']
+    if isinstance(obj, Sequence):
         return list(obj)
     else:
         return [obj]
+
+
+def _format_objects(obj: ResponseOrObject, align: bool, format_func: Callable):
+    """Generic function to format a response, object, or list of objects"""
+    obj_strings = [format_func(t, align=align) for t in _ensure_list(obj)]
+    return '\n'.join(obj_strings)
+
+
+def pad(value: Any, width: int, align: bool):
+    return str(value).rjust(width) if align else value
diff --git a/pyinaturalist/node_api.py b/pyinaturalist/node_api.py
index 4a2c571..e9e19c3 100644
--- a/pyinaturalist/node_api.py
+++ b/pyinaturalist/node_api.py
@@ -10,6 +10,7 @@ Functions
 .. automodsumm:: pyinaturalist.node_api
     :functions-only:
     :nosignatures:
+    :skip: get_all_observations
 
 """
 from logging import getLogger
@@ -71,32 +72,12 @@ def get_controlled_terms(taxon_id: int = None, user_agent: str = None) -> JsonRe
     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))
+        >>> print(format_controlled_terms(response))
         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
@@ -171,11 +152,14 @@ def get_identifications(**params) -> JsonResponse:
 
     Example:
 
-        >>> # Get all of your own species-level identifications
+        Get all of your own species-level identifications:
+
         >>> response = get_identifications(user_login='my_username', rank='species')
         >>> print([f"{i['user']['login']}: {i['taxon_id']} ({i['category']})" for i in response['results']])
-        my_username: 60132 (supporting)
-        my_username: 47126 (improving)
+        [155043569] Species: 76465 (leading) added on 2021-02-15 10:46:27-06:00 by jkcook
+        [153668189] Species: 76465 (supporting) added on 2021-02-06 17:43:37+00:00 by jkcook
+        [147500725] Species: 1163860 (improving) added on 2020-12-24 23:52:30+00:00 by jkcook
+        ...
 
         .. admonition:: Example Response
             :class: toggle
@@ -205,7 +189,9 @@ def get_observation(observation_id: int, user_agent: str = None) -> JsonResponse
 
     Example:
 
-        >>> get_observation(16227955)
+        >>> response = get_observation(16227955)
+        >>> print(format_observations(response))
+        [16227955] [493595] Species: Lixus bardanae observed on 2018-09-05 14:06:00+01:00 by niconoe at 54 rue des Badauds
 
         .. admonition:: Example Response
             :class: toggle
@@ -309,7 +295,7 @@ def get_observations(**params) -> JsonResponse:
         Get basic info for observations in response:
 
         >>> from pyinaturalist.formatters import format_observations
-        >>> print(format_observations(response['results']))
+        >>> print(format_observations(response))
         '[57754375] Species: Danaus plexippus (Monarch) observed by samroom on 2020-08-27 at Railway Ave, Wilcox, SK'
         '[57707611] Species: Danaus plexippus (Monarch) observed by ingridt3 on 2020-08-26 at Michener Dr, Regina, SK'
 
@@ -332,10 +318,9 @@ def get_observations(**params) -> JsonResponse:
     return observations
 
 
-@document_request_params([*docs._get_observations, docs._only_id])
 def get_all_observations(**params) -> List[JsonResponse]:
-    """[Deprecated] Like :py:func:`get_observations()`, but gets all pages of results"""
-    msg = "get_all_observations() is deprecated; please Use get_observations(page='all') instead"
+    """Deprecated; use ``get_observations(page='all')`` instead"""
+    msg = "get_all_observations() is deprecated; please use get_observations(page='all') instead"
     warn(DeprecationWarning(msg))
     return paginate_all(get_observations, method='id', **params)['results']
 
@@ -350,7 +335,12 @@ def get_observation_species_counts(**params) -> JsonResponse:
     **API reference:** https://api.inaturalist.org/v1/docs/#!/Observations/get_observations_species_counts
 
     Example:
-        >>> get_observation_species_counts(user_login='my_username', quality_grade='research')
+        >>> response = get_observation_species_counts(user_login='my_username', quality_grade='research')
+        >>> print(format_species_counts(response))
+        [62060] Species: Palomena prasina (Green Shield Bug): 10
+        [84804] Species: Graphosoma italicum (European Striped Shield Bug): 8
+        [55727] Species: Cymbalaria muralis (Ivy-leaved toadflax): 3
+        ...
 
         .. admonition:: Example Response
             :class: toggle
@@ -403,16 +393,17 @@ def get_observation_observers(**params) -> JsonResponse:
     Notes:
         * Options for ``order_by`` are 'observation_count' (default) or 'species_count'
         * This endpoint will only return up to 500 results
-
-    ``GET /observations/observers`` API node is buggy. It's currently limited to
-    500 results and using ``order_by=species_count`` may produce unusual results.
-    See this issue for more details: https://github.com/inaturalist/iNaturalistAPI/issues/235
+        * See this issue for more details: https://github.com/inaturalist/iNaturalistAPI/issues/235
 
     **API reference:** https://api.inaturalist.org/v1/docs/#!/Observations/get_observations_observers
 
-
     Example:
-        >>> get_observation_observers(place_id=72645, order_by='species_count')
+        >>> response = get_observation_observers(place_id=72645, order_by='species_count')
+        >>> print(format_users(response, align=True))
+        [ 1566366] fossa1211
+        [  674557] schurchin
+        [    5813] fluffberger (Fluff Berger)
+
 
         .. admonition:: Example Response
             :class: toggle
@@ -443,7 +434,11 @@ def get_observation_identifiers(**params) -> JsonResponse:
     Note: This endpoint will only return up to 500 results.
 
     Example:
-        >>> get_observation_identifiers(place_id=72645)
+        >>> response = get_observation_identifiers(place_id=72645)
+        >>> print(format_users(response, align=True))
+        [  409010] jdoe42 (Jane Doe)
+        [  691216] jbrown252 (James Brown)
+        [ 3959037] tnsparkleberry
 
         .. admonition:: Example Response
             :class: toggle
@@ -476,11 +471,9 @@ def get_places_by_id(place_id: MultiInt, user_agent: str = None) -> JsonResponse
 
     Example:
         >>> response = get_places_by_id([67591, 89191])
-        >>> print({p['id']: p['name'] for p in  response['results']})
-        {
-            67591: 'Riversdale Beach',
-            89191: 'Conservation Area Riversdale',
-        }
+        >>> print(format_places(response))
+        [89191] Conservation Area Riversdale
+        [67591] Riversdale Beach
 
         .. admonition:: Example Response
             :class: toggle
@@ -505,8 +498,7 @@ def get_places_by_id(place_id: MultiInt, user_agent: str = None) -> JsonResponse
 @document_request_params([docs._bounding_box, docs._name])
 def get_places_nearby(**params) -> JsonResponse:
     """
-    Given an bounding box, and an optional name query, return standard iNaturalist curator approved
-    and community non-curated places nearby
+    Given an bounding box, and an optional name query, return places nearby
 
     **API reference:** https://api.inaturalist.org/v1/docs/#!/Places/get_places_nearby
 
@@ -514,37 +506,26 @@ def get_places_nearby(**params) -> JsonResponse:
         >>> bounding_box = (150.0, -50.0, -149.999, -49.999)
         >>> response = get_places_nearby(*bounding_box)
 
-        Show basic info for standard (curated) places in response:
+        Response is split into standard (curated) places and community (non-curated) places:
 
-        >>> print({p['id']: p['name'] for p in  response['results']['standard']})
-        {
-            97394: 'North America',
-            97395: 'Asia',
-            97393: 'Oceania',
-            97392: 'Africa',
-            97391: 'Europe',
-            97389: 'South America',
-            7161:  'Russia',
-            1:     'United States',
-            6712:  'Canada',
-            10316: 'United States Minor Outlying Islands',
-        }
+        >>> print(len(response['results']['standard']))
+        10
+        >>> print(len(response['results']['community']))
+        10
 
-        Show basic info for community-contributed places in response:
+        Show basic info for all places in response:
 
-        >>> print({p['id']: p['name'] for p in  response['results']['community']})
-        {
-            11770:  'Mehedinti',
-            119755: 'Mahurangi College',
-            150981: 'Ceap Breatainn',
-            136758: 'Willapa Hills (US EPA Level IV Ecoregion)',
-            119694: 'Tetlin National Wildlife Refuge',
-            70630:  'Murray - Sunset',
-            141915: 'Fundy Pollinator Trail',
-            72346:  'Sucunduri',
-            50505:  'Great Salt Lake',
-            72230:  'Rio Novo',
-         }
+        >>> print(format_places(response, align=True))
+        Standard:
+        [   97394] North America
+        [   97395] Asia
+        [   97393] Oceania
+        ...
+        Community:
+        [  166719] Burgenland (accurate border)
+        [   11770] Mehedinti
+        [  119755] Mahurangi College
+        ...
 
         .. admonition:: Example Response
             :class: toggle
@@ -572,13 +553,11 @@ def get_places_autocomplete(q: str = None, **params) -> JsonResponse:
 
     Example:
         >>> response = get_places_autocomplete('Irkutsk')
-        >>> print({p['id']: p['name'] for p in  response['results']})
-        {
-            11803:  'Irkutsk',
-            41853:  'Irkutsk',
-            41854:  'Irkutskiy rayon',
-            163077: 'Irkutsk agglomeration',
-        }
+        >>> print(format_places(response))
+        [   11803] Irkutsk
+        [   41854] Irkutskiy rayon
+        [  166186] Irkutsk Oblast - ADD
+        [  163077] Irkutsk agglomeration
 
         .. admonition:: Example Response
             :class: toggle
@@ -625,14 +604,12 @@ def get_projects(**params) -> JsonResponse:
 
         Show basic info for projects in response:
 
-        >>> print({p['id']: p['title'] for p in response['results']})
-        {
-            8291:  'PNW Invasive Plant EDDR',
-            19200: 'King County (WA) Noxious and Invasive Weeds',
-            8527:  'WA Invasives',
-            2344:  'Invasive & Huntable Animals',
-            6432:  'CBWN Invasive Plants',
-        }
+        >>> print(format_projects(response, align=True))
+        [    8291] PNW Invasive Plant EDDR
+        [   19200] King County (WA) Noxious and Invasive Weeds
+        [  102925] Keechelus/Kachess Invasive Plants
+        ...
+
 
         .. admonition:: Example Response
             :class: toggle
@@ -661,7 +638,10 @@ def get_projects_by_id(
 
     Example:
 
-        >>> get_projects_by_id([8348, 6432])
+        >>> response = get_projects_by_id([8348, 6432])
+        >>> print(format_projects(response))
+        [8348] Tucson High Native and Invasive Species Inventory
+        [6432] CBWN Invasive Plants
 
         .. admonition:: Example Response
             :class: toggle
@@ -704,8 +684,11 @@ def get_taxa(**params) -> JsonResponse:
     Example:
 
         >>> response = get_taxa(q='vespi', rank=['genus', 'family'])
-        >>> print({taxon['id']: taxon['name'] for taxon in response['results']})
-        {52747: 'Vespidae', 84737: 'Vespina', 92786: 'Vespicula', 646195: 'Vespiodes', ...}
+        >>> print(format_taxa(response))
+        [52747] Family: Vespidae (Hornets, Paper Wasps, Potter Wasps, and Allies)
+        [92786] Genus: Vespicula
+        [646195] Genus: Vespiodes
+        ...
 
         .. admonition:: Example Response
             :class: toggle
@@ -780,21 +763,20 @@ def get_taxa_autocomplete(**params) -> JsonResponse:
 
         Get basic info for taxa in response:
 
-        >>> from pyinaturalist.formatters import format_taxa
-        >>> print(format_taxa(response['results'], align=True))
-        >>> # See example output below
+        >>> print(format_taxa(response, align=True))
+        [   52747]       Family: Vespidae (Hornets, Paper Wasps, Potter Wasps, and Allies)
+        [   84738]    Subfamily: Vespinae (Hornets and Yellowjackets)
+        [  131878]      Species: Nicrophorus vespillo (Vespillo Burying Beetle)
 
         If you get unexpected matches, the search likely matched a synonym, either in the form of a
         common name or an alternative classification. Check the ``matched_term`` property for more
         info. For example:
 
-        ```python
         >>> first_result = get_taxa_autocomplete(q='zygoca')['results'][0]
         >>> first_result["name"]
         "Schlumbergera truncata"  # This doesn't look like our search term!
         >>> first_result["matched_term"]
         "Zygocactus truncatus"    # ...Because it matched an older synonym for Schlumbergera
-        ```
 
         .. admonition:: Example Response
             :class: toggle
@@ -830,6 +812,8 @@ def get_user_by_id(user_id: int, user_agent: str = None) -> JsonResponse:
     Example:
 
         >>> response = get_user_by_id(123456)
+        >>> print(format_users(response))
+        [1234] my_username
 
         .. admonition:: Example Response
             :class: toggle
@@ -858,6 +842,9 @@ def get_users_autocomplete(q: str, **params) -> JsonResponse:
     Example:
 
         >>> response = get_taxa_autocomplete(q='my_userna')
+        >>> print(format_users(response))
+        [1234] my_username
+        [12345] my_username_2
 
         .. admonition:: Example Response
             :class: toggle
diff --git a/pyinaturalist/rest_api.py b/pyinaturalist/rest_api.py
index eb8b201..1929cbd 100644
--- a/pyinaturalist/rest_api.py
+++ b/pyinaturalist/rest_api.py
@@ -144,10 +144,9 @@ def get_observation_fields(**params) -> JsonResponse:
     return {'results': obs_fields}
 
 
-@document_request_params([docs._search_query])
 def get_all_observation_fields(**params) -> ListResponse:
-    """[Deprecated] Like :py:func:`.get_observation_fields()`, but gets all pages of results"""
-    msg = "get_all_observation_fields() is deprecated; please Use get_observation_fields(page='all') instead"
+    """Deprecated; use ``get_observation_fields(page='all')`` instead"""
+    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']
 


Test patch:
diff --git a/test/test_formatters.py b/test/test_formatters.py
index 94178f7..e84a575 100644
--- a/test/test_formatters.py
+++ b/test/test_formatters.py
@@ -1,19 +1,37 @@
 import pytest
 
 from pyinaturalist.formatters import (
+    format_controlled_terms,
+    format_identifications,
     format_observations,
+    format_places,
+    format_projects,
     format_species_counts,
     format_taxa,
+    format_users,
     simplify_observations,
 )
 from test.conftest import load_sample_data
 
+controlled_term_1 = load_sample_data('get_controlled_terms.json')['results'][0]
+controlled_term_2 = load_sample_data('get_controlled_terms.json')['results'][1]
+identification_1 = load_sample_data('get_identifications.json')['results'][0]
+identification_2 = load_sample_data('get_identifications.json')['results'][1]
 observation_1 = load_sample_data('get_observation.json')['results'][0]
 observation_2 = load_sample_data('get_observations_node_page1.json')['results'][0]
+place_1 = load_sample_data('get_places_by_id.json')['results'][1]
+place_2 = load_sample_data('get_places_autocomplete.json')['results'][0]
+places_nearby = load_sample_data('get_places_nearby.json')['results']
+places_nearby['standard'] = places_nearby['standard'][:3]
+places_nearby['community'] = places_nearby['community'][:3]
+project_1 = load_sample_data('get_projects.json')['results'][0]
+project_2 = load_sample_data('get_projects.json')['results'][1]
 species_count_1 = load_sample_data('get_observation_species_counts.json')['results'][0]
 species_count_2 = load_sample_data('get_observation_species_counts.json')['results'][1]
 taxon_1 = load_sample_data('get_taxa.json')['results'][0]
 taxon_2 = load_sample_data('get_taxa.json')['results'][2]
+user_1 = load_sample_data('get_user_by_id.json')['results'][0]
+user_2 = load_sample_data('get_users_autocomplete.json')['results'][0]
 
 
 def get_variations(response_object):
@@ -21,24 +39,107 @@ def get_variations(response_object):
     return [{'results': [response_object]}, [response_object], response_object]
 
 
+controlled_term_str_1 = """
+12: Plant Phenology
+    21: No Evidence of Flowering
+    13: Flowering
+    14: Fruiting
+    15: Flower Budding
+""".strip()
+controlled_term_str_2 = """
+9: Sex
+    10: Female
+    11: Male
+    20: Cannot Be Determined
+""".strip()
+
+
+@pytest.mark.parametrize('input', get_variations(controlled_term_1))
+def test_format_controlled_terms(input):
+    assert format_controlled_terms(input) == controlled_term_str_1
+
+
+def test_format_controlled_terms__align():
+    expected_str = '\n'.join([controlled_term_str_1, controlled_term_str_2])
+    terms = [controlled_term_1, controlled_term_2]
+    assert format_controlled_terms(terms, align=True) == expected_str
+
+
+@pytest.mark.parametrize('input', get_variations(identification_1))
+def test_format_identifications(input):
+    expected_str = (
+        '[155554373] Species: 60132 (supporting) added on 2021-02-18T20:31:32-06:00 by jkcook'
+    )
+    assert format_identifications(input) == expected_str
+
+
+def test_format_identifications__align():
+    expected_str = (
+        '[155554373]      Species:    60132 (supporting) added on 2021-02-18T20:31:32-06:00 by jkcook\n'
+        '[155554077]      Species:    61340 (supporting) added on 2021-02-18T20:29:06-06:00 by jkcook'
+    )
+    assert format_identifications([identification_1, identification_2], align=True) == expected_str
+
+
 @pytest.mark.parametrize('input', get_variations(observation_1))
 def test_format_observation(input):
-    assert format_observations(input) == (
+    expected_str = (
         '[16227955] [493595] Species: Lixus bardanae '
-        'observed by niconoe on 2018-09-05 at 54 rue des Badauds'
+        'observed on 2018-09-05 by niconoe at 54 rue des Badauds'
     )
+    assert format_observations(input) == expected_str
 
 
 def test_format_observation__align():
     expected_str = (
-        '[16227955] [  493595]      Species                                          Lixus bardanae\n'
-        '    observed by niconoe on 2018-09-05 at 54 rue des Badauds\n'
-        '[57754375] [   48662]      Species                              Danaus plexippus (Monarch)\n'
-        '    observed by samroom on 2020-08-27 at Railway Ave, Wilcox, SK, CA'
+        '[16227955] [  493595]      Species: Lixus bardanae\n'
+        '    observed on 2018-09-05 by niconoe at 54 rue des Badauds\n'
+        '[57754375] [   48662]      Species: Danaus plexippus (Monarch)\n'
+        '    observed on 2020-08-27 by samroom at Railway Ave, Wilcox, SK, CA'
     )
     assert format_observations([observation_1, observation_2], align=True) == expected_str
 
 
+@pytest.mark.parametrize('input', get_variations(project_1))
+def test_format_projects(input):
+    expected_str = '[8291] PNW Invasive Plant EDDR'
+    assert format_projects(input) == expected_str
+
+
+def test_format_projects__align():
+    expected_str = (
+        '[    8291] PNW Invasive Plant EDDR\n'
+        '[   19200] King County (WA) Noxious and Invasive Weeds'
+    )
+    assert format_projects([project_1, project_2], align=True) == expected_str
+
+
+@pytest.mark.parametrize('input', get_variations(place_1))
+def test_format_places(input):
+    expected_str = '[89191] Conservation Area Riversdale'
+    assert format_places(input) == expected_str
+
+
+def test_format_places__align():
+    expected_str = '[   89191] Conservation Area Riversdale\n[   93735] Springbok'
+    assert format_places([place_1, place_2], align=True) == expected_str
+
+
+def test_format_places__nearby():
+    places_str = """
+Standard:
+[   97394] North America
+[   97395] Asia
+[   97393] Oceania
+
+Community:
+[   11770] Mehedinti
+[  119755] Mahurangi College
+[  150981] Ceap Breatainn
+""".strip()
+    assert format_places(places_nearby, align=True) == places_str
+
+
 @pytest.mark.parametrize('input', get_variations(species_count_1))
 def test_format_species_counts(input):
     expected_str = '[48484] Species: Harmonia axyridis (Asian Lady Beetle): 31'
@@ -47,8 +148,8 @@ def test_format_species_counts(input):
 
 def test_format_species_counts__align():
     expected_str = (
-        '[   48484]      Species                   Harmonia axyridis (Asian Lady Beetle): 31\n'
-        '[   51702]      Species   Coccinella septempunctata (Seven-spotted Lady Beetle): 19'
+        '[   48484]      Species: Harmonia axyridis (Asian Lady Beetle): 31\n'
+        '[   51702]      Species: Coccinella septempunctata (Seven-spotted Lady Beetle): 19'
     )
     assert format_species_counts([species_count_1, species_count_2], align=True) == expected_str
 
@@ -66,11 +167,9 @@ def test_format_taxon__without_common_name(input):
 
 def test_format_taxa__align():
     expected_str = (
-        '[   70118]      Species Nicrophorus vespilloides (Lesser Vespillo Burying Beetle)\n'
-        '[  124162]      Species                                   Temnostoma vespiforme'
+        '[   70118]      Species: Nicrophorus vespilloides (Lesser Vespillo Burying Beetle)\n'
+        '[  124162]      Species: Temnostoma vespiforme'
     )
-    x = format_taxa([taxon_1, taxon_2], align=True)
-    print(x)
     assert format_taxa([taxon_1, taxon_2], align=True) == expected_str
 
 
@@ -78,6 +177,17 @@ def test_format_taxon__invalid():
     assert format_taxa(None) == 'unknown taxon'
 
 
+@pytest.mark.parametrize('input', get_variations(user_2))
+def test_format_users(input):
+    expected_str = '[886482] niconoe (Nicolas Noé)'
+    assert format_users(input) == expected_str
+
+
+def test_format_users__align():
+    expected_str = '[       1] kueda (Ken-ichi Ueda)\n[  886482] niconoe (Nicolas Noé)'
+    assert format_users([user_1, user_2], align=True) == expected_str
+
+
 def test_simplify_observation():
     simplified_obs = simplify_observations(observation_1)
     # Not much worth testing here, just make sure it returns something that can be formatted