Repository: niconoe/pyinaturalist
Pull request #388 (2022-05-26 02:03:56)

Refactor
* Simplify datetime conversion
* Skip parsing timezone name since we already have offset
* Use offset from `time_observed_at` instead of `time_zone_offset` (fixes a but in which these two could be different)

Refactor/simplify functions in between controller request functions and final request call:
* Remove `{method}_v1()` functions
* Move `request()` and `prepare_request()` into `ClientSession` methods



Patch:
diff --git a/.gitignore b/.gitignore
index d538682..50172f7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,6 +59,9 @@ docs/modules/
 *.sqlite
 *.db
 
+# Profilers
+profile.json
+
 # Editors
 .~*
 .idea/
diff --git a/pyinaturalist/auth.py b/pyinaturalist/auth.py
index 47b1579..ce09571 100644
--- a/pyinaturalist/auth.py
+++ b/pyinaturalist/auth.py
@@ -6,7 +6,7 @@ from keyring import get_password, set_password
 from keyring.errors import KeyringError
 from requests import Response
 
-from pyinaturalist.constants import API_V0_BASE_URL, JWT_EXPIRATION, KEYRING_KEY
+from pyinaturalist.constants import API_V0, JWT_EXPIRATION, KEYRING_KEY
 from pyinaturalist.exceptions import AuthenticationError
 from pyinaturalist.session import ClientSession, get_local_session
 
