Repository: niconoe/pyinaturalist (python)

Issue:
Drop support for Python 3.5
[Python 3.5 reached EOL on 2020-09-13](https://devguide.python.org/#status-of-python-branches). I think it would be reasonable to **keep pyinaturalist compatible with Python 3.5 through v0.11**. For v0.12+, we can remove 3.5 from the tox tests and start making use of Python 3.6 features: f-strings, type annotations for variables, etc.

Also see #32.

Pull request description:
Drop support for python 3.5
Closes #60 

Patch:
diff --git a/.travis.yml b/.travis.yml
index 19c3884..4287fbd 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -6,8 +6,6 @@ dist: xenial
 
 matrix:
   include:
-  # Note: Python 3.5 will reach EOL in September 2020
-  - python: "3.5"
   - python: "3.6"
   - python: "3.7"
   # Run a separate job for combined code quality checks & build tests
diff --git a/HISTORY.md b/HISTORY.md
index 85cc5de..9519961 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,5 +1,9 @@
 # History
 
+## 0.12.0 (TBD)
+
+* Dropped support for python 3.4
+
 ## 0.11.0 (2020-11-04)
 [See all Issues & PRs](https://github.com/niconoe/pyinaturalist/milestone/2?closed=1)
 
diff --git a/pyinaturalist/__init__.py b/pyinaturalist/__init__.py
index b4724cd..c7c5d63 100644
--- a/pyinaturalist/__init__.py
+++ b/pyinaturalist/__init__.py
@@ -8,7 +8,7 @@ __version__ = "0.12.0"
 # These are imported here so they can be set with pyinaturalist.<variable>
 from pyinaturalist.constants import DRY_RUN_ENABLED, DRY_RUN_WRITE_ONLY
 
-DEFAULT_USER_AGENT = "Pyinaturalist/{version}".format(version=__version__)
+DEFAULT_USER_AGENT = f"pyinaturalist/{__version__}"
 user_agent = DEFAULT_USER_AGENT
 
 
@@ -20,8 +20,9 @@ def get_prerelease_version(version: str) -> str:
     """
     if not (getenv("TRAVIS") == "true" and getenv("TRAVIS_BRANCH") == "dev"):
         return version
-    new_version = "{}-dev.{}".format(version, getenv("TRAVIS_BUILD_NUMBER", "0"))
-    getLogger(__name__).info("Using pre-release version: {}".format(new_version))
+    build_number = getenv("TRAVIS_BUILD_NUMBER", "0")
+    new_version = f"{version}-dev.{build_number}"
+    getLogger(__name__).info(f"Using pre-release version: {new_version}")
     return new_version
 
 
diff --git a/pyinaturalist/api_docs.py b/pyinaturalist/api_docs.py
index 1f9d3b4..de875b5 100644
--- a/pyinaturalist/api_docs.py
+++ b/pyinaturalist/api_docs.py
@@ -478,7 +478,7 @@ def _search_query(q: str = None, search_query: str = None):
 
 def _format_param_choices():
     return "\n".join(
-        ["  * {}: {}".format(param, choices) for param, choices in MULTIPLE_CHOICE_PARAMS.items()]
+        [f"  * {param}: {choices}" for param, choices in MULTIPLE_CHOICE_PARAMS.items()]
     )
 
 
diff --git a/pyinaturalist/api_requests.py b/pyinaturalist/api_requests.py
index 9c3113b..8400018 100644
--- a/pyinaturalist/api_requests.py
+++ b/pyinaturalist/api_requests.py
@@ -47,7 +47,7 @@ def request(
     ids: Union[str, List] = None,
     params: Dict = None,
     headers: Dict = None,
-    **kwargs
+    **kwargs,
 ) -> requests.Response:
     """Wrapper around :py:func:`requests.request` that supports dry-run mode and
     adds appropriate headers.
@@ -109,5 +109,5 @@ def env_to_bool(environment_variable: str) -> bool:
 
 def log_request(*args, **kwargs):
     """ Log all relevant information about an HTTP request """
-    kwargs_strs = ["{}={}".format(k, v) for k, v in kwargs.items()]
+    kwargs_strs = [f"{k}={v}" for k, v in kwargs.items()]
     logger.info("Request: {}".format(", ".join(list(args) + kwargs_strs)))
diff --git a/pyinaturalist/node_api.py b/pyinaturalist/node_api.py
index ee06bbe..bde3d4a 100644
--- a/pyinaturalist/node_api.py
+++ b/pyinaturalist/node_api.py
@@ -156,7 +156,7 @@ def get_all_observations(
         object returned by :py:func:`.get_observations()`.
     """
     kwargs = check_deprecated_params(params, **kwargs)
-    results = []  # type: List[JsonResponse]
+    results: List[JsonResponse] = []
     id_above = 0
     pagination_params = {
         **kwargs,
diff --git a/pyinaturalist/request_params.py b/pyinaturalist/request_params.py
index 4898315..3b184ea 100644
--- a/pyinaturalist/request_params.py
+++ b/pyinaturalist/request_params.py
@@ -205,7 +205,7 @@ def ensure_file_obj(photo: FileOrPath) -> BinaryIO:
     """Given a file objects or path, read it into a file-like object if it's a path"""
     if isinstance(photo, str):
         file_path = abspath(expanduser(photo))
-        logger.info("Reading from file: {}".format(file_path))
+        logger.info(f"Reading from file: {file_path}")
         with open(file_path, "rb") as f:
             return BytesIO(f.read())
     return photo
@@ -281,7 +281,7 @@ def validate_ids(ids: Any) -> str:
         :py:exc:`ValueError` if any values are not valid integers
     """
     if not is_int_list(ids):
-        raise ValueError("Invalid ID(s): {}; must specify integers only".format(ids))
+        raise ValueError(f"Invalid ID(s): {ids}; must specify integers only")
     return convert_list([int(id) for id in ensure_list(ids)])
 
 
diff --git a/pyinaturalist/rest_api.py b/pyinaturalist/rest_api.py
index 916957f..e9edc76 100644
--- a/pyinaturalist/rest_api.py
+++ b/pyinaturalist/rest_api.py
@@ -13,8 +13,6 @@ Functions
 from time import sleep
 from typing import Dict, Any, List, Union
 
-from urllib.parse import urljoin
-
 from pyinaturalist import api_docs as docs
 from pyinaturalist.constants import (
     THROTTLING_DELAY,
@@ -68,7 +66,7 @@ def get_access_token(
     }
 
     response = post(
-        "{base_url}/oauth/token".format(base_url=INAT_BASE_URL),
+        f"{INAT_BASE_URL}/oauth/token",
         json=payload,
         user_agent=user_agent,
     )
@@ -144,7 +142,7 @@ def get_observations(user_agent: str = None, **kwargs) -> Union[List, str]:
     validate_multiple_choice_param(kwargs, "order_by", REST_OBS_ORDER_BY_PROPERTIES)
 
     response = get(
-        urljoin(INAT_BASE_URL, "observations.{}".format(response_format)),
+        f"{INAT_BASE_URL}/observations.{response_format}",
         params=kwargs,
         user_agent=user_agent,
     )
@@ -180,7 +178,7 @@ def get_observation_fields(user_agent: str = None, **kwargs) -> ListResponse:
     """
     kwargs = check_deprecated_params(**kwargs)
     response = get(
-        "{base_url}/observation_fields.json".format(base_url=INAT_BASE_URL),
+        f"{INAT_BASE_URL}/observation_fields.json",
         params=kwargs,
         user_agent=user_agent,
     )
@@ -271,9 +269,7 @@ def put_observation_field_values(
     }
 
     response = put(
-        "{base_url}/observation_field_values/{id}".format(
-            base_url=INAT_BASE_URL, id=observation_field_id
-        ),
+        f"{INAT_BASE_URL}/observation_field_values/{observation_field_id}",
         access_token=access_token,
         user_agent=user_agent,
         json=payload,
@@ -344,7 +340,7 @@ def create_observation(
         kwargs["local_photos"] = ensure_file_objs(kwargs["local_photos"])
 
     response = post(
-        url="{base_url}/observations.json".format(base_url=INAT_BASE_URL),
+        url=f"{INAT_BASE_URL}/observations.json",
         json={"observation": kwargs},
         access_token=access_token,
         user_agent=user_agent,
@@ -367,7 +363,7 @@ def update_observation(
     params: RequestParams = None,
     access_token: str = None,
     user_agent: str = None,
-    **kwargs
+    **kwargs,
 ) -> ListResponse:
     """
     Update a single observation.
@@ -420,7 +416,7 @@ def update_observation(
         kwargs["ignore_photos"] = 1
 
     response = put(
-        url="{base_url}/observations/{id}.json".format(base_url=INAT_BASE_URL, id=observation_id),
+        url=f"{INAT_BASE_URL}/observations/{observation_id}.json",
         json={"observation": kwargs},
         access_token=access_token,
         user_agent=user_agent,
@@ -464,7 +460,7 @@ def add_photo_to_observation(
         Information about the newly created photo
     """
     response = post(
-        url="{base_url}/observation_photos".format(base_url=INAT_BASE_URL),
+        url=f"{INAT_BASE_URL}/observation_photos",
         access_token=access_token,
         data={"observation_photo[observation_id]": observation_id},
         files={"file": ensure_file_obj(photo)},
@@ -494,7 +490,7 @@ def delete_observation(observation_id: int, access_token: str = None, user_agent
         :py:exc:`requests.HTTPError` (403) if the observation belongs to another user
     """
     response = delete(
-        url="{base_url}/observations/{id}.json".format(base_url=INAT_BASE_URL, id=observation_id),
+        url=f"{INAT_BASE_URL}/observations/{observation_id}.json",
         access_token=access_token,
         user_agent=user_agent,
         headers={"Content-type": "application/json"},
diff --git a/setup.cfg b/setup.cfg
index 5457b4f..77c8f31 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -11,7 +11,6 @@ classifiers =
     License :: OSI Approved :: MIT License
     Natural Language :: English
     Programming Language :: Python :: 3
-    Programming Language :: Python :: 3.5
     Programming Language :: Python :: 3.6
     Programming Language :: Python :: 3.7
     Programming Language :: Python :: 3.8
diff --git a/tox.ini b/tox.ini
index a2080ad..0e3b77f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,7 +1,7 @@
 [tox]
 minversion = 3.15
 envlist =
-    py35, py36, py37, py38, py39,
+    py36, py37, py38, py39,
     coverage, mypy, style, dist-test, docs, lint
 
 [testenv]
@@ -29,13 +29,6 @@ commands =
     black --check .
     printf '\n\n'
 
-# Install only minimal dependencies for older interpreters
-[testenv:py35]
-deps =
-    pytest==4.6.9
-    requests-mock>=1.7
-extras =
-
 [testenv:docs]
 extras =
     test


Test patch:
diff --git a/test/manual_tests/obs_crud_test.py b/test/manual_tests/obs_crud_test.py
index 9a4dbcc..2db3bff 100644
--- a/test/manual_tests/obs_crud_test.py
+++ b/test/manual_tests/obs_crud_test.py
@@ -58,7 +58,7 @@ def create_test_obs(token):
         observation_fields={297: 1},
     )
     test_obs_id = response[0]["id"]
-    print("Created new observation: {}".format(test_obs_id))
+    print(f"Created new observation: {test_obs_id}")
 
     obs = get_observation(test_obs_id)
     print("Fetched new observation:")
@@ -74,7 +74,7 @@ def update_test_obs(test_obs_id, token):
     )
     photo_id = response.get("photo").get("id")
     assert photo_id
-    print("Added photo to observation: {}".format(photo_id))
+    print(f"Added photo to observation: {photo_id}")
     # pprint(response, indent=2)
 
     response = update_observation(
diff --git a/test/test_node_api.py b/test/test_node_api.py
index 7a4d559..2260e75 100644
--- a/test/test_node_api.py
+++ b/test/test_node_api.py
@@ -427,7 +427,7 @@ def test_user_agent(requests_mock):
         status_code=200,
     )
 
-    default_ua = "Pyinaturalist/{v}".format(v=pyinaturalist.__version__)
+    default_ua = f"pyinaturalist/{pyinaturalist.__version__}"
 
     # By default, we have a 'Pyinaturalist' user agent:
     get_observation(16227955)
diff --git a/test/test_rest_api.py b/test/test_rest_api.py
index b3d2a21..9143c82 100644
--- a/test/test_rest_api.py
+++ b/test/test_rest_api.py
@@ -4,7 +4,6 @@ from unittest.mock import patch
 
 import pytest
 from requests import HTTPError
-from urllib.parse import urljoin
 
 from pyinaturalist.constants import INAT_BASE_URL
 from pyinaturalist.rest_api import (
@@ -28,7 +27,7 @@ PAGE_2_JSON_RESPONSE = load_sample_data("get_observation_fields_page2.json")
 
 def get_observations_response(response_format):
     response_format = response_format.replace("widget", "js")
-    return load_sample_data("get_observations.{}".format(response_format))
+    return load_sample_data(f"get_observations.{response_format}")
 
 
 @pytest.mark.parametrize("response_format", OBSERVATION_FORMATS)
@@ -38,7 +37,7 @@ def test_get_observations(response_format, requests_mock):
     key = "json" if response_format == "json" else "text"
 
     requests_mock.get(
-        urljoin(INAT_BASE_URL, "observations.{}".format(response_format)),
+        f"{INAT_BASE_URL}/observations.{response_format}",
         status_code=200,
         **{key: response},
     )
@@ -62,7 +61,7 @@ def test_get_observation_fields(requests_mock):
     """ get_observation_fields() work as expected (basic use)"""
 
     requests_mock.get(
-        urljoin(INAT_BASE_URL, "observation_fields.json?q=sex&page=2"),
+        f"{INAT_BASE_URL}/observation_fields.json?q=sex&page=2",
         json=PAGE_2_JSON_RESPONSE,
         status_code=200,
     )
@@ -75,20 +74,20 @@ def test_get_all_observation_fields(requests_mock):
     """get_all_observation_fields() is able to paginate, accepts a search query and return correct results"""
 
     requests_mock.get(
-        urljoin(INAT_BASE_URL, "observation_fields.json?q=sex&page=1"),
+        f"{INAT_BASE_URL}/observation_fields.json?q=sex&page=1",
         json=PAGE_1_JSON_RESPONSE,
         status_code=200,
     )
 
     requests_mock.get(
-        urljoin(INAT_BASE_URL, "observation_fields.json?q=sex&page=2"),
+        f"{INAT_BASE_URL}/observation_fields.json?q=sex&page=2",
         json=PAGE_2_JSON_RESPONSE,
         status_code=200,
     )
 
     page_3_json_response = []
     requests_mock.get(
-        urljoin(INAT_BASE_URL, "observation_fields.json?q=sex&page=3"),
+        f"{INAT_BASE_URL}/observation_fields.json?q=sex&page=3",
         json=page_3_json_response,
         status_code=200,
     )
@@ -100,7 +99,7 @@ def test_get_all_observation_fields(requests_mock):
 def test_get_all_observation_fields_noparam(requests_mock):
     """get_all_observation_fields() can also be called without a search query without errors"""
     requests_mock.get(
-        urljoin(INAT_BASE_URL, "observation_fields.json?page=1"),
+        f"{INAT_BASE_URL}/observation_fields.json?page=1",
         json=[],
         status_code=200,
     )
@@ -110,7 +109,7 @@ def test_get_all_observation_fields_noparam(requests_mock):
 
 def test_put_observation_field_values(requests_mock):
     requests_mock.put(
-        urljoin(INAT_BASE_URL, "observation_field_values/31"),
+        f"{INAT_BASE_URL}/observation_field_values/31",
         json=load_sample_data("put_observation_field_value_result.json"),
         status_code=200,
     )
@@ -139,7 +138,7 @@ def test_get_access_token_fail(requests_mock):
         "method.",
     }
     requests_mock.post(
-        urljoin(INAT_BASE_URL, "oauth/token"),
+        f"{INAT_BASE_URL}/oauth/token",
         json=rejection_json,
         status_code=401,
     )
@@ -158,7 +157,7 @@ def test_get_access_token(requests_mock):
         "created_at": 1539352135,
     }
     requests_mock.post(
-        urljoin(INAT_BASE_URL, "oauth/token"),
+        f"{INAT_BASE_URL}/oauth/token",
         json=accepted_json,
         status_code=200,
     )
@@ -170,7 +169,7 @@ def test_get_access_token(requests_mock):
 
 def test_update_observation(requests_mock):
     requests_mock.put(
-        urljoin(INAT_BASE_URL, "observations/17932425.json"),
+        f"{INAT_BASE_URL}/observations/17932425.json",
         json=load_sample_data("update_observation_result.json"),
         status_code=200,
     )
@@ -198,9 +197,11 @@ def test_update_observation__local_photo(put, ensure_file_objs):
 
 
 def test_update_nonexistent_observation(requests_mock):
-    """When we try to update a non-existent observation, iNat returns an error 410 with "obs does not longer exists". """
+    """When we try to update a non-existent observation, iNat returns an error 410 with
+    "observation does not exist"
+    """
     requests_mock.put(
-        urljoin(INAT_BASE_URL, "observations/999999999.json"),
+        f"{INAT_BASE_URL}/observations/999999999.json",
         json={"error": "Cette observation n’existe plus."},
         status_code=410,
     )
@@ -219,7 +220,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(
-        urljoin(INAT_BASE_URL, "observations/16227955.json"),
+        f"{INAT_BASE_URL}/observations/16227955.json",
         json={"error": "Cette observation n’existe plus."},
         status_code=410,
     )
@@ -237,7 +238,7 @@ def test_update_observation_not_mine(requests_mock):
 
 def test_create_observation(requests_mock):
     requests_mock.post(
-        urljoin(INAT_BASE_URL, "observations.json"),
+        f"{INAT_BASE_URL}/observations.json",
         json=load_sample_data("create_observation_result.json"),
         status_code=200,
     )
@@ -267,7 +268,7 @@ def test_create_observation_fail(requests_mock):
     }
 
     requests_mock.post(
-        urljoin(INAT_BASE_URL, "observations.json"),
+        f"{INAT_BASE_URL}/observations.json",
         json=load_sample_data("create_observation_fail.json"),
         status_code=422,
     )
@@ -280,7 +281,7 @@ def test_create_observation_fail(requests_mock):
 
 def test_add_photo_to_observation(requests_mock):
     requests_mock.post(
-        urljoin(INAT_BASE_URL, "observation_photos"),
+        f"{INAT_BASE_URL}/observation_photos",
         json=load_sample_data("add_photo_to_observation.json"),
         status_code=200,
     )
@@ -292,14 +293,14 @@ def test_add_photo_to_observation(requests_mock):
 
 
 def test_delete_observation(requests_mock):
-    requests_mock.delete(urljoin(INAT_BASE_URL, "observations/24774619.json"), status_code=200)
+    requests_mock.delete(f"{INAT_BASE_URL}/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(urljoin(INAT_BASE_URL, "observations/24774619.json"), status_code=404)
+    requests_mock.delete(f"{INAT_BASE_URL}/observations/24774619.json", status_code=404)
 
     with pytest.raises(ObservationNotFound):
         delete_observation(observation_id=24774619, access_token="valid token")