@@ -92,7 +92,7 @@ def get_access_token(
             raise AuthenticationError('Not all authentication parameters were provided')
 
     # Get OAuth access token
-    response = session.post(f'{API_V0_BASE_URL}/oauth/token', json=payload)
+    response = session.post(f'{API_V0}/oauth/token', json=payload)
     response.raise_for_status()
     access_token = response.json()['access_token']
 
@@ -147,8 +147,9 @@ def _get_jwt(
     session: ClientSession, access_token: str = '', only_if_cached: bool = False
 ) -> Response:
     return session.get(
-        f'{API_V0_BASE_URL}/users/api_token',
+        f'{API_V0}/users/api_token',
         headers={'Authorization': f'Bearer {access_token}'},
         expire_after=JWT_EXPIRATION,  # type: ignore
         only_if_cached=only_if_cached,  # If True, will return a 504 if not cached
+        raise_for_status=False,
     )
diff --git a/pyinaturalist/client.py b/pyinaturalist/client.py
index b8483be..4d3a0cc 100644
--- a/pyinaturalist/client.py
+++ b/pyinaturalist/client.py
@@ -1,10 +1,13 @@
+# TODO: "optional auth" option
+# TODO: Improve Sphinx docs generated for controller attributes
+from inspect import ismethod
 from logging import getLogger
 from typing import Any, Callable, Dict, Type
 
 from requests import Session
 
 from pyinaturalist.auth import get_access_token
-from pyinaturalist.constants import JsonResponse
+from pyinaturalist.constants import RequestParams
 from pyinaturalist.controllers import (
     ObservationController,
     PlaceController,
@@ -109,48 +112,38 @@ class iNatClient:
         self._token_expires = None
 
         # Controllers
-        # TODO: Improve Sphinx docs generated for these attributes
         self.observations = ObservationController(self)  #: Interface for observation requests
         self.places = PlaceController(self)  #: Interface for project requests
         self.projects = ProjectController(self)  #: Interface for project requests
         self.taxa = TaxonController(self)  #: Interface for taxon requests
         self.users = UserController(self)  #: Interface for user requests
 
-    def _update_kwargs(self, request_function, auth: bool = False, **kwargs):
-        """Apply any applicable client settings to request parameters before sending a request.
+    def add_client_settings(
+        self, request_function, kwargs: RequestParams = None, auth: bool = False
+    ) -> RequestParams:
+        """Add any applicable client settings to request parameters before sending a request.
         Explicit keyword arguments will override any client settings.
         """
-        kwargs = strip_empty_values(kwargs)
-        client_settings = {
-            'dry_run': self.dry_run,
-            'session': self.session,
-        }
+        kwargs = strip_empty_values(kwargs or {})
+        client_kwargs = {'dry_run': self.dry_run, 'session': self.session}
 
         # Add access token and default params, if applicable
         if auth:
-            client_settings['access_token'] = get_access_token(**self.creds)  # type: ignore
-        client_settings.update(get_valid_kwargs(request_function, self.default_params))
-
-        for k, v in client_settings.items():
+            client_kwargs['access_token'] = get_access_token(**self.creds)  # type: ignore
+        client_kwargs.update(get_valid_kwargs(request_function, self.default_params))
+        for k, v in client_kwargs.items():
             kwargs.setdefault(k, v)
 
-        return kwargs
-
-    def request(self, request_function: Callable, auth: bool = False, **kwargs) -> JsonResponse:
-        """Send a request, with client settings applied
+        # If we're directly calling a ClientSession method, we don't need to pass a session object
+        if ismethod(request_function):
+            kwargs.pop('session')
 
-        Args:
-            request_function: The API request function to call, which should return a JSON response
-            auth: Indicates that the request requires authentication
-            params: Original request parameters
-        """
-        kwargs = self._update_kwargs(request_function, auth, **kwargs)
-        return request_function(**kwargs)
+        return kwargs
 
     def paginate(
         self,
         request_function: Callable,
-        model: T,
+        model: Type[T],
         auth: bool = False,
         cls: Type[Paginator] = Paginator,
         **kwargs,
@@ -158,11 +151,25 @@ class iNatClient:
         """Create a paginator for a request, with client settings applied
 
         Args:
-            request_function: The API request function to call, which should return a JSON response
+            request_function: The API request function to call
             model: Model class used for the response
             auth: Indicates that the request requires authentication
             cls: Alternative Paginator class to use
             params: Original request parameters
         """
-        kwargs = self._update_kwargs(request_function, auth, **kwargs)
+        kwargs = self.add_client_settings(request_function, kwargs, auth)
         return cls(request_function, model, **kwargs)
+
+    def request(self, request_function: Callable, *args, auth: bool = False, **kwargs):
+        """Send a request, with client settings applied.
+
+        Args:
+            request_function: The API request function to call
+            auth: Indicates that the request requires authentication
+            params: Original request parameters
+
+        Returns:
+            Results of ``request_function()``
+        """
+        kwargs = self.add_client_settings(request_function, kwargs, auth)
+        return request_function(*args, **kwargs)
diff --git a/pyinaturalist/constants.py b/pyinaturalist/constants.py
index 78ba9b3..61f4118 100644
--- a/pyinaturalist/constants.py
+++ b/pyinaturalist/constants.py
@@ -7,11 +7,11 @@ from dateutil.relativedelta import relativedelta
 from platformdirs import user_cache_dir
 
 # iNaturalist URLs
-API_V0_BASE_URL = 'https://www.inaturalist.org'
-API_V1_BASE_URL = 'https://api.inaturalist.org/v1'
-API_V2_BASE_URL = 'https://api.inaturalist.org/v2'
+API_V0 = 'https://www.inaturalist.org'
+API_V1 = 'https://api.inaturalist.org/v1'
+API_V2 = 'https://api.inaturalist.org/v2'
 EXPORT_URL = 'https://www.inaturalist.org/observations/export'
-INAT_BASE_URL = API_V0_BASE_URL
+INAT_BASE_URL = API_V0
 INAT_REPO = 'https://raw.githubusercontent.com/inaturalist/inaturalist/main'
 ICONIC_TAXA_BASE_URL = f'{INAT_REPO}/app/assets/images/iconic_taxa'
 PHOTO_BASE_URL = 'https://static.inaturalist.org/photos'
diff --git a/pyinaturalist/controllers/__init__.py b/pyinaturalist/controllers/__init__.py
index a59ddd8..c20950b 100644
--- a/pyinaturalist/controllers/__init__.py
+++ b/pyinaturalist/controllers/__init__.py
@@ -1,8 +1,8 @@
 """Controller classes for :py:class:`.iNatClient`"""
 # flake8: noqa: F401
-from pyinaturalist.controllers.base import BaseController
-from pyinaturalist.controllers.observations import ObservationController
-from pyinaturalist.controllers.places import PlaceController
-from pyinaturalist.controllers.projects import ProjectController
-from pyinaturalist.controllers.taxa import TaxonController
-from pyinaturalist.controllers.users import UserController
+from pyinaturalist.controllers.base_controller import BaseController
+from pyinaturalist.controllers.observations_controller import ObservationController
+from pyinaturalist.controllers.place_controller import PlaceController
+from pyinaturalist.controllers.project_controller import ProjectController
+from pyinaturalist.controllers.taxon_controller import TaxonController
+from pyinaturalist.controllers.user_controller import UserController
diff --git a/pyinaturalist/controllers/base.py b/pyinaturalist/controllers/base_controller.py
similarity index 79%
rename from pyinaturalist/controllers/base.py
rename to pyinaturalist/controllers/base_controller.py
index 23c60c8..c642bf5 100644
--- a/pyinaturalist/controllers/base.py
+++ b/pyinaturalist/controllers/base_controller.py
@@ -1,7 +1,11 @@
 from abc import abstractmethod
+from typing import TYPE_CHECKING
 
 from pyinaturalist.paginator import Paginator
 
+if TYPE_CHECKING:
+    from pyinaturalist.client import iNatClient
+
 
 class BaseController:
     """Base class for controllers. A controller provides an interface for all requests related to a
@@ -10,7 +14,7 @@ class BaseController:
     to navigate.
     """
 
-    def __init__(self, client):
+    def __init__(self, client: 'iNatClient'):
         self.client = client
 
     @abstractmethod
diff --git a/pyinaturalist/controllers/observations.py b/pyinaturalist/controllers/observations_controller.py
similarity index 88%
rename from pyinaturalist/controllers/observations.py
rename to pyinaturalist/controllers/observations_controller.py
index c819352..5fb6479 100644
--- a/pyinaturalist/controllers/observations.py
+++ b/pyinaturalist/controllers/observations_controller.py
@@ -1,6 +1,12 @@
 from typing import Optional
 
-from pyinaturalist.constants import HistogramResponse, IntOrStr, ListResponse
+from pyinaturalist.constants import (
+    API_V1,
+    V1_OBS_ORDER_BY_PROPERTIES,
+    HistogramResponse,
+    IntOrStr,
+    ListResponse,
+)
 from pyinaturalist.controllers import BaseController
 from pyinaturalist.docs import document_controller_params
 from pyinaturalist.models import (
@@ -12,6 +18,7 @@ from pyinaturalist.models import (
     UserCounts,
 )
 from pyinaturalist.paginator import Paginator
+from pyinaturalist.request_params import validate_multiple_choice_param
 from pyinaturalist.v1 import (
     create_observation,
     delete_observation,
@@ -45,7 +52,12 @@ class ObservationController(BaseController):
 
     @document_controller_params(get_observations)
     def search(self, **params) -> Paginator[Observation]:
-        return self.client.paginate(get_observations, Observation, method='id', **params)
+        params = validate_multiple_choice_param(params, 'order_by', V1_OBS_ORDER_BY_PROPERTIES)
+        params = self.client.add_client_settings(get_observations, params)
+        # self.client.session.get(f'{API_V1}/observations', **params).json()
+        return Paginator(
+            self.client.session.get, Observation, f'{API_V1}/observations', method='id', **params
+        )
 
     # TODO: Does this need a model with utility functions, or is {datetime: count} sufficient?
     @document_controller_params(get_observation_histogram)
diff --git a/pyinaturalist/controllers/places.py b/pyinaturalist/controllers/place_controller.py
similarity index 100%
rename from pyinaturalist/controllers/places.py
rename to pyinaturalist/controllers/place_controller.py
diff --git a/pyinaturalist/controllers/projects.py b/pyinaturalist/controllers/project_controller.py
similarity index 100%
rename from pyinaturalist/controllers/projects.py
rename to pyinaturalist/controllers/project_controller.py
diff --git a/pyinaturalist/controllers/taxa.py b/pyinaturalist/controllers/taxon_controller.py
similarity index 100%
rename from pyinaturalist/controllers/taxa.py
rename to pyinaturalist/controllers/taxon_controller.py
diff --git a/pyinaturalist/controllers/users.py b/pyinaturalist/controllers/user_controller.py
similarity index 100%
rename from pyinaturalist/controllers/users.py
rename to pyinaturalist/controllers/user_controller.py
diff --git a/pyinaturalist/converters.py b/pyinaturalist/converters.py
index eb84403..8b53b0e 100644
--- a/pyinaturalist/converters.py
+++ b/pyinaturalist/converters.py
@@ -1,6 +1,6 @@
 """Type conversion utilities used for both requests and responses"""
 import re
-from datetime import date, datetime, timedelta
+from datetime import date, datetime
 from io import BytesIO
 from logging import getLogger
 from os.path import abspath, expanduser
@@ -10,7 +10,7 @@ from warnings import catch_warnings, simplefilter
 
 from dateutil.parser import UnknownTimezoneWarning  # type: ignore  # (missing from type stubs)
 from dateutil.parser import parse as parse_date
-from dateutil.tz import tzlocal, tzoffset
+from dateutil.tz import tzlocal
 from requests import Session
 
 from pyinaturalist.constants import (
@@ -164,97 +164,21 @@ def convert_generic_timestamps(result: ResponseResult) -> ResponseResult:
     return result
 
 
-# TODO: Replace this with attrs version
 def convert_observation_timestamps(result: ResponseResult) -> ResponseResult:
     """Replace observation date/time info with datetime objects"""
-    if 'created_at_details' not in result and 'observed_on_string' not in result:
-        return result
-
     observation = result.copy()
-    tz_offset = observation.get('time_zone_offset', '')
-    tz_name = observation.get('observed_time_zone')
-
-    created_datetime = observation.get('created_at')
-    if not isinstance(created_datetime, datetime):
-        created_datetime = try_datetime(observation.get('created_at_details', {}).get('date'))
-        created_datetime = convert_offset(created_datetime, tz_offset, tz_name)
 
-    # Ignore any timezone info in observed_on timestamp; offset field is more reliable
-    observed_datetime = try_datetime(observation.get('observed_on_string', ''), ignoretz=True)
-    observed_datetime = convert_offset(observed_datetime, tz_offset, tz_name)
+    observed_on = observation.pop('time_observed_at', None)
+    if not isinstance(observation.get('observed_on'), datetime) and observed_on:
+        observation['observed_on'] = try_datetime(observed_on)
 
-    # If valid, add the datetime objects and remove all other redundant date/time fields
-    if created_datetime and observed_datetime:
-        for field in OBSERVATION_TIME_FIELDS:
-            observation.pop(field, None)
-    observation['created_at'] = created_datetime
-    observation['observed_on'] = observed_datetime
+    created_at = observation.get('created_at')
+    if not isinstance(created_at, datetime):
+        observation['created_at'] = try_datetime(created_at)
 
     return observation
 
 
-def convert_observation_timestamp(
-    timestamp: str, tz_offset: str = None, tz_name: str = None, ignoretz: bool = False
-) -> Optional[datetime]:
-    """Convert an observation timestamp + timezone info to a datetime. This is needed because
-    observed_on and created_at can be in in inconsistent (user-submitted?) formats.
-    """
-    dt = try_datetime(timestamp, ignoretz=ignoretz)
-    return convert_offset(dt, tz_offset, tz_name)
-
-
-def convert_offset(
-    datetime_obj: Optional[datetime], tz_offset: str = None, tz_name: str = None
-) -> Optional[datetime]:
-    """Use timezone offset info to replace a datetime's tzinfo"""
-    if not datetime_obj or not tz_offset:
-        return datetime_obj
-
-    try:
-        offset = parse_offset(tz_offset, tz_name)
-        return datetime_obj.replace(tzinfo=offset)
-    except (AttributeError, TypeError, ValueError) as e:
-        logger.debug(f'Could not parse offset: {tz_offset}: {str(e)}')
-        return None
-
-
-def parse_offset(tz_offset: str, tz_name: str = None) -> tzoffset:
-    """Convert a timezone offset string to a tzoffset object, accounting for some common variations
-    in format
-
-    Examples:
-
-        >>> parse_offset('GMT-08:00', 'PST')
-        tzoffset('PST', -28800)
-        >>> parse_offset('-06:00')
-        tzoffset(None, -21600)
-        >>> parse_offset('+05:30')
-        tzoffset(None, 19800)
-        >>> parse_offset('0530')
-        tzoffset(None, 19800)
-
-    """
-
-    def remove_prefixes(text, prefixes):
-        for prefix in prefixes:
-            if text.startswith(prefix):
-                text = text[len(prefix) :]  # noqa  # black and flake8 fight over this one
-        return text
-
-    # Parse hours, minutes, and sign from offset string; account for either `hh:mm` or `hhmm` format
-    tz_offset = remove_prefixes(tz_offset, ['GMT', 'UTC']).strip()
-    multiplier = -1 if tz_offset.startswith('-') else 1
-    tz_offset = tz_offset.lstrip('+-')
-    if ':' in tz_offset:
-        hours, minutes = tz_offset.split(':')
-    else:
-        hours, minutes = tz_offset[:2], tz_offset[2:]
-
-    # Convert to a timezone offset in +/- seconds
-    delta = timedelta(hours=int(hours), minutes=int(minutes))
-    return tzoffset(tz_name, delta.total_seconds() * multiplier)
-
-
 def safe_split(value: Any, delimiter: str = '|') -> List[str]:
     """Split a pipe-(or other token)-delimited string"""
     return list(ensure_list(value, convert_csv=True, delimiter=delimiter))
diff --git a/pyinaturalist/models/observation.py b/pyinaturalist/models/observation.py
index df305e3..017f88b 100644
--- a/pyinaturalist/models/observation.py
+++ b/pyinaturalist/models/observation.py
@@ -13,7 +13,6 @@ from pyinaturalist.constants import (
     DateTime,
     TableRow,
 )
-from pyinaturalist.converters import convert_observation_timestamp
 from pyinaturalist.docs import extend_init_signature
 from pyinaturalist.models import (
     Annotation,
@@ -187,41 +186,15 @@ class Observation(BaseModel):
     # time_zone_offset: str = field(default=None)
 
     # Attributes that will only be used during init and then omitted
-    temp_attrs = [
-        'created_at_details',
-        'observed_on_string',
-        'observed_time_zone',
-        'time_zone_offset',
-    ]
+    temp_attrs = ['time_observed_at']
 
     # Convert observation timestamps prior to __attrs_init__
-    def __init__(
-        self,
-        # created_at_details: Dict = None,
-        # observed_on_string: str = None,
-        # observed_time_zone: str = None,
-        # time_zone_offset: str = None,
-        **kwargs,
-    ):
-        created_at_details = kwargs.pop('created_at_details', None)
-        observed_on_string = kwargs.pop('observed_on_string', None)
-        observed_time_zone = kwargs.pop('observed_time_zone', None)
-        time_zone_offset = kwargs.pop('time_zone_offset', None)
-
-        tz_offset = time_zone_offset
-        tz_name = observed_time_zone
-        created_date = (created_at_details or {}).get('date')
-
-        if not isinstance(kwargs.get('created_at'), datetime) and created_date:
-            kwargs['created_at'] = convert_observation_timestamp(created_date, tz_offset, tz_name)
-        if not isinstance(kwargs.get('observed_on'), datetime) and observed_on_string:
-            kwargs['observed_on'] = convert_observation_timestamp(
-                observed_on_string, tz_offset, tz_name, ignoretz=True
-            )
-
+    def __init__(self, **kwargs):
+        observed_on = kwargs.pop('time_observed_at', None)
+        if not isinstance(kwargs['observed_on'], datetime) and observed_on:
+            kwargs['observed_on'] = observed_on
         if not kwargs.get('uri'):
             kwargs['uri'] = f'{INAT_BASE_URL}/observations/{kwargs.get("id", "")}'
-
         self.__attrs_init__(**kwargs)  # type: ignore
 
     @classmethod
diff --git a/pyinaturalist/paginator.py b/pyinaturalist/paginator.py
index 4459fe5..07cf531 100644
--- a/pyinaturalist/paginator.py
+++ b/pyinaturalist/paginator.py
@@ -11,6 +11,7 @@ from typing import (
     Iterator,
     List,
     Optional,
+    Type,
 )
 
 from requests import Response
@@ -54,7 +55,7 @@ class Paginator(Iterable, AsyncIterable, Generic[T]):
     def __init__(
         self,
         request_function: Callable,
-        model: T,
+        model: Type[T],
         *request_args,
         method: str = 'page',
         limit: int = None,
@@ -83,7 +84,9 @@ class Paginator(Iterable, AsyncIterable, Generic[T]):
             self.kwargs['page'] = 1
 
         if request_function:
-            log_kwargs = {k: v for k, v in self.kwargs.items() if k != 'session'}
+            log_kwargs = {
+                k: v for k, v in self.kwargs.items() if k not in ['session', 'access_token']
+            }
             logger.debug(
                 f'Prepared paginated request: {self.request_function.__name__}'
                 f'(args={self.request_args}, kwargs={log_kwargs})'
@@ -125,8 +128,10 @@ class Paginator(Iterable, AsyncIterable, Generic[T]):
         """
         if self.total_results is None:
             kwargs = {**self.kwargs, 'per_page': 0}
-            count_response = self.request_function(*self.request_args, **kwargs)
-            self.total_results = int(count_response['total_results'])
+            response = self.request_function(*self.request_args, **kwargs)
+            if isinstance(response, Response):
+                response = response.json()
+            self.total_results = int(response['total_results'])
         return self.total_results
 
     def next_page(self) -> List[T]:
@@ -228,6 +233,8 @@ class IDPaginator(Paginator):
             return []
 
         response = self.request_function(next_ids, *self.request_args, **self.kwargs)
+        if isinstance(response, Response):
+            response = response.json()
         self.results_fetched += 1
         return response['results'] if 'results' in response else [response]
 
@@ -270,6 +277,14 @@ class AutocompletePaginator(Paginator):
 class JsonPaginator(Paginator):
     """Paginator that returns raw response dicts instead of model objects"""
 
+    def __init__(
+        self,
+        request_function: Callable,
+        *request_args,
+        **kwargs,
+    ):
+        super().__init__(request_function, None, *request_args, **kwargs)  # type: ignore
+
     def __iter__(self) -> Iterator[ResponseResult]:
         """Iterate over paginated results, and skip conversion to model objects"""
         while not self.exhausted:
@@ -319,4 +334,4 @@ def paginate_all(request_function: Callable, *args, method: str = 'page', **kwar
     Returns:
         Response dict containing combined results, in the same format as ``api_func``
     """
-    return JsonPaginator(request_function, None, *args, method=method, **kwargs).all()
+    return JsonPaginator(request_function, *args, method=method, **kwargs).all()
diff --git a/pyinaturalist/session.py b/pyinaturalist/session.py
index 971561e..abedcbf 100644
--- a/pyinaturalist/session.py
+++ b/pyinaturalist/session.py
@@ -5,7 +5,6 @@ from os import getenv
 from typing import Dict, Type
 from unittest.mock import Mock
 
-import forge
 from requests import PreparedRequest, Request, Response, Session
 from requests.adapters import HTTPAdapter
 from requests.utils import default_user_agent
@@ -148,6 +147,108 @@ class ClientSession(CacheMixin, LimiterMixin, Session):
         adapter = HTTPAdapter(max_retries=retry)
         self.mount('https://', adapter)
 
+    def request(  # type: ignore
+        self,
+        method: str,
+        url: str,
+        access_token: str = None,
+        allow_str_ids: bool = False,
+        dry_run: bool = False,
+        expire_after: ExpirationTime = None,
+        files: FileOrPath = None,
+        headers: Dict = None,
+        ids: MultiInt = None,
+        json: Dict = None,
+        raise_for_status: bool = True,
+        refresh: bool = False,
+        timeout: int = None,
+        **params: RequestParams,
+    ) -> Response:
+        """Wrapper around :py:func:`requests.request` with additional options specific to iNat API requests
+
+        Args:
+            method: HTTP method
+            url: Request URL
+            access_token: access_token: the access token, as returned by :func:`get_access_token()`
+            dry_run: Just log the request instead of sending a real request
+            expire_after: How long to keep cached API requests
+            files: File object, path, or URL to upload
+            headers: Request headers
+            ids: One or more integer IDs used as REST resource(s) to request
+            json: JSON request body
+            refresh: Skip reading from the cache and always fetch a fresh response
+            timeout: Time (in seconds) to wait for a response from the server; if exceeded, a
+                :py:exc:`requests.exceptions.Timeout` will be raised.
+            params: All other keyword arguments are interpreted as request parameters
+
+        Returns:
+            API response
+        """
+        request = self.prepare_inat_request(
+            method=method,
+            url=url,
+            access_token=access_token,
+            allow_str_ids=allow_str_ids,
+            files=files,
+            headers=headers,
+            ids=ids,
+            json=json,
+            params=params,
+        )
+        logger.info(format_request(request, dry_run))
+
+        # Make a mock request, if specified
+        if dry_run or is_dry_run_enabled(method):
+            return MOCK_RESPONSE
+
+        # Otherwise, send the request
+        response = self.send(request, timeout=timeout, expire_after=expire_after, refresh=refresh)
+        logger.debug(format_response(response))
+
+        if raise_for_status:
+            response.raise_for_status()
+        return response
+
+    def prepare_inat_request(
+        self,
+        method: str,
+        url: str,
+        access_token: str = None,
+        files: FileOrPath = None,
+        headers: Dict = None,
+        ids: MultiInt = None,
+        json: Dict = None,
+        params: RequestParams = None,
+        allow_str_ids: bool = False,
+        **kwargs,
+    ) -> PreparedRequest:
+        """Translate pyinaturalist-specific options into standard request arguments"""
+        # Prepare request params and URL
+        params = preprocess_request_params(params)
+        url = convert_url_ids(url, ids, allow_str_ids)
+
+        # Set auth header
+        headers = headers or {}
+        if access_token:
+            headers['Authorization'] = f'Bearer {access_token}'
+
+        # Convert any datetimes in request body, and read any files or URLs for uploading
+        json = preprocess_request_body(json)
+        if files:
+            files = {'file': ensure_file_obj(files, self)}  # type: ignore
+
+        # Convert into a PreparedRequest
+        request = Request(
+            method=method,
+            url=url,
+            files=files,
+            headers=headers,
+            json=json,
+            params=params,
+            **kwargs,
+        )
+        return super().prepare_request(request)
+
     def send(  # type: ignore  # Adds kwargs not present in Session.send()
         self,
         request: PreparedRequest,
@@ -177,139 +278,28 @@ class ClientSession(CacheMixin, LimiterMixin, Session):
         )
 
 
-def request(
-    method: str,
-    url: str,
-    access_token: str = None,
-    allow_str_ids: bool = False,
-    dry_run: bool = False,
-    expire_after: ExpirationTime = None,
-    files: FileOrPath = None,
-    headers: Dict = None,
-    ids: MultiInt = None,
-    json: Dict = None,
-    raise_for_status: bool = True,
-    refresh: bool = False,
-    session: Session = None,
-    timeout: int = None,
-    **params: RequestParams,
-) -> Response:
-    """Wrapper around :py:func:`requests.request` with additional options specific to iNat API requests
-
-    Args:
-        method: HTTP method
-        url: Request URL
-        access_token: access_token: the access token, as returned by :func:`get_access_token()`
-        dry_run: Just log the request instead of sending a real request
-        expire_after: How long to keep cached API requests
-        files: File object, path, or URL to upload
-        headers: Request headers
-        ids: One or more integer IDs used as REST resource(s) to request
-        json: JSON request body
-        refresh: Skip reading from the cache and always fetch a fresh response
-        session: An existing Session object to use instead of creating a new one
-        timeout: Time (in seconds) to wait for a response from the server; if exceeded, a
-            :py:exc:`requests.exceptions.Timeout` will be raised.
-        params: All other keyword arguments are interpreted as request parameters
-
-    Returns:
-        API response
-    """
-    session = session or get_local_session()
-    request = prepare_request(
-        session=session,
-        method=method,
-        url=url,
-        access_token=access_token,
-        allow_str_ids=allow_str_ids,
-        files=files,
-        headers=headers,
-        ids=ids,
-        json=json,
-        params=params,
-    )
-    logger.info(format_request(request, dry_run))
-
-    # Make a mock request, if specified
-    if dry_run or is_dry_run_enabled(method):
-        return MOCK_RESPONSE
-
-    # Otherwise, send the request
-    session_kwargs = {'timeout': timeout}
-    if isinstance(session, CacheMixin):
-        session_kwargs['expire_after'] = expire_after
-        session_kwargs['refresh'] = refresh
-    response = session.send(request, **session_kwargs)
-    logger.debug(format_response(response))
-
-    if raise_for_status:
-        response.raise_for_status()
-    return response
-
-
-def prepare_request(
-    session: Session,
-    method: str,
-    url: str,
-    access_token: str = None,
-    files: FileOrPath = None,
-    headers: Dict = None,
-    ids: MultiInt = None,
-    json: Dict = None,
-    params: RequestParams = None,
-    allow_str_ids: bool = False,
-    **kwargs,
-) -> PreparedRequest:
-    """Translate ``pyinaturalist``-specific options into standard request arguments"""
-    # Prepare request params and URL
-    params = preprocess_request_params(params)
-    url = convert_url_ids(url, ids, allow_str_ids)
-
-    # Set auth header
-    headers = headers or {}
-    if access_token:
-        headers['Authorization'] = f'Bearer {access_token}'
-
-    # Convert any datetimes in request body, and read any files or URLs for uploading
-    json = preprocess_request_body(json)
-    if files:
-        files = {'file': ensure_file_obj(files, session)}  # type: ignore
-
-    # Convert into a PreparedRequest
-    request = Request(
-        method=method,
-        url=url,
-        files=files,
-        headers=headers,
-        json=json,
-        params=params,
-        **kwargs,
-    )
-    return session.prepare_request(request)
-
-
-@forge.copy(request, exclude='method')
-def delete(url: str, **kwargs) -> Response:
+def delete(url: str, session: ClientSession = None, **kwargs) -> Response:
     """Wrapper around :py:func:`requests.delete` with additional options specific to iNat API requests"""
-    return request('DELETE', url, **kwargs)
+    session = session or get_local_session()
+    return session.request('DELETE', url, **kwargs)
 
 
-@forge.copy(request, exclude='method')
-def get(url: str, **kwargs) -> Response:
+def get(url: str, session: ClientSession = None, **kwargs) -> Response:
     """Wrapper around :py:func:`requests.get` with additional options specific to iNat API requests"""
-    return request('GET', url, **kwargs)
+    session = session or get_local_session()
+    return session.request('GET', url, **kwargs)
 
 
-@forge.copy(request, exclude='method')
-def post(url: str, **kwargs) -> Response:
+def post(url: str, session: ClientSession = None, **kwargs) -> Response:
     """Wrapper around :py:func:`requests.post` with additional options specific to iNat API requests"""
-    return request('POST', url, **kwargs)
+    session = session or get_local_session()
+    return session.request('POST', url, **kwargs)
 
 
-@forge.copy(request, exclude='method')
-def put(url: str, **kwargs) -> Response:
+def put(url: str, session: ClientSession = None, **kwargs) -> Response:
     """Wrapper around :py:func:`requests.put` with additional options specific to iNat API requests"""
-    return request('PUT', url, **kwargs)
+    session = session or get_local_session()
+    return session.request('PUT', url, **kwargs)
 
 
 def clear_cache():
diff --git a/pyinaturalist/v0/observation_fields.py b/pyinaturalist/v0/observation_fields.py
index 15e2f8f..9ac53f2 100644
--- a/pyinaturalist/v0/observation_fields.py
+++ b/pyinaturalist/v0/observation_fields.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-from pyinaturalist.constants import API_V0_BASE_URL, JsonResponse
+from pyinaturalist.constants import API_V0, JsonResponse
 from pyinaturalist.converters import convert_all_timestamps
 from pyinaturalist.docs import document_request_params
 from pyinaturalist.docs import templates as docs
@@ -35,14 +35,14 @@ def get_observation_fields(**params) -> JsonResponse:
     all_pages = params.pop('page', None)
     params['page'] = 1
     params['per_page'] = 30  # This seems to return 30 results regardless of the per_page param
-    obs_fields = get(f'{API_V0_BASE_URL}/observation_fields.json', **params).json()
+    obs_fields = get(f'{API_V0}/observation_fields.json', **params).json()
 
     # This endpoint returns a list with no pagination metadata, so iterate until there are no more pages
     if all_pages:
         page_results = obs_fields
         while len(page_results) == 30:
             params['page'] += 1
-            page_results = get(f'{API_V0_BASE_URL}/observation_fields.json', **params).json()
+            page_results = get(f'{API_V0}/observation_fields.json', **params).json()
             obs_fields.extend(page_results)
 
     obs_fields = convert_all_timestamps(obs_fields)
@@ -91,7 +91,5 @@ def put_observation_field_values(
             'value': value,
         }
     }
-    response = put(
-        f'{API_V0_BASE_URL}/observation_field_values/{observation_field_id}', json=body, **params
-    )
+    response = put(f'{API_V0}/observation_field_values/{observation_field_id}', json=body, **params)
     return response.json()
diff --git a/pyinaturalist/v0/observations.py b/pyinaturalist/v0/observations.py
index ef74545..87950fd 100644
--- a/pyinaturalist/v0/observations.py
+++ b/pyinaturalist/v0/observations.py
@@ -2,7 +2,7 @@ from logging import getLogger
 from typing import List, Union
 
 from pyinaturalist.constants import (
-    API_V0_BASE_URL,
+    API_V0,
     OBSERVATION_FORMATS,
     V0_OBS_ORDER_BY_PROPERTIES,
     ListResponse,
@@ -78,7 +78,7 @@ def get_observations(**params) -> Union[List, str]:
         raise ValueError('Invalid response format')
     validate_multiple_choice_param(params, 'order_by', V0_OBS_ORDER_BY_PROPERTIES)
 
-    response = get(f'{API_V0_BASE_URL}/observations.{response_format}', **params)
+    response = get(f'{API_V0}/observations.{response_format}', **params)
     if response_format == 'json':
         observations = response.json()
         observations = convert_all_coordinates(observations)
@@ -126,7 +126,7 @@ def create_observation(**params) -> ListResponse:
     """
     photos, sounds, _, params, kwargs = convert_observation_params(params)
     response = post(
-        url=f'{API_V0_BASE_URL}/observations.json',
+        url=f'{API_V0}/observations.json',
         json={'observation': params},
         **kwargs,
     )
@@ -184,7 +184,7 @@ def update_observation(observation_id: int, **params) -> ListResponse:
     """
     photos, sounds, _, params, kwargs = convert_observation_params(params)
     response = put(
-        url=f'{API_V0_BASE_URL}/observations/{observation_id}.json',
+        url=f'{API_V0}/observations/{observation_id}.json',
         json={'observation': params},
         **kwargs,
     )
@@ -231,13 +231,13 @@ def upload_photos(observation_id: int, photos: MultiFile, **params) -> ListRespo
         Information about the uploaded photo(s)
     """
     responses = []
+    params['observation_photo[observation_id]'] = observation_id
 
     for photo in ensure_list(photos):
         response = post(
-            url=f'{API_V0_BASE_URL}/observation_photos',
+            url=f'{API_V0}/observation_photos',
             files=photo,
             raise_for_status=False,
-            **{'observation_photo[observation_id]': observation_id},
             **params,
         )
         responses.append(response)
@@ -282,13 +282,13 @@ def upload_sounds(observation_id: int, sounds: MultiFile, **params) -> ListRespo
         Information about the uploaded sound(s)
     """
     responses = []
+    params['observation_sound[observation_id]'] = observation_id
 
     for sound in ensure_list(sounds):
         response = post(
-            url=f'{API_V0_BASE_URL}/observation_sounds',
+            url=f'{API_V0}/observation_sounds',
             files=sound,
             raise_for_status=False,
-            **{'observation_sound[observation_id]': observation_id},
             **params,
         )
         responses.append(response)
@@ -321,7 +321,7 @@ def delete_observation(observation_id: int, **params):
         :py:exc:`~urllib3.exceptions.HTTPError`: 403 if the observation belongs to another user
     """
     response = delete(
-        url=f'{API_V0_BASE_URL}/observations/{observation_id}.json',
+        url=f'{API_V0}/observations/{observation_id}.json',
         raise_for_status=False,
         **params,
     )
diff --git a/pyinaturalist/v1/__init__.py b/pyinaturalist/v1/__init__.py
index 15ad6b9..c6cfbeb 100644
--- a/pyinaturalist/v1/__init__.py
+++ b/pyinaturalist/v1/__init__.py
@@ -5,32 +5,6 @@ See: http://api.inaturalist.org/v1/docs/
 Most recent API version tested: 1.3.0
 """
 # flake8: noqa: F401, F403
-from requests import Response
-
-from pyinaturalist.constants import API_V1_BASE_URL
-from pyinaturalist.session import delete, get, post, put
-
-
-def get_v1(endpoint: str, **kwargs) -> Response:
-    """Make a GET request to iNaturalist API v1"""
-    return get(f'{API_V1_BASE_URL}/{endpoint}', **kwargs)
-
-
-def post_v1(endpoint: str, **kwargs) -> Response:
-    """Make a POST request to iNaturalist API v1"""
-    return post(f'{API_V1_BASE_URL}/{endpoint}', **kwargs)
-
-
-def put_v1(endpoint: str, **kwargs) -> Response:
-    """Make a PUT request to iNaturalist API v1"""
-    return put(f'{API_V1_BASE_URL}/{endpoint}', **kwargs)
-
-
-def delete_v1(endpoint: str, **kwargs) -> Response:
-    """Make a DELETE request to iNaturalist API v1"""
-    return delete(f'{API_V1_BASE_URL}/{endpoint}', **kwargs)
-
-
 from pyinaturalist.v1.controlled_terms import get_controlled_terms
 from pyinaturalist.v1.identifications import get_identifications, get_identifications_by_id
 from pyinaturalist.v1.messages import get_message_by_id, get_messages, get_unread_meassage_count
diff --git a/pyinaturalist/v1/controlled_terms.py b/pyinaturalist/v1/controlled_terms.py
index 25b0314..272de2c 100644
--- a/pyinaturalist/v1/controlled_terms.py
+++ b/pyinaturalist/v1/controlled_terms.py
@@ -1,6 +1,6 @@
-from pyinaturalist.constants import JsonResponse
+from pyinaturalist.constants import API_V1, JsonResponse
 from pyinaturalist.exceptions import TaxonNotFound
-from pyinaturalist.v1 import get_v1
+from pyinaturalist.session import get
 
 
 def get_controlled_terms(taxon_id: int = None, **params) -> JsonResponse:
@@ -44,7 +44,7 @@ def get_controlled_terms(taxon_id: int = None, **params) -> JsonResponse:
     """
     # 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 = get_v1(endpoint, taxon_id=taxon_id, **params)
+    response = get(f'{API_V1}/{endpoint}', taxon_id=taxon_id, **params)
 
     # controlled_terms/for_taxon returns a 422 if the specified taxon does not exist
     if response.status_code in (404, 422):
diff --git a/pyinaturalist/v1/identifications.py b/pyinaturalist/v1/identifications.py
index 9bfa940..84c2d14 100644
--- a/pyinaturalist/v1/identifications.py
+++ b/pyinaturalist/v1/identifications.py
@@ -1,10 +1,10 @@
-from pyinaturalist.constants import JsonResponse, MultiInt
+from pyinaturalist.constants import API_V1, JsonResponse, MultiInt
 from pyinaturalist.converters import convert_all_timestamps
 from pyinaturalist.docs import document_request_params
 from pyinaturalist.docs import templates as docs
 from pyinaturalist.paginator import paginate_all
 from pyinaturalist.request_params import convert_rank_range
-from pyinaturalist.v1 import get_v1
+from pyinaturalist.session import get
 
 
 def get_identifications_by_id(identification_id: MultiInt, **params) -> JsonResponse:
@@ -28,7 +28,7 @@ def get_identifications_by_id(identification_id: MultiInt, **params) -> JsonResp
     Returns:
         Response dict containing identification records
     """
-    response = get_v1('identifications', ids=identification_id, **params)
+    response = get(f'{API_V1}/identifications', ids=identification_id, **params)
     identifications = response.json()
     identifications['results'] = convert_all_timestamps(identifications['results'])
     return identifications
@@ -62,9 +62,9 @@ def get_identifications(**params) -> JsonResponse:
     """
     params = convert_rank_range(params)
     if params.get('page') == 'all':
-        identifications = paginate_all(get_v1, 'identifications', **params)
+        identifications = paginate_all(get, f'{API_V1}/identifications', **params)
     else:
-        identifications = get_v1('identifications', **params).json()
+        identifications = get(f'{API_V1}/identifications', **params).json()
 
     identifications['results'] = convert_all_timestamps(identifications['results'])
     return identifications
diff --git a/pyinaturalist/v1/messages.py b/pyinaturalist/v1/messages.py
index cf5aa29..b6b0d72 100644
--- a/pyinaturalist/v1/messages.py
+++ b/pyinaturalist/v1/messages.py
@@ -1,10 +1,10 @@
 from logging import getLogger
 
-from pyinaturalist.constants import JsonResponse, MultiInt
+from pyinaturalist.constants import API_V1, JsonResponse, MultiInt
 from pyinaturalist.converters import convert_all_timestamps
 from pyinaturalist.docs import document_request_params
 from pyinaturalist.docs import templates as docs
-from pyinaturalist.v1 import get_v1
+from pyinaturalist.session import get
 
 logger = getLogger(__name__)
 
@@ -30,7 +30,7 @@ def get_message_by_id(message_id: MultiInt, **params) -> JsonResponse:
     Returns:
         Response dict containing user record
     """
-    response = get_v1('messages', ids=message_id, **params)
+    response = get(f'{API_V1}/messages', ids=message_id, **params)
     messages = response.json()
     messages['results'] = convert_all_timestamps(messages['results'])
     return messages
@@ -62,7 +62,7 @@ def get_messages(**params) -> JsonResponse:
         params['box'] = 'any'
         params['q'] = None
 
-    response = get_v1('messages', **params)
+    response = get(f'{API_V1}/messages', **params)
     messages = response.json()
     messages['results'] = convert_all_timestamps(messages['results'])
     return messages
@@ -83,7 +83,7 @@ def get_unread_meassage_count(**params) -> int:
     Returns:
         Unread message count
     """
-    response = get_v1('messages/unread', **params)
+    response = get(f'{API_V1}/messages/unread', **params)
     try:
         return int(response.json()['count'])
     except (KeyError, TypeError, ValueError):
diff --git a/pyinaturalist/v1/observation_fields.py b/pyinaturalist/v1/observation_fields.py
index 3cfd840..2a3f069 100644
--- a/pyinaturalist/v1/observation_fields.py
+++ b/pyinaturalist/v1/observation_fields.py
@@ -1,9 +1,9 @@
 from typing import Any, Union
 
-from pyinaturalist.constants import JsonResponse
+from pyinaturalist.constants import API_V1, JsonResponse
 from pyinaturalist.docs import document_request_params
 from pyinaturalist.docs import templates as docs
-from pyinaturalist.v1 import delete_v1, post_v1
+from pyinaturalist.session import delete, post
 
 
 @document_request_params(docs._ofvs, docs._access_token)
@@ -45,7 +45,7 @@ def set_observation_field(
         'observation_field_id': observation_field_id,
         'value': value,
     }
-    response = post_v1('observation_field_values', json=body, **params)
+    response = post(f'{API_V1}/observation_field_values', json=body, **params)
     return response.json()
 
 
@@ -70,5 +70,5 @@ def delete_observation_field(observation_field_value_id: Union[int, str], **para
         observation_field_value_id: ID or UUID of the observation field value to delete
         access_token: An access token required for user authentication, as returned by :py:func:`.get_access_token()`
     """
-    response = delete_v1(f'observation_field_values/{observation_field_value_id}', **params)
+    response = delete(f'{API_V1}/observation_field_values/{observation_field_value_id}', **params)
     return response.json()
diff --git a/pyinaturalist/v1/observations.py b/pyinaturalist/v1/observations.py
index 05f3ec4..8e1924b 100644
--- a/pyinaturalist/v1/observations.py
+++ b/pyinaturalist/v1/observations.py
@@ -1,6 +1,8 @@
+from copy import deepcopy
 from logging import getLogger
 
 from pyinaturalist.constants import (
+    API_V1,
     V1_OBS_ORDER_BY_PROPERTIES,
     HistogramResponse,
     IntOrStr,
@@ -23,7 +25,7 @@ from pyinaturalist.docs import templates as docs
 from pyinaturalist.exceptions import ObservationNotFound
 from pyinaturalist.paginator import paginate_all
 from pyinaturalist.request_params import convert_observation_params, validate_multiple_choice_param
-from pyinaturalist.v1 import delete_v1, get_v1, post_v1, put_v1
+from pyinaturalist.session import delete, get, post, put
 
 logger = getLogger(__name__)
 
@@ -110,7 +112,7 @@ def get_observation_histogram(**params) -> HistogramResponse:
         Dict of ``{time_key: observation_count}``. Keys are ints for 'month of year' and\
         'week of year' intervals, and :py:class:`~datetime.datetime` objects for all other intervals.
     """
-    response = get_v1('observations/histogram', **params)
+    response = get(f'{API_V1}/observations/histogram', **params)
     return convert_observation_histogram(response.json())
 
 
@@ -150,12 +152,12 @@ def get_observations(**params) -> JsonResponse:
     Returns:
         Response dict containing observation records
     """
-    validate_multiple_choice_param(params, 'order_by', V1_OBS_ORDER_BY_PROPERTIES)
+    params = validate_multiple_choice_param(params, 'order_by', V1_OBS_ORDER_BY_PROPERTIES)
 
     if params.get('page') == 'all':
-        observations = paginate_all(get_v1, 'observations', method='id', **params)
+        observations = paginate_all(get, f'{API_V1}/observations', method='id', **params)
     else:
-        observations = get_v1('observations', **params).json()
+        observations = get(f'{API_V1}/observations', **params).json()
 
     observations['results'] = convert_all_coordinates(observations['results'])
     observations['results'] = convert_all_timestamps(observations['results'])
@@ -189,7 +191,7 @@ def get_observation_identifiers(**params) -> JsonResponse:
         Response dict of identifiers
     """
     params.setdefault('per_page', 500)
-    response = get_v1('observations/identifiers', **params)
+    response = get(f'{API_V1}/observations/identifiers', **params)
     return response.json()
 
 
@@ -223,7 +225,7 @@ def get_observation_observers(**params) -> JsonResponse:
         Response dict of observers
     """
     params.setdefault('per_page', 500)
-    response = get_v1('observations/observers', **params)
+    response = get(f'{API_V1}/observations/observers', **params)
     return response.json()
 
 
@@ -254,9 +256,9 @@ def get_observation_species_counts(**params) -> JsonResponse:
         Response dict containing taxon records with counts
     """
     if params.get('page') == 'all':
-        return paginate_all(get_v1, 'observations/species_counts', **params)
+        return paginate_all(get, f'{API_V1}/observations/species_counts', **params)
     else:
-        return get_v1('observations/species_counts', **params).json()
+        return get(f'{API_V1}/observations/species_counts', **params).json()
 
 
 @document_request_params(*docs._get_observations)
@@ -281,7 +283,7 @@ def get_observation_popular_field_values(**params) -> JsonResponse:
         Response dict. Each record contains a ``count``, a ``month_of_year`` histogram, a
             ``controlled_attribute``, and a ``controlled_value``.
     """
-    response_json = get_v1('observations/popular_field_values', **params).json()
+    response_json = get(f'{API_V1}/observations/popular_field_values', **params).json()
     for r in response_json['results']:
         r['month_of_year'] = convert_histogram(r['month_of_year'], interval='month_of_year')
     return response_json
@@ -307,9 +309,9 @@ def get_observation_taxonomy(user_id: IntOrStr = None, **params) -> JsonResponse
         Response dict containing taxon records with counts
     """
     if params.get('page') == 'all':
-        return paginate_all(get_v1, 'observations/taxonomy', user_id=user_id, **params)
+        return paginate_all(get, f'{API_V1}/observations/taxonomy', user_id=user_id, **params)
     else:
-        return get_v1('observations/taxonomy', user_id=user_id, **params).json()
+        return get(f'{API_V1}/observations/taxonomy', user_id=user_id, **params).json()
 
 
 @document_common_args
@@ -334,7 +336,7 @@ def get_observation_taxon_summary(observation_id: int, **params) -> JsonResponse
     Returns:
         Response dict containing taxon summary, optionally with conservation status and listed taxon
     """
-    results = get_v1(f'observations/{observation_id}/taxon_summary', **params).json()
+    results = get(f'{API_V1}/observations/{observation_id}/taxon_summary', **params).json()
     results['conservation_status'] == convert_generic_timestamps(results['conservation_status'])
     results['listed_taxon'] == convert_generic_timestamps(results['listed_taxon'])
     return results
@@ -376,7 +378,7 @@ def create_observation(**params) -> JsonResponse:
         JSON response containing the newly created observation(s)
     """
     photos, sounds, photo_ids, params, kwargs = convert_observation_params(params)
-    response = post_v1('observations', json={'observation': params}, **kwargs)
+    response = post(f'{API_V1}/observations', json={'observation': params}, **kwargs)
     response_json = response.json()
     observation_id = response_json['id']
 
@@ -427,7 +429,7 @@ def update_observation(observation_id: int, **params) -> ListResponse:
         payload['local_photos'] = {str(observation_id): combined_photo_ids}
         kwargs.pop('ignore_photos', None)
 
-    response = put_v1(f'observations/{observation_id}', json=payload, **kwargs)
+    response = put(f'{API_V1}/observations/{observation_id}', json=payload, **kwargs)
     upload(observation_id, photos=photos, sounds=sounds, **kwargs)
     return response.json()
 
@@ -483,23 +485,17 @@ def upload(
     logger.info(f'Uploading {len(photos)} photos and {len(sounds)} sounds')
 
     # Upload photos
+    photo_params = deepcopy(params)
+    photo_params['observation_photo[observation_id]'] = observation_id
     for photo in photos:
-        response = post_v1(
-            'observation_photos',
-            files=photo,
-            **{'observation_photo[observation_id]': observation_id},
-            **params,
-        )
+        response = post(f'{API_V1}/observation_photos', files=photo, **photo_params)
         responses.append(response)
 
     # Upload sounds
+    sound_params = deepcopy(params)
+    sound_params['observation_sound[observation_id]'] = observation_id
     for sound in sounds:
-        response = post_v1(
-            'observation_sounds',
-            files=sound,
-            **{'observation_sound[observation_id]': observation_id},
-            **params,
-        )
+        response = post(f'{API_V1}/observation_sounds', files=sound, **sound_params)
         responses.append(response)
 
     # Attach previously uploaded photos by ID
@@ -535,8 +531,8 @@ def delete_observation(observation_id: int, access_token: str = None, **params):
         :py:exc:`.ObservationNotFound` if the requested observation doesn't exist
         :py:exc:`requests.HTTPError` (403) if the observation belongs to another user
     """
-    response = delete_v1(
-        f'observations/{observation_id}',
+    response = delete(
+        f'{API_V1}/observations/{observation_id}',
         access_token=access_token,
         raise_for_status=False,
         **params,
diff --git a/pyinaturalist/v1/places.py b/pyinaturalist/v1/places.py
index 1846a07..9a41ced 100644
--- a/pyinaturalist/v1/places.py
+++ b/pyinaturalist/v1/places.py
@@ -1,12 +1,11 @@
 from functools import wraps
 
-from pyinaturalist.constants import JsonResponse, MultiInt
+from pyinaturalist.constants import API_V1, JsonResponse, MultiInt
 from pyinaturalist.converters import convert_all_coordinates, convert_all_place_coordinates
 from pyinaturalist.docs import document_request_params
 from pyinaturalist.docs import templates as docs
-from pyinaturalist.models import Place
 from pyinaturalist.paginator import AutocompletePaginator, JsonPaginator
-from pyinaturalist.v1 import get_v1
+from pyinaturalist.session import get
 
 
 def get_places_by_id(place_id: MultiInt, **params) -> JsonResponse:
@@ -33,7 +32,7 @@ def get_places_by_id(place_id: MultiInt, **params) -> JsonResponse:
     Returns:
         Response dict containing place records
     """
-    response = get_v1('places', ids=place_id, **params)
+    response = get(f'{API_V1}/places', ids=place_id, **params)
 
     # Convert coordinates to floats
     places = response.json()
@@ -78,7 +77,9 @@ def get_places_nearby(
     Returns:
         Response dict containing place records, divided into 'standard' and 'community' places.
     """
-    response = get_v1('places/nearby', nelat=nelat, nelng=nelng, swlat=swlat, swlng=swlng, **params)
+    response = get(
+        f'{API_V1}/places/nearby', nelat=nelat, nelng=nelng, swlat=swlat, swlng=swlng, **params
+    )
     return convert_all_place_coordinates(response.json())
 
 
@@ -110,10 +111,9 @@ def get_places_autocomplete(q: str = None, **params) -> JsonResponse:
         Response dict containing place records
     """
     if params.get('page') == 'all':
-        pagninator = PlaceAutocompletePaginator(q=q, **params)
-        places = pagninator.all()
+        places = PlaceAutocompletePaginator(q=q, **params).all()
     else:
-        places = get_v1('places/autocomplete', q=q, **params).json()
+        places = get(f'{API_V1}/places/autocomplete', q=q, **params).json()
 
     places['results'] = convert_all_coordinates(places['results'])
     return places
@@ -123,8 +123,8 @@ class PlaceAutocompletePaginator(JsonPaginator, AutocompletePaginator):  # type:
     def __init__(self, **kwargs):
         kwargs['per_page'] = 20
 
-        @wraps(get_v1)
+        @wraps(get)
         def reqeuest_function(**request_kwargs):
-            return get_v1('places/autocomplete', **request_kwargs)
+            return get(f'{API_V1}/places/autocomplete', **request_kwargs)
 
-        super().__init__(reqeuest_function, model=Place, **kwargs)
+        super().__init__(reqeuest_function, **kwargs)
diff --git a/pyinaturalist/v1/posts.py b/pyinaturalist/v1/posts.py
index 492b6ca..894515b 100644
--- a/pyinaturalist/v1/posts.py
+++ b/pyinaturalist/v1/posts.py
@@ -1,8 +1,8 @@
-from pyinaturalist.constants import ListResponse
+from pyinaturalist.constants import API_V1, ListResponse
 from pyinaturalist.converters import convert_all_coordinates, convert_all_timestamps
 from pyinaturalist.docs import document_request_params
 from pyinaturalist.docs import templates as docs
-from pyinaturalist.v1 import get_v1
+from pyinaturalist.session import get
 
 
 @document_request_params(docs._get_posts)
@@ -21,7 +21,7 @@ def get_posts(**params) -> ListResponse:
     Returns:
         List containing journal posts from the iNaturalist site
     """
-    response = get_v1('posts', **params)
+    response = get(f'{API_V1}/posts', **params)
 
     posts = response.json()
     posts = convert_all_coordinates(posts)
diff --git a/pyinaturalist/v1/projects.py b/pyinaturalist/v1/projects.py
index d3b2bda..86c92e2 100644
--- a/pyinaturalist/v1/projects.py
+++ b/pyinaturalist/v1/projects.py
@@ -1,6 +1,7 @@
 from logging import getLogger
 
 from pyinaturalist.constants import (
+    API_V1,
     PROJECT_ORDER_BY_PROPERTIES,
     IntOrStr,
     JsonResponse,
@@ -12,8 +13,7 @@ from pyinaturalist.docs import document_request_params
 from pyinaturalist.docs import templates as docs
 from pyinaturalist.paginator import paginate_all
 from pyinaturalist.request_params import split_common_params, validate_multiple_choice_param
-from pyinaturalist.session import get_refresh_params
-from pyinaturalist.v1 import delete_v1, get_v1, post_v1, put_v1
+from pyinaturalist.session import delete, get, get_refresh_params, post, put
 
 logger = getLogger(__name__)
 
@@ -55,9 +55,9 @@ def get_projects(**params) -> JsonResponse:
     """
     validate_multiple_choice_param(params, 'order_by', PROJECT_ORDER_BY_PROPERTIES)
     if params.get('page') == 'all':
-        projects = paginate_all(get_v1, 'projects', **params)
+        projects = paginate_all(get, f'{API_V1}/projects', **params)
     else:
-        projects = get_v1('projects', **params).json()
+        projects = get(f'{API_V1}/projects', **params).json()
 
     projects['results'] = convert_all_coordinates(projects['results'])
     projects['results'] = convert_all_timestamps(projects['results'])
@@ -99,8 +99,12 @@ def get_projects_by_id(
     """
     if force_refresh:
         params.update(get_refresh_params(f'/projects/{project_id}'))
-    response = get_v1(
-        'projects', rule_details=rule_details, ids=project_id, allow_str_ids=True, **params
+    response = get(
+        f'{API_V1}/projects',
+        rule_details=rule_details,
+        ids=project_id,
+        allow_str_ids=True,
+        **params,
     )
 
     projects = response.json()
@@ -133,8 +137,8 @@ def add_project_observation(
     Returns:
         Information about the added project observation
     """
-    response = post_v1(
-        'project_observations',
+    response = post(
+        f'{API_V1}/project_observations',
         access_token=access_token,
         json={'observation_id': observation_id, 'project_id': project_id},
         **params,
@@ -186,8 +190,8 @@ def delete_project_observation(
 
         >>> delete_project_observation(24237, 1234, access_token=access_token)
     """
-    return delete_v1(
-        f'projects/{project_id}/remove',
+    return delete(
+        f'{API_V1}/projects/{project_id}/remove',
         access_token=access_token,
         json={'observation_id': observation_id},
         **params,
@@ -268,8 +272,8 @@ def update_project(project_id: IntOrStr, **params) -> JsonResponse:
     params, kwargs = split_common_params(params)
     kwargs['timeout'] = kwargs.get('timeout') or 60  # This endpoint can be a bit slow
 
-    response = put_v1(
-        f'projects/{project_id}',
+    response = put(
+        f'{API_V1}/projects/{project_id}',
         json={'project': params},
         **kwargs,
     )
diff --git a/pyinaturalist/v1/search.py b/pyinaturalist/v1/search.py
index 5c6cff7..7f3ae8c 100644
--- a/pyinaturalist/v1/search.py
+++ b/pyinaturalist/v1/search.py
@@ -1,8 +1,8 @@
-from pyinaturalist.constants import JsonResponse
+from pyinaturalist.constants import API_V1, JsonResponse
 from pyinaturalist.converters import convert_all_coordinates, convert_all_timestamps
 from pyinaturalist.docs import document_request_params
 from pyinaturalist.docs import templates as docs
-from pyinaturalist.v1 import get_v1
+from pyinaturalist.session import get
 
 
 @document_request_params(docs._search_params, docs._pagination)
@@ -29,7 +29,7 @@ def search(q: str, **params) -> JsonResponse:
     Returns:
         Response dict containing search results
     """
-    response = get_v1('search', q=q, **params)
+    response = get(f'{API_V1}/search', q=q, **params)
     search_results = response.json()
     search_results['results'] = convert_all_timestamps(search_results['results'])
     search_results['results'] = convert_all_coordinates(search_results['results'])
diff --git a/pyinaturalist/v1/taxa.py b/pyinaturalist/v1/taxa.py
index 8f88d29..51a0c44 100644
--- a/pyinaturalist/v1/taxa.py
+++ b/pyinaturalist/v1/taxa.py
@@ -1,10 +1,10 @@
-from pyinaturalist.constants import JsonResponse, MultiInt
+from pyinaturalist.constants import API_V1, JsonResponse, MultiInt
 from pyinaturalist.converters import convert_all_timestamps
 from pyinaturalist.docs import document_request_params
 from pyinaturalist.docs import templates as docs
 from pyinaturalist.paginator import paginate_all
 from pyinaturalist.request_params import convert_rank_range
-from pyinaturalist.v1 import get_v1
+from pyinaturalist.session import get
 
 
 @document_request_params(docs._taxon_params, docs._taxon_id_params, docs._pagination)
@@ -34,9 +34,9 @@ def get_taxa(**params) -> JsonResponse:
     """
     params = convert_rank_range(params)
     if params.get('page') == 'all':
-        taxa = paginate_all(get_v1, 'taxa', **params)
+        taxa = paginate_all(get, f'{API_V1}/taxa', **params)
     else:
-        taxa = get_v1('taxa', **params).json()
+        taxa = get(f'{API_V1}/taxa', **params).json()
 
     taxa['results'] = convert_all_timestamps(taxa['results'])
     return taxa
@@ -71,7 +71,7 @@ def get_taxa_by_id(taxon_id: MultiInt, **params) -> JsonResponse:
     Returns:
         Response dict containing taxon records
     """
-    response = get_v1('taxa', ids=taxon_id, **params)
+    response = get(f'{API_V1}/taxa', ids=taxon_id, **params)
     taxa = response.json()
     taxa['results'] = convert_all_timestamps(taxa['results'])
     return taxa
@@ -124,7 +124,7 @@ def get_taxa_autocomplete(**params) -> JsonResponse:
         Response dict containing taxon records
     """
     params = convert_rank_range(params)
-    response = get_v1('taxa/autocomplete', **params)
+    response = get(f'{API_V1}/taxa/autocomplete', **params)
     return response.json()
 
 
@@ -145,6 +145,6 @@ def get_taxa_map_layers(taxon_id: int, **params) -> JsonResponse:
     Args:
         taxon_id: iNaturalist taxon ID. Only one value is allowed.
     """
-    response = get_v1(f'taxa/{taxon_id}/map_layers', **params).json()
+    response = get(f'{API_V1}/taxa/{taxon_id}/map_layers', **params).json()
     response['gbif_url'] = f'https://www.gbif.org/species/{response["gbif_id"]}'
     return response
diff --git a/pyinaturalist/v1/users.py b/pyinaturalist/v1/users.py
index 4ce9810..188bd4e 100644
--- a/pyinaturalist/v1/users.py
+++ b/pyinaturalist/v1/users.py
@@ -1,10 +1,10 @@
 from logging import getLogger
 
-from pyinaturalist.constants import IntOrStr, JsonResponse
+from pyinaturalist.constants import API_V1, IntOrStr, JsonResponse
 from pyinaturalist.converters import convert_all_timestamps, convert_generic_timestamps
 from pyinaturalist.docs import document_request_params
 from pyinaturalist.docs import templates as docs
-from pyinaturalist.v1 import get_v1
+from pyinaturalist.session import get
 
 logger = getLogger(__name__)
 
@@ -32,7 +32,7 @@ def get_user_by_id(user_id: IntOrStr, **params) -> JsonResponse:
     Returns:
         Response dict containing user record
     """
-    response = get_v1('users', ids=user_id, allow_str_ids=True, **params)
+    response = get(f'{API_V1}/users', ids=user_id, allow_str_ids=True, **params)
     results = response.json()['results']
     if not results:
         return {}
@@ -62,7 +62,7 @@ def get_users_autocomplete(q: str, **params) -> JsonResponse:
     Returns:
         Response dict containing user records
     """
-    response = get_v1('users/autocomplete', q=q, **params)
+    response = get(f'{API_V1}/users/autocomplete', q=q, **params)
     users = response.json()
     users['results'] = convert_all_timestamps(users['results'])
     return users


Test patch:
diff --git a/test/conftest.py b/test/conftest.py
index f2a21fb..16ec77c 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -10,10 +10,11 @@ from os.path import join
 from unittest.mock import MagicMock, patch
 
 import pytest
-from requests import Session
+from requests_cache import DO_NOT_CACHE, BaseCache
 
 from pyinaturalist import enable_logging
 from pyinaturalist.constants import SAMPLE_DATA_DIR
+from pyinaturalist.session import ClientSession
 
 # If ipdb is installed, register it as the default debugger
 try:
@@ -43,18 +44,18 @@ MOCK_CREDS_OAUTH = {
 enable_logging('DEBUG')
 
 
-class TestSession(Session):
-    """Session subclass that adds additional keyword args to main methods, so it can be used in
-    place of `ClientSession` during tests
-    """
+class TestSession(ClientSession):
+    """Session class to use for tests, which disables rate-limiting and caching"""
 
-    def request(self, *args, expire_after=None, only_if_cached=None, refresh=False, **kwargs):
-        return super().request(*args, **kwargs)
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.limiter = MagicMock()
+        self.cache = BaseCache(expire_after=DO_NOT_CACHE)
 
 
 @pytest.fixture(scope='function', autouse=True)
-def patch_cached_session(request):
-    """Use a regular requests.Session to disable request caching and rate-limiting during tests"""
+def patch_session(request):
+    """Disable request caching and rate-limiting during tests"""
     if 'enable_client_session' in request.keywords:
         yield
     else:
diff --git a/test/controllers/test_observation_controller.py b/test/controllers/test_observation_controller.py
index ef75a0f..cf99050 100644
--- a/test/controllers/test_observation_controller.py
+++ b/test/controllers/test_observation_controller.py
@@ -4,7 +4,7 @@ from datetime import datetime
 from dateutil.tz import tzutc
 
 from pyinaturalist.client import iNatClient
-from pyinaturalist.constants import API_V1_BASE_URL
+from pyinaturalist.constants import API_V1
 from pyinaturalist.models import (
     ConservationStatus,
     LifeList,
@@ -21,7 +21,7 @@ from test.sample_data import *
 def test_from_id(requests_mock):
     observation_id = 57754375
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations',
+        f'{API_V1}/observations',
         json=SAMPLE_DATA['get_observations_node_page1'],
         status_code=200,
     )
@@ -34,7 +34,7 @@ def test_from_id(requests_mock):
 def test_from_id__not_found(requests_mock):
     observation_id = 57754375
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations',
+        f'{API_V1}/observations',
         json={'results': [], 'total_results': 0},
         status_code=200,
     )
@@ -44,7 +44,7 @@ def test_from_id__not_found(requests_mock):
 def test_from_ids(requests_mock):
     observation_id = 57754375
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations',
+        f'{API_V1}/observations',
         [
             {'json': SAMPLE_DATA['get_observations_node_page1'], 'status_code': 200},
             {'json': SAMPLE_DATA['get_observations_node_page2'], 'status_code': 200},
@@ -59,7 +59,7 @@ def test_from_ids(requests_mock):
 def test_from_ids__limit(requests_mock):
     observation_id = 57754375
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations',
+        f'{API_V1}/observations',
         [
             {'json': SAMPLE_DATA['get_observations_node_page1'], 'status_code': 200},
             {'json': SAMPLE_DATA['get_observations_node_page2'], 'status_code': 200},
@@ -73,7 +73,7 @@ def test_from_ids__limit(requests_mock):
 
 def test_search(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations',
+        f'{API_V1}/observations',
         json=SAMPLE_DATA['get_observations_node_page1'],
         status_code=200,
     )
@@ -91,7 +91,7 @@ def test_search(requests_mock):
 
 def test_histogram(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations/histogram',
+        f'{API_V1}/observations/histogram',
         json=SAMPLE_DATA['get_observation_histogram_day'],
         status_code=200,
     )
@@ -103,7 +103,7 @@ def test_histogram(requests_mock):
 
 def test_identifiers(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations/identifiers',
+        f'{API_V1}/observations/identifiers',
         json=j_observation_identifiers,
         status_code=200,
     )
@@ -117,7 +117,7 @@ def test_identifiers(requests_mock):
 
 def test_observers(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations/observers',
+        f'{API_V1}/observations/observers',
         json=j_observation_observers,
         status_code=200,
     )
@@ -132,7 +132,7 @@ def test_observers(requests_mock):
 
 def test_life_list(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations/taxonomy',
+        f'{API_V1}/observations/taxonomy',
         json=j_life_list,
         status_code=200,
     )
@@ -148,7 +148,7 @@ def test_life_list(requests_mock):
 
 def test_popular_fields(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations/popular_field_values',
+        f'{API_V1}/observations/popular_field_values',
         json=SAMPLE_DATA['get_observation_popular_field_values'],
         status_code=200,
     )
@@ -162,7 +162,7 @@ def test_popular_fields(requests_mock):
 
 def test_species_counts(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations/species_counts',
+        f'{API_V1}/observations/species_counts',
         json=SAMPLE_DATA['get_observation_species_counts'],
         status_code=200,
     )
@@ -178,7 +178,7 @@ def test_species_counts(requests_mock):
 
 def test_taxon_summary__with_conservation_status(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations/89238647/taxon_summary',
+        f'{API_V1}/observations/89238647/taxon_summary',
         json=j_taxon_summary_1_conserved,
         status_code=200,
     )
@@ -193,7 +193,7 @@ def test_taxon_summary__with_conservation_status(requests_mock):
 
 def test_taxon_summary__with_listed_taxon(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations/7849808/taxon_summary',
+        f'{API_V1}/observations/7849808/taxon_summary',
         json=j_taxon_summary_2_listed,
         status_code=200,
     )
diff --git a/test/controllers/test_place_controller.py b/test/controllers/test_place_controller.py
index 73e6647..d601f68 100644
--- a/test/controllers/test_place_controller.py
+++ b/test/controllers/test_place_controller.py
@@ -1,12 +1,12 @@
 from pyinaturalist.client import iNatClient
-from pyinaturalist.constants import API_V1_BASE_URL
+from pyinaturalist.constants import API_V1
 from pyinaturalist.models import Place
 from test.sample_data import SAMPLE_DATA, j_place_1, j_place_2
 
 
 def test_from_id(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/places/89191',
+        f'{API_V1}/places/89191',
         json={'results': [j_place_1]},
         status_code=200,
     )
@@ -17,7 +17,7 @@ def test_from_id(requests_mock):
 
 def test_from_ids(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/places/89191,67591',
+        f'{API_V1}/places/89191,67591',
         json={'results': [j_place_1, j_place_2], 'total_results': 2},
         status_code=200,
     )
@@ -32,7 +32,7 @@ def test_from_ids(requests_mock):
 
 def test_autocomplete(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/places/autocomplete',
+        f'{API_V1}/places/autocomplete',
         json=SAMPLE_DATA['get_places_autocomplete'],
         status_code=200,
     )
@@ -48,7 +48,7 @@ def test_get_places_autocomplete__all_pages(requests_mock):
     page_1 = {'total_results': 25, 'results': [{'id': i} for i in range(20)]}
     page_2 = {'total_results': 25, 'results': [{'id': i} for i in range(15, 25)]}
     requests_mock.get(
-        f'{API_V1_BASE_URL}/places/autocomplete',
+        f'{API_V1}/places/autocomplete',
         [
             {'json': page_1, 'status_code': 200},
             {'json': page_2, 'status_code': 200},
@@ -62,7 +62,7 @@ def test_get_places_autocomplete__all_pages(requests_mock):
 
 def test_nearby(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/places/nearby',
+        f'{API_V1}/places/nearby',
         json=SAMPLE_DATA['get_places_nearby'],
         status_code=200,
     )
diff --git a/test/controllers/test_project_controller.py b/test/controllers/test_project_controller.py
index 2455255..00b8337 100644
--- a/test/controllers/test_project_controller.py
+++ b/test/controllers/test_project_controller.py
@@ -5,7 +5,7 @@ from unittest.mock import patch
 from dateutil.tz import tzutc
 
 from pyinaturalist.client import iNatClient
-from pyinaturalist.constants import API_V1_BASE_URL
+from pyinaturalist.constants import API_V1
 from pyinaturalist.models import Project, ProjectObservationField, ProjectUser, User
 from test.sample_data import SAMPLE_DATA, j_project_1, j_project_2, j_project_3_obs_fields
 
@@ -13,7 +13,7 @@ from test.sample_data import SAMPLE_DATA, j_project_1, j_project_2, j_project_3_
 def test_from_id(requests_mock):
     project_id = 8291
     requests_mock.get(
-        f'{API_V1_BASE_URL}/projects/{project_id}',
+        f'{API_V1}/projects/{project_id}',
         json={'results': [j_project_1]},
         status_code=200,
     )
@@ -25,7 +25,7 @@ def test_from_id(requests_mock):
 def test_from_ids(requests_mock):
     project_id = 8291
     requests_mock.get(
-        f'{API_V1_BASE_URL}/projects/{project_id}',
+        f'{API_V1}/projects/{project_id}',
         json={'results': [j_project_1, j_project_2], 'total_results': 2},
         status_code=200,
     )
@@ -47,7 +47,7 @@ def test_from_ids(requests_mock):
 
 def test_search(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/projects',
+        f'{API_V1}/projects',
         json=SAMPLE_DATA['get_projects'],
         status_code=200,
     )
@@ -75,7 +75,7 @@ def test_search(requests_mock):
 
 def test_search__with_obs_fields(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/projects',
+        f'{API_V1}/projects',
         json={'results': [j_project_3_obs_fields], 'total_results': 1},
         status_code=200,
     )
@@ -91,7 +91,7 @@ def test_search__with_obs_fields(requests_mock):
 @patch('pyinaturalist.client.get_access_token', return_value='token')
 def test_add_observation(get_access_token, requests_mock):
     requests_mock.post(
-        f'{API_V1_BASE_URL}/project_observations',
+        f'{API_V1}/project_observations',
         json=SAMPLE_DATA['add_project_observation'],
         status_code=200,
     )
diff --git a/test/controllers/test_taxon_controller.py b/test/controllers/test_taxon_controller.py
index 7c91572..54f8fb2 100644
--- a/test/controllers/test_taxon_controller.py
+++ b/test/controllers/test_taxon_controller.py
@@ -1,5 +1,5 @@
 from pyinaturalist.client import iNatClient
-from pyinaturalist.constants import API_V1_BASE_URL
+from pyinaturalist.constants import API_V1
 from pyinaturalist.models import Taxon
 from test.sample_data import SAMPLE_DATA
 
@@ -7,7 +7,7 @@ from test.sample_data import SAMPLE_DATA
 def test_from_id(requests_mock):
     taxon_id = 70118
     requests_mock.get(
-        f'{API_V1_BASE_URL}/taxa/{taxon_id}',
+        f'{API_V1}/taxa/{taxon_id}',
         json=SAMPLE_DATA['get_taxa_by_id'],
         status_code=200,
     )
@@ -20,7 +20,7 @@ def test_from_id(requests_mock):
 def test_from_ids(requests_mock):
     taxon_id = 70118
     requests_mock.get(
-        f'{API_V1_BASE_URL}/taxa/{taxon_id}',
+        f'{API_V1}/taxa/{taxon_id}',
         json=SAMPLE_DATA['get_taxa_by_id'],
         status_code=200,
     )
@@ -32,7 +32,7 @@ def test_from_ids(requests_mock):
 
 def test_autocomplete(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/taxa/autocomplete',
+        f'{API_V1}/taxa/autocomplete',
         json=SAMPLE_DATA['get_taxa_autocomplete'],
         status_code=200,
     )
@@ -44,7 +44,7 @@ def test_autocomplete(requests_mock):
 
 def test_search(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/taxa',
+        f'{API_V1}/taxa',
         json=SAMPLE_DATA['get_taxa'],
         status_code=200,
     )
diff --git a/test/controllers/test_user_controller.py b/test/controllers/test_user_controller.py
index 14dcca5..12b00dc 100644
--- a/test/controllers/test_user_controller.py
+++ b/test/controllers/test_user_controller.py
@@ -1,12 +1,12 @@
 from pyinaturalist.client import iNatClient
-from pyinaturalist.constants import API_V1_BASE_URL
+from pyinaturalist.constants import API_V1
 from pyinaturalist.models import User
 from test.sample_data import SAMPLE_DATA
 
 
 def test_from_id(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/users/1',
+        f'{API_V1}/users/1',
         json=SAMPLE_DATA['get_user_by_id'],
         status_code=200,
     )
@@ -18,12 +18,12 @@ def test_from_id(requests_mock):
 
 def test_from_ids(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/users/1',
+        f'{API_V1}/users/1',
         json=SAMPLE_DATA['get_user_by_id'],
         status_code=200,
     )
     requests_mock.get(
-        f'{API_V1_BASE_URL}/users/2',
+        f'{API_V1}/users/2',
         json=SAMPLE_DATA['get_user_by_id'],
         status_code=200,
     )
@@ -35,7 +35,7 @@ def test_from_ids(requests_mock):
 
 def test_autocomplete(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/users/autocomplete',
+        f'{API_V1}/users/autocomplete',
         json=SAMPLE_DATA['get_users_autocomplete'],
         status_code=200,
     )
diff --git a/test/sample_data/get_observations_node_page1.json b/test/sample_data/get_observations_node_page1.json
index 7aa2370..385aa80 100644
--- a/test/sample_data/get_observations_node_page1.json
+++ b/test/sample_data/get_observations_node_page1.json
@@ -5,7 +5,6 @@
   "results": [
     {
       "quality_grade": "research",
-      "time_observed_at": "2020-08-27T08:57:22+00:00",
       "taxon_geoprivacy": "open",
       "annotations": [],
       "uuid": "c44b91f3-534d-48c4-933d-a8b4e1d8f87b",
@@ -20,6 +19,7 @@
       "id": 57754375,
       "cached_votes_total": 0,
       "identifications_most_agree": true,
+      "created_at": "2020-08-27T18:00:51+00:00",
       "created_at_details": {
         "date": "2020-08-27",
         "week": 35,
@@ -28,15 +28,20 @@
         "year": 2020,
         "day": 27
       },
+      "created_time_zone": "Etc/UTC",
+      "time_zone_offset": "+00:00",
+      "time_observed_at": "2020-08-27T08:57:22+00:00",
+      "observed_on": "2020-08-27",
+      "observed_time_zone": "Etc/UTC",
+      "observed_on_string": "Thu Aug 27 2020 08:57:22 GMT-0600 (CST)",
+      "updated_at": "2020-08-28T04:54:19+00:00",
       "species_guess": "Monarch",
       "identifications_most_disagree": false,
       "tags": [],
       "positional_accuracy": null,
       "comments_count": 2,
       "site_id": 5,
-      "created_time_zone": "Etc/UTC",
       "license_code": "cc-by-nc",
-      "observed_time_zone": "Etc/UTC",
       "quality_metrics": [],
       "public_positional_accuracy": null,
       "reviewed_by": [
@@ -45,13 +50,8 @@
       ],
       "oauth_application_id": 3,
       "flags": [],
-      "created_at": "2020-08-27T18:00:51+00:00",
       "description": null,
-      "time_zone_offset": "+00:00",
       "project_ids_with_curator_id": [],
-      "observed_on": "2020-08-27",
-      "observed_on_string": "Thu Aug 27 2020 08:57:22 GMT-0600 (CST)",
-      "updated_at": "2020-08-28T04:54:19+00:00",
       "sounds": [],
       "place_ids": [
         6712,
diff --git a/test/test_auth.py b/test/test_auth.py
index 597ea50..dd4d0e2 100644
--- a/test/test_auth.py
+++ b/test/test_auth.py
@@ -3,11 +3,12 @@ from unittest.mock import patch
 
 import pytest
 from keyring.errors import KeyringError
-from requests import HTTPError, Response, Session
+from requests import HTTPError, Response
 
 from pyinaturalist.auth import get_access_token, get_keyring_credentials, set_keyring_credentials
-from pyinaturalist.constants import API_V0_BASE_URL
+from pyinaturalist.constants import API_V0
 from pyinaturalist.exceptions import AuthenticationError
+from pyinaturalist.session import ClientSession
 from test.conftest import MOCK_CREDS_ENV, MOCK_CREDS_OAUTH, load_sample_data
 
 token_accepted_json = load_sample_data('get_access_token.json')
@@ -30,12 +31,12 @@ MOCK_JWT_504 = {'json': {}, 'status_code': 504}
 @patch.dict(os.environ, {}, clear=True)
 def test_get_access_token__oauth(requests_mock):
     requests_mock.post(
-        f'{API_V0_BASE_URL}/oauth/token',
+        f'{API_V0}/oauth/token',
         json=token_accepted_json,
         status_code=200,
     )
     requests_mock.get(
-        f'{API_V0_BASE_URL}/users/api_token',
+        f'{API_V0}/users/api_token',
         [MOCK_JWT_504, MOCK_JWT_401],
     )
 
@@ -48,12 +49,12 @@ def test_get_access_token__oauth(requests_mock):
 @patch.dict(os.environ, {}, clear=True)
 def test_get_access_token__jwt(requests_mock):
     requests_mock.post(
-        f'{API_V0_BASE_URL}/oauth/token',
+        f'{API_V0}/oauth/token',
         json=token_accepted_json,
         status_code=200,
     )
     requests_mock.get(
-        f'{API_V0_BASE_URL}/users/api_token',
+        f'{API_V0}/users/api_token',
         [MOCK_JWT_504, MOCK_JWT_200],
     )
 
@@ -67,12 +68,12 @@ def test_get_access_token__jwt(requests_mock):
 def test_get_access_token__cached_jwt(requests_mock):
     """An initial request will return a 200 if a JWT is already cached, or a 504 otherwise."""
     requests_mock.post(
-        f'{API_V0_BASE_URL}/oauth/token',
+        f'{API_V0}/oauth/token',
         json=token_accepted_json,
         status_code=200,
     )
     requests_mock.get(
-        f'{API_V0_BASE_URL}/users/api_token',
+        f'{API_V0}/users/api_token',
         json=jwt_json,
         status_code=200,
     )
@@ -87,12 +88,12 @@ def test_get_access_token__cached_jwt(requests_mock):
 @patch('pyinaturalist.auth.get_keyring_credentials')
 def test_get_access_token__envars(mock_keyring_credentials, requests_mock):
     requests_mock.post(
-        f'{API_V0_BASE_URL}/oauth/token',
+        f'{API_V0}/oauth/token',
         json=token_accepted_json,
         status_code=200,
     )
     requests_mock.get(
-        f'{API_V0_BASE_URL}/users/api_token',
+        f'{API_V0}/users/api_token',
         [MOCK_JWT_504, MOCK_JWT_200],
     )
 
@@ -110,12 +111,12 @@ def test_get_access_token__envars(mock_keyring_credentials, requests_mock):
 )
 def test_get_access_token__mixed_args_and_envars(requests_mock):
     requests_mock.post(
-        f'{API_V0_BASE_URL}/oauth/token',
+        f'{API_V0}/oauth/token',
         json=token_accepted_json,
         status_code=200,
     )
     requests_mock.get(
-        f'{API_V0_BASE_URL}/users/api_token',
+        f'{API_V0}/users/api_token',
         [MOCK_JWT_504, MOCK_JWT_200],
     )
 
@@ -123,17 +124,18 @@ def test_get_access_token__mixed_args_and_envars(requests_mock):
     assert token == JWT_API_TOKEN
 
 
+@pytest.mark.enable_client_session
 @patch.dict(os.environ, {}, clear=True)
 @patch('pyinaturalist.auth.get_keyring_credentials', return_value=MOCK_CREDS_OAUTH)
-@patch.object(Session, 'post')
+@patch.object(ClientSession, 'post')
 def test_get_access_token__keyring(mock_post, mock_keyring_credentials, requests_mock):
     requests_mock.post(
-        f'{API_V0_BASE_URL}/oauth/token',
+        f'{API_V0}/oauth/token',
         json=token_accepted_json,
         status_code=200,
     )
     requests_mock.get(
-        f'{API_V0_BASE_URL}/users/api_token',
+        f'{API_V0}/users/api_token',
         [MOCK_JWT_504, MOCK_JWT_200],
     )
 
@@ -154,7 +156,7 @@ def test_get_access_token__missing_creds(mock_get_jwt, mock_keyring_credentials)
 @patch('pyinaturalist.auth._get_jwt', return_value=NOT_CACHED_RESPONSE)
 def test_get_access_token__invalid_creds(mock_get_jwt, requests_mock):
     requests_mock.post(
-        f'{API_V0_BASE_URL}/oauth/token',
+        f'{API_V0}/oauth/token',
         json=token_rejected_json,
         status_code=401,
     )
diff --git a/test/test_converters.py b/test/test_converters.py
index 1d5e471..f0bb938 100644
--- a/test/test_converters.py
+++ b/test/test_converters.py
@@ -10,7 +10,6 @@ from pyinaturalist.converters import (
     convert_lat_long,
     convert_observation_histogram,
     convert_observation_timestamps,
-    convert_offset,
     ensure_file_obj,
     ensure_list,
     format_dimensions,
@@ -100,87 +99,46 @@ def test_format_histogram__int_keys():
 
 
 @pytest.mark.parametrize(
-    'observed_on, created_at, tz_offset, tz_name, expected_observed, expected_created',
+    'observed_on, created_at, expected_observed, expected_created',
     [
         (
-            'Sat Sep 26 2020 12:09:51 GMT-0700 (PDT)',
-            None,
-            '-07:00',
+            'Sat Sep 26 2020 12:09:51 -07:00',
             None,
             datetime(2020, 9, 26, 12, 9, 51, tzinfo=tzoffset(None, -25200)),
             None,
         ),
         (
-            '2020-09-27 9:20:02 AM PST',
-            '2020-10-01',
-            'GMT-08:00',
-            'PST',
-            datetime(2020, 9, 27, 9, 20, 2, tzinfo=tzoffset('PST', -28800)),
-            datetime(2020, 10, 1, tzinfo=tzoffset('PST', -28800)),
+            '2020-09-27T9:20:02-08:00',
+            '2020-10-01T00:00-08:00',
+            datetime(2020, 9, 27, 9, 20, 2, tzinfo=tzoffset(None, -28800)),
+            datetime(2020, 10, 1, tzinfo=tzoffset(None, -28800)),
         ),
         (
-            'Dec 04 2020 21:00:00',
-            'Dec 10 2020',
-            'UTC +02:00',
-            'EET',
-            datetime(2020, 12, 4, 21, 0, 0, tzinfo=tzoffset('EET', 7200)),
-            datetime(2020, 12, 10, tzinfo=tzoffset('EET', 7200)),
+            '2020-12-04T21:00+02:00',
+            '2020-12-10T00:00+02:00',
+            datetime(2020, 12, 4, 21, 0, 0, tzinfo=tzoffset(None, 7200)),
+            datetime(2020, 12, 10, tzinfo=tzoffset(None, 7200)),
         ),
         (
             None,
-            'Dec 10 2020',
-            'UTC +02:00',
-            'EET',
+            '2020-12-10T00:00+02:00',
             None,
-            datetime(2020, 12, 10, tzinfo=tzoffset('EET', 7200)),
+            datetime(2020, 12, 10, tzinfo=tzoffset(None, 7200)),
         ),
     ],
 )
 def test_convert_observation_timestamps(
-    observed_on, created_at, tz_offset, tz_name, expected_observed, expected_created
+    observed_on, created_at, expected_observed, expected_created
 ):
     observation = {
-        'created_at_details': {'date': created_at},
-        'observed_on_string': observed_on,
-        'time_zone_offset': tz_offset,
-        'observed_time_zone': tz_name,
+        'created_at': created_at,
+        'time_observed_at': observed_on,
     }
     converted = convert_observation_timestamps(observation)
-    assert converted['observed_on'] == expected_observed
+    assert converted.get('observed_on') == expected_observed
     assert converted['created_at'] == expected_created
 
 
-@pytest.mark.parametrize(
-    'datetime_obj, tz_offset, tz_name, expected_date',
-    [
-        (
-            datetime(2020, 9, 26, 12, 9, 51),
-            '-07:00',
-            None,
-            datetime(2020, 9, 26, 12, 9, 51, tzinfo=tzoffset(None, -25200)),
-        ),
-        (
-            datetime(2020, 9, 27, 9, 20, 2),
-            'GMT-08:00',
-            'PST',
-            datetime(2020, 9, 27, 9, 20, 2, tzinfo=tzoffset('PST', -28800)),
-        ),
-        (
-            datetime(2020, 12, 4, 21, 0, 0),
-            'UTC +02:00',
-            'EET',
-            datetime(2020, 12, 4, 21, 0, 0, tzinfo=tzoffset('EET', 7200)),
-        ),
-    ],
-)
-def test_convert_offset(datetime_obj, tz_offset, tz_name, expected_date):
-    assert convert_offset(datetime_obj, tz_offset, tz_name) == expected_date
-
-
-def test_convert_offset__invalid_offset():
-    assert convert_offset(datetime(2020, 1, 1), 'invalid') is None
-
-
 @pytest.mark.parametrize(
     'input, expected_output',
     [(None, (0, 0)), ((1, 1), (1, 1)), ({"width": 1600, "height": 1200}, (1600, 1200))],
diff --git a/test/test_formatters.py b/test/test_formatters.py
index b20b6a1..b1fc037 100644
--- a/test/test_formatters.py
+++ b/test/test_formatters.py
@@ -8,7 +8,7 @@ from requests_cache import CachedResponse
 from rich.console import Console
 from rich.table import Table
 
-from pyinaturalist.constants import API_V0_BASE_URL
+from pyinaturalist.constants import API_V0
 from pyinaturalist.formatters import *
 from test.sample_data import *
 
@@ -76,7 +76,7 @@ def test_pprint(response):
 PRINTED_OBSERVATION = """
 Observation(
     id=16227955,
-    created_at='2018-09-05 00:00:00+01:00',
+    created_at='2018-09-05 14:31:08+02:00',
     captive=False,
     community_taxon_id=493595,
     description='',
@@ -90,7 +90,7 @@ Observation(
     num_identification_agreements=2,
     num_identification_disagreements=0,
     obscured=False,
-    observed_on='2018-09-05 14:06:00+01:00',
+    observed_on='2018-09-05 14:06:00+02:00',
     outlinks=[{'source': 'GBIF', 'url': 'http://www.gbif.org/occurrence/1914197587'}],
     owners_identification_from_vision=True,
     place_guess='54 rue des Badauds',
@@ -148,12 +148,12 @@ def test_pretty_print():
 def test_format_request():
     request = Request(
         method='GET',
-        url=API_V0_BASE_URL,
+        url=API_V0,
         headers={'Accept': 'application/json', 'Authorization': 'password123'},
         json={'client_secret': 'password123'},
     ).prepare()
     request_str = format_request(request)
-    assert API_V0_BASE_URL in request_str
+    assert API_V0 in request_str
     assert 'Accept: application/json' in request_str
     assert 'password123' not in request_str
 
diff --git a/test/test_models.py b/test/test_models.py
index 02ba5d9..9982140 100644
--- a/test/test_models.py
+++ b/test/test_models.py
@@ -248,7 +248,7 @@ def test_message__str():
 def test_observation__converters():
     obs = Observation.from_json(j_observation_2)
     utc = tzoffset('Etc/UTC', 0)
-    assert obs.created_at == datetime(2020, 8, 27, 0, 0, tzinfo=utc)
+    assert obs.created_at == datetime(2020, 8, 27, 18, 0, 51, tzinfo=utc)
     assert obs.observed_on == datetime(2020, 8, 27, 8, 57, 22, tzinfo=utc)
 
     assert isinstance(obs.comments[0], Comment) and obs.comments[0].id == 5326888
diff --git a/test/test_paginator.py b/test/test_paginator.py
index a12e61d..cc6f153 100644
--- a/test/test_paginator.py
+++ b/test/test_paginator.py
@@ -2,7 +2,7 @@ from copy import deepcopy
 
 import pytest
 
-from pyinaturalist.constants import API_V1_BASE_URL
+from pyinaturalist.constants import API_V1
 from pyinaturalist.models import Observation
 from pyinaturalist.paginator import Paginator, WrapperPaginator
 from pyinaturalist.v1 import get_observations
@@ -13,7 +13,7 @@ def test_iter(requests_mock):
     page_1 = deepcopy(SAMPLE_DATA['get_observations_node_page1'])
     page_1['total_results'] = 3
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations',
+        f'{API_V1}/observations',
         [
             {'json': page_1, 'status_code': 200},
             {'json': page_1, 'status_code': 200},
@@ -30,7 +30,7 @@ def test_iter(requests_mock):
 
 def test_iter__with_limit(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations',
+        f'{API_V1}/observations',
         [
             {'json': SAMPLE_DATA['get_observations_node_page1'], 'status_code': 200},
             {'json': SAMPLE_DATA['get_observations_node_page2'], 'status_code': 200},
@@ -45,7 +45,7 @@ def test_iter__with_limit(requests_mock):
 @pytest.mark.asyncio
 async def test_async_iter(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations',
+        f'{API_V1}/observations',
         [
             {'json': SAMPLE_DATA['get_observations_node_page1'], 'status_code': 200},
             {'json': SAMPLE_DATA['get_observations_node_page2'], 'status_code': 200},
@@ -61,7 +61,7 @@ async def test_async_iter(requests_mock):
 
 def test_count(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations?per_page=0', json={'results': [], 'total_results': 50}
+        f'{API_V1}/observations?per_page=0', json={'results': [], 'total_results': 50}
     )
 
     paginator = Paginator(get_observations, Observation, q='asdf')
diff --git a/test/test_session.py b/test/test_session.py
index a97914d..d1ad2a7 100644
--- a/test/test_session.py
+++ b/test/test_session.py
@@ -1,5 +1,5 @@
 from time import sleep
-from unittest.mock import MagicMock, patch
+from unittest.mock import patch
 
 import pytest
 from requests import Request
@@ -16,7 +16,6 @@ from pyinaturalist.session import (
     get_refresh_params,
     post,
     put,
-    request,
 )
 
 
@@ -40,20 +39,11 @@ def test_http_methods(mock_send, mock_format, http_func, http_method):
 @patch('pyinaturalist.session.Session.send')
 def test_request_headers(mock_send, mock_format):
     """Test that the request() wrapper passes along expected headers"""
-    request('GET', 'https://url', access_token='token')
+    ClientSession().request('GET', 'https://url', access_token='token')
     request_obj = mock_send.call_args[0][0]
     assert request_obj.headers['Authorization'] == 'Bearer token'
 
 
-@patch('pyinaturalist.session.format_response')
-@patch('pyinaturalist.session.ClientSession')
-def test_request_session(mock_ClientSession, mock_format):
-    mock_session = MagicMock()
-    request('GET', 'https://url', session=mock_session)
-    mock_session.send.assert_called()
-    mock_ClientSession.assert_not_called()
-
-
 # Test relevant combinations of dry-run settings and HTTP methods
 @pytest.mark.parametrize(
     'enabled_env, write_only_env, method, expected_real_request',
@@ -83,7 +73,7 @@ def test_request_session(mock_ClientSession, mock_format):
 )
 @patch('pyinaturalist.session.format_response')
 @patch('pyinaturalist.session.getenv')
-@patch('pyinaturalist.session.Session.send')
+@patch.object(ClientSession, 'send')
 def test_request_dry_run(
     mock_send,
     mock_getenv,
@@ -102,7 +92,7 @@ def test_request_dry_run(
     mock_getenv.side_effect = env_vars.__getitem__
 
     # Mock constants and run request
-    response = request(method, 'http://url')
+    response = ClientSession().request(method, 'http://url')
 
     # Verify that the request was or wasn't mocked based on settings
     if expected_real_request:
@@ -115,7 +105,7 @@ def test_request_dry_run(
 
 @patch('pyinaturalist.session.Session.send')
 def test_request_dry_run_kwarg(mock_request):
-    response = request('GET', 'http://url', dry_run=True)
+    response = ClientSession().request('GET', 'http://url', dry_run=True)
     assert response == MOCK_RESPONSE
     assert mock_request.call_count == 0
 
@@ -125,7 +115,7 @@ def test_request_dry_run_disabled(requests_mock):
     real_response = {'results': ['response object']}
     requests_mock.get('http://url', json={'results': ['response object']}, status_code=200)
 
-    assert request('GET', 'http://url').json() == real_response
+    assert ClientSession().request('GET', 'http://url').json() == real_response
 
 
 def test_session__cache_file():
diff --git a/test/v0/test_observation_fields_v0.py b/test/v0/test_observation_fields_v0.py
index e15e29e..2e32475 100644
--- a/test/v0/test_observation_fields_v0.py
+++ b/test/v0/test_observation_fields_v0.py
@@ -2,7 +2,7 @@ from datetime import datetime
 
 from dateutil.tz import tzutc
 
-from pyinaturalist.constants import API_V0_BASE_URL
+from pyinaturalist.constants import API_V0
 from pyinaturalist.v0 import get_observation_fields, put_observation_field_values
 from test.sample_data import SAMPLE_DATA
 
@@ -10,7 +10,7 @@ from test.sample_data import SAMPLE_DATA
 def test_get_observation_fields(requests_mock):
     """get_observation_fields() work as expected (basic use)"""
     requests_mock.get(
-        f'{API_V0_BASE_URL}/observation_fields.json?q=sex',
+        f'{API_V0}/observation_fields.json?q=sex',
         json=SAMPLE_DATA['get_observation_fields_page2'],
         status_code=200,
     )
@@ -29,7 +29,7 @@ def test_get_observation_fields__all_pages(requests_mock):
     page_2_json_response = SAMPLE_DATA['get_observation_fields_page2']
 
     requests_mock.get(
-        f'{API_V0_BASE_URL}/observation_fields.json',
+        f'{API_V0}/observation_fields.json',
         [
             {'json': page_1_json_response, 'status_code': 200},
             {'json': page_2_json_response, 'status_code': 200},
@@ -47,7 +47,7 @@ def test_get_observation_fields__all_pages(requests_mock):
 
 def test_put_observation_field_values(requests_mock):
     requests_mock.put(
-        f'{API_V0_BASE_URL}/observation_field_values/31',
+        f'{API_V0}/observation_field_values/31',
         json=SAMPLE_DATA['post_put_observation_field_value'],
         status_code=200,
     )
diff --git a/test/v0/test_observations_v0.py b/test/v0/test_observations_v0.py
index 1f0bb75..eabae22 100644
--- a/test/v0/test_observations_v0.py
+++ b/test/v0/test_observations_v0.py
@@ -6,7 +6,7 @@ import pytest
 from dateutil.tz import tzutc
 from requests import HTTPError
 
-from pyinaturalist.constants import API_V0_BASE_URL, OBSERVATION_FORMATS
+from pyinaturalist.constants import API_V0, OBSERVATION_FORMATS
 from pyinaturalist.exceptions import ObservationNotFound
 from pyinaturalist.v0 import (
     create_observation,
@@ -31,7 +31,7 @@ def test_get_observations(response_format, requests_mock):
     key = 'json' if response_format == 'json' else 'text'
 
     requests_mock.get(
-        f'{API_V0_BASE_URL}/observations.{response_format}',
+        f'{API_V0}/observations.{response_format}',
         status_code=200,
         **{key: mock_response},
     )
@@ -60,7 +60,7 @@ def test_get_observations__invalid_format(response_format):
 
 def test_create_observation(requests_mock):
     requests_mock.post(
-        f'{API_V0_BASE_URL}/observations.json',
+        f'{API_V0}/observations.json',
         json=load_sample_data('create_observation_result.json'),
         status_code=200,
     )
@@ -79,7 +79,7 @@ def test_create_observation__with_datetime(post, requests_mock):
     prepare_request().
     """
     requests_mock.post(
-        f'{API_V0_BASE_URL}/observations.json',
+        f'{API_V0}/observations.json',
         json=load_sample_data('create_observation_result.json'),
         status_code=200,
     )
@@ -119,7 +119,7 @@ def test_create_observation_fail(requests_mock):
     }
 
     requests_mock.post(
-        f'{API_V0_BASE_URL}/observations.json',
+        f'{API_V0}/observations.json',
         json=load_sample_data('create_observation_fail.json'),
         status_code=422,
     )
@@ -132,7 +132,7 @@ def test_create_observation_fail(requests_mock):
 
 def test_update_observation(requests_mock):
     requests_mock.put(
-        f'{API_V0_BASE_URL}/observations/17932425.json',
+        f'{API_V0}/observations/17932425.json',
         json=load_sample_data('update_observation_result.json'),
         status_code=200,
     )
@@ -174,7 +174,7 @@ def test_update_nonexistent_observation(requests_mock):
     'observation does not exist'
     """
     requests_mock.put(
-        f'{API_V0_BASE_URL}/observations/999999999.json',
+        f'{API_V0}/observations/999999999.json',
         json={'error': 'Cette observation n’existe plus.'},
         status_code=410,
     )
@@ -193,7 +193,7 @@ def test_update_nonexistent_observation(requests_mock):
 def test_update_observation_not_mine(requests_mock):
     """When we try to update the obs of another user, iNat returns an error 410 with 'obs does not longer exists'."""
     requests_mock.put(
-        f'{API_V0_BASE_URL}/observations/16227955.json',
+        f'{API_V0}/observations/16227955.json',
         json={'error': 'Cette observation n’existe plus.'},
         status_code=410,
     )
@@ -211,7 +211,7 @@ def test_update_observation_not_mine(requests_mock):
 
 def test_upload_photos(requests_mock):
     requests_mock.post(
-        f'{API_V0_BASE_URL}/observation_photos',
+        f'{API_V0}/observation_photos',
         json=load_sample_data('post_observation_photos.json'),
         status_code=200,
     )
@@ -224,7 +224,7 @@ def test_upload_photos(requests_mock):
 
 def test_upload_sounds(requests_mock):
     requests_mock.post(
-        f'{API_V0_BASE_URL}/observation_sounds',
+        f'{API_V0}/observation_sounds',
         json=load_sample_data('post_observation_sounds.json'),
         status_code=200,
     )
@@ -236,14 +236,14 @@ def test_upload_sounds(requests_mock):
 
 
 def test_delete_observation(requests_mock):
-    requests_mock.delete(f'{API_V0_BASE_URL}/observations/24774619.json', status_code=200)
+    requests_mock.delete(f'{API_V0}/observations/24774619.json', status_code=200)
     response = delete_observation(observation_id=24774619, access_token='valid token')
     assert response is None
 
 
 def test_delete_unexisting_observation(requests_mock):
     """ObservationNotFound is raised if the observation doesn't exists"""
-    requests_mock.delete(f'{API_V0_BASE_URL}/observations/24774619.json', status_code=404)
+    requests_mock.delete(f'{API_V0}/observations/24774619.json', status_code=404)
 
     with pytest.raises(ObservationNotFound):
         delete_observation(observation_id=24774619, access_token='valid token')
diff --git a/test/v1/test_controlled_terms.py b/test/v1/test_controlled_terms.py
index 78e1a15..8c05d35 100644
--- a/test/v1/test_controlled_terms.py
+++ b/test/v1/test_controlled_terms.py
@@ -1,11 +1,11 @@
-from pyinaturalist.constants import API_V1_BASE_URL
+from pyinaturalist.constants import API_V1
 from pyinaturalist.v1 import get_controlled_terms
 from test.conftest import load_sample_data
 
 
 def test_get_controlled_terms(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/controlled_terms',
+        f'{API_V1}/controlled_terms',
         json=load_sample_data('get_controlled_terms.json'),
         status_code=200,
     )
@@ -24,7 +24,7 @@ def test_get_controlled_terms(requests_mock):
 
 def test_get_controlled_terms_for_taxon(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/controlled_terms/for_taxon',
+        f'{API_V1}/controlled_terms/for_taxon',
         json=load_sample_data('get_controlled_terms_for_taxon.json'),
         status_code=200,
     )
diff --git a/test/v1/test_identifications.py b/test/v1/test_identifications.py
index 5dd39ff..31f9850 100644
--- a/test/v1/test_identifications.py
+++ b/test/v1/test_identifications.py
@@ -2,14 +2,14 @@ from datetime import datetime
 
 from dateutil.tz import tzoffset
 
-from pyinaturalist.constants import API_V1_BASE_URL
+from pyinaturalist.constants import API_V1
 from pyinaturalist.v1 import get_identifications, get_identifications_by_id
 from test.conftest import load_sample_data
 
 
 def test_get_identifications(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/identifications',
+        f'{API_V1}/identifications',
         json=load_sample_data('get_identifications.json'),
         status_code=200,
     )
@@ -30,7 +30,7 @@ def test_get_identifications_by_id(requests_mock):
     mock_response = load_sample_data('get_identifications.json')
     mock_response['results'] = [mock_response['results'][0]]
     requests_mock.get(
-        f'{API_V1_BASE_URL}/identifications/155554373',
+        f'{API_V1}/identifications/155554373',
         json=mock_response,
         status_code=200,
     )
diff --git a/test/v1/test_messages.py b/test/v1/test_messages.py
index 1c12efd..31ed9f6 100644
--- a/test/v1/test_messages.py
+++ b/test/v1/test_messages.py
@@ -2,7 +2,7 @@ from datetime import datetime
 
 from dateutil.tz import tzoffset
 
-from pyinaturalist.constants import API_V1_BASE_URL
+from pyinaturalist.constants import API_V1
 from pyinaturalist.v1 import get_message_by_id, get_messages, get_unread_meassage_count
 from test.sample_data import SAMPLE_DATA
 
@@ -10,7 +10,7 @@ from test.sample_data import SAMPLE_DATA
 def test_get_message_by_id(requests_mock):
     message_id = 12345
     requests_mock.get(
-        f'{API_V1_BASE_URL}/messages/{message_id}',
+        f'{API_V1}/messages/{message_id}',
         json=SAMPLE_DATA['get_messages'],
         status_code=200,
     )
@@ -26,7 +26,7 @@ def test_get_message_by_id(requests_mock):
 
 def test_get_messages(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/messages',
+        f'{API_V1}/messages',
         json=SAMPLE_DATA['get_messages'],
         status_code=200,
     )
@@ -42,7 +42,7 @@ def test_get_messages(requests_mock):
 
 def test_get_messages__threads(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/messages',
+        f'{API_V1}/messages',
         json=SAMPLE_DATA['get_messages'],
         status_code=200,
     )
@@ -53,7 +53,7 @@ def test_get_messages__threads(requests_mock):
 
 def test_get_unread_meassage_count(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/messages/unread',
+        f'{API_V1}/messages/unread',
         json={'count': 12},
         status_code=200,
     )
@@ -63,7 +63,7 @@ def test_get_unread_meassage_count(requests_mock):
 
 def test_get_unread_meassage_count__invalid(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/messages/unread',
+        f'{API_V1}/messages/unread',
         json={'results': 'invalid'},
         status_code=200,
     )
diff --git a/test/v1/test_node_api.py b/test/v1/test_node_api.py
index 5f92762..ea74e98 100644
--- a/test/v1/test_node_api.py
+++ b/test/v1/test_node_api.py
@@ -2,7 +2,7 @@ from datetime import datetime
 
 from dateutil.tz import tzutc
 
-from pyinaturalist.constants import API_V1_BASE_URL
+from pyinaturalist.constants import API_V1
 from pyinaturalist.v1 import get_user_by_id, get_users_autocomplete
 from test.conftest import load_sample_data
 
@@ -10,7 +10,7 @@ from test.conftest import load_sample_data
 def test_get_user_by_id(requests_mock):
     user_id = 1
     requests_mock.get(
-        f'{API_V1_BASE_URL}/users/{user_id}',
+        f'{API_V1}/users/{user_id}',
         json=load_sample_data('get_user_by_id.json'),
         status_code=200,
     )
@@ -23,7 +23,7 @@ def test_get_user_by_id(requests_mock):
 
 def test_get_users_autocomplete(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/users/autocomplete',
+        f'{API_V1}/users/autocomplete',
         json=load_sample_data('get_users_autocomplete.json'),
         status_code=200,
     )
diff --git a/test/v1/test_observation_fields.py b/test/v1/test_observation_fields.py
index 7a37784..2d52482 100644
--- a/test/v1/test_observation_fields.py
+++ b/test/v1/test_observation_fields.py
@@ -1,11 +1,11 @@
-from pyinaturalist.constants import API_V1_BASE_URL
+from pyinaturalist.constants import API_V1
 from pyinaturalist.v1 import delete_observation_field, set_observation_field
 from test.sample_data import SAMPLE_DATA
 
 
 def test_set_observation_field(requests_mock):
     requests_mock.post(
-        f'{API_V1_BASE_URL}/observation_field_values',
+        f'{API_V1}/observation_field_values',
         json=SAMPLE_DATA['post_put_observation_field_value'],
         status_code=200,
     )
@@ -25,7 +25,7 @@ def test_set_observation_field(requests_mock):
 
 def test_delete_observation_field(requests_mock):
     requests_mock.delete(
-        f'{API_V1_BASE_URL}/observation_field_values/1234',
+        f'{API_V1}/observation_field_values/1234',
         json={'errors': []},
         status_code=200,
     )
diff --git a/test/v1/test_observations.py b/test/v1/test_observations.py
index 4435319..b23077d 100644
--- a/test/v1/test_observations.py
+++ b/test/v1/test_observations.py
@@ -6,7 +6,7 @@ from unittest.mock import patch
 import pytest
 from dateutil.tz import tzoffset, tzutc
 
-from pyinaturalist.constants import API_V1_BASE_URL
+from pyinaturalist.constants import API_V1
 from pyinaturalist.exceptions import ObservationNotFound
 from pyinaturalist.v1 import (
     create_observation,
@@ -28,7 +28,7 @@ from test.sample_data import SAMPLE_DATA, j_taxon_summary_1_conserved, j_taxon_s
 
 def test_get_observation(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations',
+        f'{API_V1}/observations',
         json=SAMPLE_DATA['get_observation'],
         status_code=200,
     )
@@ -42,7 +42,7 @@ def test_get_observation(requests_mock):
 
 def test_get_observation_histogram(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations/histogram',
+        f'{API_V1}/observations/histogram',
         json=SAMPLE_DATA['get_observation_histogram_month'],
         status_code=200,
     )
@@ -57,7 +57,7 @@ def test_get_observation_histogram(requests_mock):
 
 def test_get_observations(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations',
+        f'{API_V1}/observations',
         json=SAMPLE_DATA['get_observations_node_page1'],
         status_code=200,
     )
@@ -85,7 +85,7 @@ def test_get_observations(requests_mock):
 
 def test_get_observations__all_pages(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations',
+        f'{API_V1}/observations',
         [
             {'json': SAMPLE_DATA['get_observations_node_page1'], 'status_code': 200},
             {'json': SAMPLE_DATA['get_observations_node_page2'], 'status_code': 200},
@@ -98,7 +98,7 @@ def test_get_observations__all_pages(requests_mock):
 
 def test_get_observation__non_existent(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations',
+        f'{API_V1}/observations',
         json=SAMPLE_DATA['get_nonexistent_observation'],
         status_code=200,
     )
@@ -108,7 +108,7 @@ def test_get_observation__non_existent(requests_mock):
 
 def test_get_observation_identifiers(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations/identifiers',
+        f'{API_V1}/observations/identifiers',
         json=SAMPLE_DATA['get_observation_identifiers_node_page1'],
         status_code=200,
     )
@@ -124,7 +124,7 @@ def test_get_observation_identifiers(requests_mock):
 
 def test_get_observation_observers(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations/observers',
+        f'{API_V1}/observations/observers',
         json=SAMPLE_DATA['get_observation_observers_node_page1'],
         status_code=200,
     )
@@ -140,7 +140,7 @@ def test_get_observation_observers(requests_mock):
 
 def test_get_observation_popular_field_values(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations/popular_field_values',
+        f'{API_V1}/observations/popular_field_values',
         json=SAMPLE_DATA['get_observation_popular_field_values'],
         status_code=200,
     )
@@ -155,7 +155,7 @@ def test_get_observation_popular_field_values(requests_mock):
 
 def test_get_observation_species_counts(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations/species_counts',
+        f'{API_V1}/observations/species_counts',
         json=SAMPLE_DATA['get_observation_species_counts'],
         status_code=200,
     )
@@ -169,7 +169,7 @@ def test_get_observation_species_counts(requests_mock):
 
 def test_get_observation_species_counts__all_pages(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations/species_counts',
+        f'{API_V1}/observations/species_counts',
         [
             {
                 'json': SAMPLE_DATA['get_all_observation_species_counts_page1'],
@@ -203,7 +203,7 @@ def test_get_observation_species_counts__invalid_multiple_choice_params():
 
 def test_get_observation_taxonomy(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations/taxonomy',
+        f'{API_V1}/observations/taxonomy',
         json=SAMPLE_DATA['get_observation_taxonomy'],
         status_code=200,
     )
@@ -218,7 +218,7 @@ def test_get_observation_taxonomy(requests_mock):
 
 def test_get_observation_taxon_summary__with_conservation_status(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations/89238647/taxon_summary',
+        f'{API_V1}/observations/89238647/taxon_summary',
         json=j_taxon_summary_1_conserved,
         status_code=200,
     )
@@ -232,7 +232,7 @@ def test_get_observation_taxon_summary__with_conservation_status(requests_mock):
 
 def test_get_observation_taxon_summary__with_listed_taxon(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/observations/7849808/taxon_summary',
+        f'{API_V1}/observations/7849808/taxon_summary',
         json=j_taxon_summary_2_listed,
         status_code=200,
     )
@@ -247,7 +247,7 @@ def test_get_observation_taxon_summary__with_listed_taxon(requests_mock):
 
 def test_create_observation(requests_mock):
     requests_mock.post(
-        f'{API_V1_BASE_URL}/observations',
+        f'{API_V1}/observations',
         json=SAMPLE_DATA['create_observation_v1'],
         status_code=200,
     )
@@ -260,7 +260,7 @@ def test_create_observation(requests_mock):
 
 
 @patch('pyinaturalist.v1.observations.upload')
-@patch('pyinaturalist.v1.observations.post_v1')
+@patch('pyinaturalist.v1.observations.post')
 def test_create_observation__with_files(mock_post, mock_upload):
     create_observation(
         access_token='token',
@@ -278,7 +278,7 @@ def test_create_observation__with_files(mock_post, mock_upload):
 
 def test_update_observation(requests_mock):
     requests_mock.put(
-        f'{API_V1_BASE_URL}/observations/17932425',
+        f'{API_V1}/observations/17932425',
         json=SAMPLE_DATA['update_observation_result'],
         status_code=200,
     )
@@ -293,7 +293,7 @@ def test_update_observation(requests_mock):
 
 
 @patch('pyinaturalist.v1.observations.upload')
-@patch('pyinaturalist.v1.observations.put_v1')
+@patch('pyinaturalist.v1.observations.put')
 def test_update_observation__with_photos(mock_put, mock_upload):
     update_observation(1234, access_token='token', photos='photo.jpg')
 
@@ -306,7 +306,7 @@ def test_update_observation__with_photos(mock_put, mock_upload):
 
 
 @patch('pyinaturalist.v1.observations.get_observation')
-@patch('pyinaturalist.v1.observations.put_v1')
+@patch('pyinaturalist.v1.observations.put')
 def test_update_observation__with_photo_ids(mock_put, mock_get_observation):
     mock_get_observation.return_value = {'photos': [{'id': 1234}]}
     update_observation(1234, access_token='token', photo_ids=5678)
@@ -316,7 +316,7 @@ def test_update_observation__with_photo_ids(mock_put, mock_get_observation):
     assert payload['local_photos'] == {'1234': [1234, 5678]}
 
 
-@patch('pyinaturalist.v1.observations.put_v1')
+@patch('pyinaturalist.v1.observations.put')
 def test_update_observation__remove_existing_photos(mock_put):
     update_observation(1234, access_token='token', ignore_photos=False)
 
@@ -326,12 +326,12 @@ def test_update_observation__remove_existing_photos(mock_put):
 
 def test_upload(requests_mock):
     requests_mock.post(
-        f'{API_V1_BASE_URL}/observation_photos',
+        f'{API_V1}/observation_photos',
         json=SAMPLE_DATA['post_observation_photos'],
         status_code=200,
     )
     requests_mock.post(
-        f'{API_V1_BASE_URL}/observation_sounds',
+        f'{API_V1}/observation_sounds',
         json=SAMPLE_DATA['post_observation_sounds'],
         status_code=200,
     )
@@ -353,6 +353,6 @@ def test_upload__with_photo_ids(mock_update_observation):
 
 
 def test_delete_observation(requests_mock):
-    requests_mock.delete(f'{API_V1_BASE_URL}/observations/24774619', status_code=200)
+    requests_mock.delete(f'{API_V1}/observations/24774619', status_code=200)
     response = delete_observation(observation_id=24774619, access_token='valid token')
     assert response is None
diff --git a/test/v1/test_places.py b/test/v1/test_places.py
index 8a4c944..9188baf 100644
--- a/test/v1/test_places.py
+++ b/test/v1/test_places.py
@@ -1,13 +1,13 @@
 import pytest
 
-from pyinaturalist.constants import API_V1_BASE_URL
+from pyinaturalist.constants import API_V1
 from pyinaturalist.v1 import get_places_autocomplete, get_places_by_id, get_places_nearby
 from test.sample_data import SAMPLE_DATA
 
 
 def test_get_places_by_id(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/places/93735,89191',
+        f'{API_V1}/places/93735,89191',
         json=SAMPLE_DATA['get_places_by_id'],
         status_code=200,
     )
@@ -31,7 +31,7 @@ def test_get_places_by_id__invalid_inputs(place_id):
 
 def test_get_places_nearby(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/places/nearby',
+        f'{API_V1}/places/nearby',
         json=SAMPLE_DATA['get_places_nearby'],
         status_code=200,
     )
@@ -51,7 +51,7 @@ def test_get_places_nearby(requests_mock):
 
 def test_get_places_autocomplete(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/places/autocomplete',
+        f'{API_V1}/places/autocomplete',
         json=SAMPLE_DATA['get_places_autocomplete'],
         status_code=200,
     )
@@ -71,7 +71,7 @@ def test_get_places_autocomplete__all_pages(requests_mock):
     page_1 = {'total_results': 25, 'results': [{'id': i} for i in range(20)]}
     page_2 = {'total_results': 25, 'results': [{'id': i} for i in range(15, 25)]}
     requests_mock.get(
-        f'{API_V1_BASE_URL}/places/autocomplete',
+        f'{API_V1}/places/autocomplete',
         [
             {'json': page_1, 'status_code': 200},
             {'json': page_2, 'status_code': 200},
@@ -88,7 +88,7 @@ def test_get_places_autocomplete__single_page(requests_mock):
     page_1 = {'total_results': 20, 'results': [{'id': i} for i in range(20)]}
     page_2 = {'total_results': 20, 'results': [{'id': i} for i in range(15, 25)]}
     requests_mock.get(
-        f'{API_V1_BASE_URL}/places/autocomplete',
+        f'{API_V1}/places/autocomplete',
         [
             {'json': page_1, 'status_code': 200},
             {'json': page_2, 'status_code': 200},
diff --git a/test/v1/test_posts.py b/test/v1/test_posts.py
index 410c230..c066d76 100644
--- a/test/v1/test_posts.py
+++ b/test/v1/test_posts.py
@@ -2,14 +2,14 @@ from datetime import datetime
 
 from dateutil.tz import tzutc
 
-from pyinaturalist.constants import API_V1_BASE_URL
+from pyinaturalist.constants import API_V1
 from pyinaturalist.v1 import get_posts
 from test.conftest import load_sample_data
 
 
 def test_get_posts_from_login(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/posts?login=eduramirezh',
+        f'{API_V1}/posts?login=eduramirezh',
         json=load_sample_data('get_posts_login.json'),
         status_code=200,
     )
@@ -25,7 +25,7 @@ def test_get_posts_from_login(requests_mock):
 
 def test_get_posts_from_project(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/posts?project_id=100',
+        f'{API_V1}/posts?project_id=100',
         json=load_sample_data('get_posts_project.json'),
         status_code=200,
     )
diff --git a/test/v1/test_projects.py b/test/v1/test_projects.py
index 83a5701..6bfb81c 100644
--- a/test/v1/test_projects.py
+++ b/test/v1/test_projects.py
@@ -3,7 +3,7 @@ from unittest.mock import patch
 
 from dateutil.tz import tzutc
 
-from pyinaturalist.constants import API_V1_BASE_URL
+from pyinaturalist.constants import API_V1
 from pyinaturalist.v1 import (
     add_project_observation,
     add_project_users,
@@ -18,7 +18,7 @@ from test.sample_data import SAMPLE_DATA
 
 def test_get_projects(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/projects',
+        f'{API_V1}/projects',
         json=SAMPLE_DATA['get_projects'],
         status_code=200,
     )
@@ -37,7 +37,7 @@ def test_get_projects(requests_mock):
 
 def test_get_projects_by_id(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/projects/8348,6432',
+        f'{API_V1}/projects/8348,6432',
         json=SAMPLE_DATA['get_projects_by_id'],
         status_code=200,
     )
@@ -55,7 +55,7 @@ def test_get_projects_by_id(requests_mock):
 
 def test_add_project_observation(requests_mock):
     requests_mock.post(
-        f'{API_V1_BASE_URL}/project_observations',
+        f'{API_V1}/project_observations',
         json=SAMPLE_DATA['add_project_observation'],
         status_code=200,
     )
@@ -63,10 +63,10 @@ def test_add_project_observation(requests_mock):
     assert response['id'] == 54986584
 
 
-@patch('pyinaturalist.v1.projects.put_v1')
+@patch('pyinaturalist.v1.projects.put')
 def test_add_project_users(mock_put, requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/projects/1234',
+        f'{API_V1}/projects/1234',
         json=SAMPLE_DATA['get_projects'],
         status_code=200,
     )
@@ -79,7 +79,7 @@ def test_add_project_users(mock_put, requests_mock):
 
 def test_delete_project_observation(requests_mock):
     requests_mock.delete(
-        f'{API_V1_BASE_URL}/projects/1234/remove',
+        f'{API_V1}/projects/1234/remove',
         status_code=200,
     )
     response = delete_project_observation(
@@ -88,10 +88,10 @@ def test_delete_project_observation(requests_mock):
     assert response.status_code == 200
 
 
-@patch('pyinaturalist.v1.projects.put_v1')
+@patch('pyinaturalist.v1.projects.put')
 def test_delete_project_users(mock_put, requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/projects/1234',
+        f'{API_V1}/projects/1234',
         json=SAMPLE_DATA['get_projects'],
         status_code=200,
     )
@@ -103,7 +103,7 @@ def test_delete_project_users(mock_put, requests_mock):
     assert rules[1]['operand_id'] == 5678 and rules[1]['_destroy'] is True
 
 
-@patch('pyinaturalist.v1.projects.put_v1')
+@patch('pyinaturalist.v1.projects.put')
 def test_update_project(mock_put):
     update_project(1234, title='New Title', description='New Description')
 
diff --git a/test/v1/test_search.py b/test/v1/test_search.py
index d75ea7c..529a66e 100644
--- a/test/v1/test_search.py
+++ b/test/v1/test_search.py
@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from pyinaturalist.constants import API_V1_BASE_URL
+from pyinaturalist.constants import API_V1
 from pyinaturalist.v1 import search
 from test.conftest import load_sample_data
 
@@ -8,7 +8,7 @@ from test.conftest import load_sample_data
 def test_search(requests_mock):
     """Simulate /search results with one of each record type"""
     requests_mock.get(
-        f'{API_V1_BASE_URL}/search',
+        f'{API_V1}/search',
         json=load_sample_data('get_search.json'),
         status_code=200,
     )
diff --git a/test/v1/test_taxa.py b/test/v1/test_taxa.py
index 4aef880..9e8f848 100644
--- a/test/v1/test_taxa.py
+++ b/test/v1/test_taxa.py
@@ -3,7 +3,7 @@ from urllib.parse import urlencode
 
 import pytest
 
-from pyinaturalist.constants import API_V1_BASE_URL
+from pyinaturalist.constants import API_V1
 from pyinaturalist.v1 import get_taxa, get_taxa_autocomplete, get_taxa_by_id, get_taxa_map_layers
 from test.conftest import load_sample_data
 
@@ -15,7 +15,7 @@ CLASS_THOUGH_PHYLUM = ['class', 'superclass', 'subphylum', 'phylum']
 def test_get_taxa(requests_mock):
     params = {'q': 'vespi', 'rank': 'genus,subgenus,species'}
     requests_mock.get(
-        f'{API_V1_BASE_URL}/taxa?{urlencode(params)}',
+        f'{API_V1}/taxa?{urlencode(params)}',
         json=load_sample_data('get_taxa.json'),
         status_code=200,
     )
@@ -41,7 +41,7 @@ def test_get_taxa(requests_mock):
         ({'max_rank': 'species', 'rank': 'override_me'}, SPECIES_AND_LOWER),
     ],
 )
-@patch('pyinaturalist.v1.taxa.get_v1')
+@patch('pyinaturalist.v1.taxa.get')
 def test_get_taxa_by_rank_range(
     mock_get,
     params,
@@ -57,7 +57,7 @@ def test_get_taxa_by_rank_range(
 def test_get_taxa_by_id(requests_mock):
     taxon_id = 70118
     requests_mock.get(
-        f'{API_V1_BASE_URL}/taxa/{taxon_id}',
+        f'{API_V1}/taxa/{taxon_id}',
         json=load_sample_data('get_taxa_by_id.json'),
         status_code=200,
     )
@@ -80,7 +80,7 @@ def test_get_taxa_by_id__invalid_inputs(taxon_id):
 
 def test_get_taxa_autocomplete(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/taxa/autocomplete',
+        f'{API_V1}/taxa/autocomplete',
         json=load_sample_data('get_taxa_autocomplete.json'),
         status_code=200,
     )
@@ -99,7 +99,7 @@ def test_get_taxa_autocomplete(requests_mock):
 
 def test_get_taxa_map_layers(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/taxa/47588/map_layers',
+        f'{API_V1}/taxa/47588/map_layers',
         json=load_sample_data('get_taxa_map_layers.json'),
         status_code=200,
     )
diff --git a/test/v1/test_users.py b/test/v1/test_users.py
index 5f92762..ea74e98 100644
--- a/test/v1/test_users.py
+++ b/test/v1/test_users.py
@@ -2,7 +2,7 @@ from datetime import datetime
 
 from dateutil.tz import tzutc
 
-from pyinaturalist.constants import API_V1_BASE_URL
+from pyinaturalist.constants import API_V1
 from pyinaturalist.v1 import get_user_by_id, get_users_autocomplete
 from test.conftest import load_sample_data
 
@@ -10,7 +10,7 @@ from test.conftest import load_sample_data
 def test_get_user_by_id(requests_mock):
     user_id = 1
     requests_mock.get(
-        f'{API_V1_BASE_URL}/users/{user_id}',
+        f'{API_V1}/users/{user_id}',
         json=load_sample_data('get_user_by_id.json'),
         status_code=200,
     )
@@ -23,7 +23,7 @@ def test_get_user_by_id(requests_mock):
 
 def test_get_users_autocomplete(requests_mock):
     requests_mock.get(
-        f'{API_V1_BASE_URL}/users/autocomplete',
+        f'{API_V1}/users/autocomplete',
         json=load_sample_data('get_users_autocomplete.json'),
         status_code=200,
     )