From d904b5739c420581cafa08c60890aa924a13075c Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Fri, 8 May 2020 20:25:11 -0500 Subject: [PATCH 01/25] Add usage examples to README for taxa endpoints Also: * Move installation section to README for more visibility * Add symlinked images dir for display on both GitHub and readthedocs --- README.rst | 127 +++++++++++++++++++++++++++-- docs/docs/README.rst | 2 + docs/docs/images | 1 + docs/images/taxon_autocomplete.png | Bin 0 -> 60816 bytes docs/index.rst | 1 - docs/installation.rst | 17 ---- pyinaturalist/node_api.py | 3 + 7 files changed, 126 insertions(+), 25 deletions(-) create mode 100644 docs/docs/README.rst create mode 120000 docs/docs/images create mode 100755 docs/images/taxon_autocomplete.png delete mode 100644 docs/installation.rst diff --git a/README.rst b/README.rst index c4a659d1..2aec460a 100644 --- a/README.rst +++ b/README.rst @@ -23,12 +23,33 @@ That being said, many things are already possible (searching observations, creat contributions are welcome! Python 3 only. +See full documentation at ``_. + +Installation +------------ + +Simply use pip:: + + $ pip install pyinaturalist + +Or if you prefer using the development version:: + + $ pip install git+https://github.com/niconoe/pyinaturalist.git + +Or, to set up for local development (preferably in a new virtualenv):: + + $ git clone https://github.com/niconoe/pyinaturalist.git + $ cd pyinaturalist + $ pip install -Ue ".[dev]" Examples -------- +Observations +^^^^^^^^^^^^ + Search all observations matching a criteria: --------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python @@ -39,7 +60,7 @@ Search all observations matching a criteria: see `available parameters `_. For authenticated API calls, you first need to obtain a token for the user: ---------------------------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python @@ -55,7 +76,7 @@ For authenticated API calls, you first need to obtain a token for the user: Note: you'll need to `create an iNaturalist app `_. Create a new observation: -------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python @@ -82,7 +103,7 @@ Create a new observation: new_observation_id = r[0]['id'] Upload a picture for this observation: --------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python from pyinaturalist.rest_api import add_photo_to_observation @@ -92,7 +113,7 @@ Upload a picture for this observation: access_token=token) Update an existing observation of yours: ----------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python from pyinaturalist.rest_api import update_observation @@ -103,7 +124,7 @@ Update an existing observation of yours: Get a list of all (globally available) observation fields: ----------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python from pyinaturalist.rest_api import get_all_observation_fields @@ -111,7 +132,7 @@ Get a list of all (globally available) observation fields: r = get_all_observation_fields(search_query="DNA") Sets an observation field value to an existing observation: ------------------------------------------------------------ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python from pyinaturalist.rest_api import put_observation_field_values @@ -121,3 +142,95 @@ Sets an observation field value to an existing observation: value=250, access_token=token) +Taxonomy +^^^^^^^^ + +Search for all taxa matching some criteria: +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Let's say you partially remember either a genus or family name that started with **'vespi'**-something: + +.. code-block:: python + + >>> from pyinaturalist.node_api import get_taxa + >>> response = get_taxa(q="vespi", rank=["genus", "family"]) + >>> print({taxon["id"]: taxon["name"] for taxon in response["results"]}) + {52747: "Vespidae", 84737: "Vespina", 92786: "Vespicula", 646195: "Vespiodes", ...} + + +Oh, that's right, it was **'Vespidae'**! Now let's find all of its subfamilies using its taxon ID +from the results above: + +.. code-block:: python + + >>> response = get_taxa(parent_id=52747) + >>> print({taxon["id"]: taxon["name"] for taxon in response["results"]}) + {343248: "Polistinae", 84738: "Vespinae", 119344: "Eumeninae", 121511: "Masarinae", ...} + +Get a specific taxon by ID: +~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Let's find out more about this 'Polistinae' genus. We could search for it by name or by ID, +but since we already know the ID from the previous search, let's use that: + +.. code-block:: python + + >>> from pyinaturalist.node_api import get_taxa_by_id + >>> response = get_taxa_by_id(343248) + +There is a lot of info in there, but let's just get the basics for now: + +.. code-block:: python + + >>> basic_fields = ["preferred_common_name", "observations_count", "wikipedia_url", "wikipedia_summary"] + >>> print({f: response["results"][0][f] for f in basic_fields}) + { + "preferred_common_name": "Paper Wasps", + "observations_count": 69728, + "wikipedia_url": "http://en.wikipedia.org/wiki/Polistinae", + "wikipedia_summary": "The Polistinae are eusocial wasps closely related to the more familiar yellow jackets...", + } + +Taxon autocomplete +~~~~~~~~~~~~~~~~~~ +This is a text search-optimized endpoint that provides autocompletion in the Naturalist web UI: + +.. image:: docs/images/taxon_autocomplete.png + :alt: Taxon autocompletion in the iNaturalist web UI + :scale: 60% + +This one is a bit more niche, but it provides a fast way to search the iNaturalist taxonomy +database. Here is an example that will run searches from console input: + +.. code-block:: python + + #!/usr/bin/env python3 + from pyinaturalist.node_api import get_taxa_autocomplete + + def format_matches(query): + response = get_taxa_autocomplete(q=query) + matches = [ + '{:>8}: {:>12}: {}'.format(match['id'], match['rank'], match['name']) + for match in response['results'] + ] + return '\n'.join(matches) + + if __name__ == "__main__": + print("Press Ctrl-C to exit") + + while True: + query = input("> ") + print(format_matches(query)) + +Example usage:: + + > opilio + 527573: Genus Opilio + 47367: Order Opiliones + 84644: Species Phalangium opilio + 527419: Subfamily Opilioninae + ... + > coleo + 372759: Subclass Coleoidea + 47208: Order Coleoptera + 359229: Species Coleotechnites florae + 53502: Genus Brickellia + ... diff --git a/docs/docs/README.rst b/docs/docs/README.rst new file mode 100644 index 00000000..087aecb0 --- /dev/null +++ b/docs/docs/README.rst @@ -0,0 +1,2 @@ +The symlink(s) in this directory exist so that relative links to static content will resolve in +both README.rst (as displayed on GitHub) and Sphinx html docs (as displayed on readthedocs.io). diff --git a/docs/docs/images b/docs/docs/images new file mode 120000 index 00000000..5e675731 --- /dev/null +++ b/docs/docs/images @@ -0,0 +1 @@ +../images \ No newline at end of file diff --git a/docs/images/taxon_autocomplete.png b/docs/images/taxon_autocomplete.png new file mode 100755 index 0000000000000000000000000000000000000000..11940467e0162a56a7c7bbff5f70348008027b1a GIT binary patch literal 60816 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%0DL8|cM;l1BrRY-Zt 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

gS{A~uY`VP3PuzWSy&D+srj{xYm*#zppmFh3 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 z�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 AJpcdz literal 0 HcmV?d00001 diff --git a/docs/index.rst b/docs/index.rst index 409f238a..1efbb54d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,7 +13,6 @@ Contents: .. toctree:: :maxdepth: 2 - installation reference contributing infrastructure diff --git a/docs/installation.rst b/docs/installation.rst deleted file mode 100644 index 47da1efe..00000000 --- a/docs/installation.rst +++ /dev/null @@ -1,17 +0,0 @@ -============ -Installation -============ - -Simply use pip:: - - $ pip install pyinaturalist - -Or if you prefer using the development version:: - - $ pip install git+https://github.com/niconoe/pyinaturalist.git - -Or, to set up for local development (preferably in a new virtualenv):: - - $ git clone https://github.com/niconoe/pyinaturalist.git - $ cd pyinaturalist - $ pip install -Ue ".[dev]" diff --git a/pyinaturalist/node_api.py b/pyinaturalist/node_api.py index c2c91871..f3effb15 100644 --- a/pyinaturalist/node_api.py +++ b/pyinaturalist/node_api.py @@ -161,6 +161,9 @@ def get_taxa_autocomplete(user_agent: str = None, **params) -> Dict[str, Any]: """Given a query string, returns taxa with names starting with the search term See: https://api.inaturalist.org/v1/docs/#!/Taxa/get_taxa_autocomplete + **Note:** There appears to currently be a bug in the API that causes ``per_page`` to not have + any effect. + :param q: Name must begin with this value :param is_active: Taxon is active :param taxon_id: Only show taxa with this ID, or its descendants From e66cd328cf5123693b7beb8dcb2c7a2b9d8a6b89 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Fri, 8 May 2020 20:40:54 -0500 Subject: [PATCH 02/25] Add minified response format as an option for get_taxa_autocomplete() Also handle an error when in which `get_taxa_by_id()` could attempt to request multiple IDs if a list or other non-integer value is passed in (unlike the other endpoints this one only supports a single ID. In this case a `ValueError` is more useful than an `HttpError` (422 response). --- README.rst | 20 +++++--------------- pyinaturalist/node_api.py | 26 +++++++++++++++++++++++--- test/test_pyinaturalist.py | 25 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 18 deletions(-) diff --git a/README.rst b/README.rst index 2aec460a..700a1b47 100644 --- a/README.rst +++ b/README.rst @@ -202,23 +202,12 @@ database. Here is an example that will run searches from console input: .. code-block:: python - #!/usr/bin/env python3 from pyinaturalist.node_api import get_taxa_autocomplete - def format_matches(query): - response = get_taxa_autocomplete(q=query) - matches = [ - '{:>8}: {:>12}: {}'.format(match['id'], match['rank'], match['name']) - for match in response['results'] - ] - return '\n'.join(matches) - - if __name__ == "__main__": - print("Press Ctrl-C to exit") - - while True: - query = input("> ") - print(format_matches(query)) + while True: + query = input("> ") + response = get_taxa_autocomplete(q=query, minify=True) + print("\n".join(response["results"])) Example usage:: @@ -234,3 +223,4 @@ Example usage:: 359229: Species Coleotechnites florae 53502: Genus Brickellia ... + diff --git a/pyinaturalist/node_api.py b/pyinaturalist/node_api.py index f3effb15..df3631b7 100644 --- a/pyinaturalist/node_api.py +++ b/pyinaturalist/node_api.py @@ -108,7 +108,7 @@ def get_all_observations(params: Dict, user_agent: str = None) -> List[Dict[str, id_above = results[-1]["id"] -def get_taxa_by_id(taxon_id, user_agent: str = None) -> Dict[str, Any]: +def get_taxa_by_id(taxon_id: int, user_agent: str = None) -> Dict[str, Any]: """ Get one or more taxa by ID. See: https://api.inaturalist.org/v1/docs/#!/Taxa/get_taxa_id @@ -117,6 +117,8 @@ def get_taxa_by_id(taxon_id, user_agent: str = None) -> Dict[str, Any]: :returns: A list of dicts containing taxa results """ + if not isinstance(taxon_id, int): + raise ValueError("Please specify a single integer for the taxon ID") r = make_inaturalist_api_get_call( "taxa/{}".format(taxon_id), {}, user_agent=user_agent ) @@ -157,7 +159,9 @@ def get_taxa( return r.json() -def get_taxa_autocomplete(user_agent: str = None, **params) -> Dict[str, Any]: +def get_taxa_autocomplete( + user_agent: str = None, minify: bool = False, **params +) -> Dict[str, Any]: """Given a query string, returns taxa with names starting with the search term See: https://api.inaturalist.org/v1/docs/#!/Taxa/get_taxa_autocomplete @@ -174,6 +178,7 @@ def get_taxa_autocomplete(user_agent: str = None, **params) -> Dict[str, Any]: :param locale: Locale preference for taxon common names :param preferred_place_id: Place preference for regional taxon common names :param all_names: Include all taxon names in the response + :param minify: Condense each match into a single string containg taxon ID, rank, and name :returns: A list of dicts containing taxa results """ @@ -181,7 +186,22 @@ def get_taxa_autocomplete(user_agent: str = None, **params) -> Dict[str, Any]: "taxa/autocomplete", params, user_agent=user_agent ) r.raise_for_status() - return r.json() + json_response = r.json() + + if minify: + json_response["results"] = format_matches(json_response["results"]) + return json_response + + +def format_matches(results: List) -> List[str]: + """Format text search matches into a single string containing taxon ID, rank, and name. + Whitespace-aligned for display purposes. + """ + # Padding in format strings is to visually align taxon IDs (< 7 chars) and ranks (< 11 chars) + return [ + "{:>8}: {:>12} {}".format(match["id"], match["rank"].title(), match["name"]) + for match in results + ] def get_rank_range(min_rank: str = None, max_rank: str = None) -> List[str]: diff --git a/test/test_pyinaturalist.py b/test/test_pyinaturalist.py index 16545219..4c6a3a2c 100755 --- a/test/test_pyinaturalist.py +++ b/test/test_pyinaturalist.py @@ -127,6 +127,9 @@ def test_get_taxa_by_id(self, requests_mock): assert result["is_active"] is True assert len(result["ancestors"]) == 12 + with pytest.raises(ValueError): + get_taxa_by_id([1, 2]) + def test_get_taxa_autocomplete(self, requests_mock): requests_mock.get( urljoin(INAT_NODE_API_BASE_URL, "taxa/autocomplete?q=vespi"), @@ -146,6 +149,28 @@ def test_get_taxa_autocomplete(self, requests_mock): assert first_result["is_active"] is True assert len(first_result["ancestor_ids"]) == 11 + def test_get_taxa_autocomplete_minified(self, requests_mock): + requests_mock.get( + urljoin(INAT_NODE_API_BASE_URL, "taxa/autocomplete?q=vespi"), + json=_load_sample_json("get_taxa_autocomplete.json"), + status_code=200, + ) + expected_results = [ + " 52747: Family Vespidae", + " 84738: Subfamily Vespinae", + " 131878: Species Nicrophorus vespillo", + " 495392: Species Vespidae st1", + " 70118: Species Nicrophorus vespilloides", + " 84737: Genus Vespina", + " 621584: Species Vespicula cypho", + " 621585: Species Vespicula trachinoides", + " 621586: Species Vespicula zollingeri", + " 299443: Species Vespita woolleyi", + ] + + response = get_taxa_autocomplete(q="vespi", minify=True) + assert response["results"] == expected_results + class TestRestApi(object): @requests_mock.Mocker(kw="mock") From caf687ff20a22b2cae7e68e920f4c4bd26fb0c9a Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Sat, 23 May 2020 09:11:52 -0500 Subject: [PATCH 03/25] Add common names to minified search results; Add more info on search result synonyms to README --- README.rst | 24 ++++++++++++++++++------ pyinaturalist/node_api.py | 21 ++++++++++++--------- test/test_pyinaturalist.py | 9 +++++---- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/README.rst b/README.rst index 700a1b47..191466db 100644 --- a/README.rst +++ b/README.rst @@ -213,14 +213,26 @@ Example usage:: > opilio 527573: Genus Opilio - 47367: Order Opiliones - 84644: Species Phalangium opilio + 47367: Order Opiliones (Harvestmen) + 84644: Species Phalangium opilio (European Harvestman) 527419: Subfamily Opilioninae ... > coleo - 372759: Subclass Coleoidea - 47208: Order Coleoptera - 359229: Species Coleotechnites florae - 53502: Genus Brickellia + 372759: Subclass Coleoidea (Coleoids) + 47208: Order Coleoptera (Beetles) + 359229: Species Coleotechnites florae (Coleotechnites Flower Moth) + 53502: Genus Brickellia (brickellbushes) ... + +If you get unexpected matches, the search likely matched a synonym, either in the form of a +common name or an alternative classification. Check the ``matched_term`` property for more +info. For example: + + .. code-block:: python + + >>> first_result = get_taxa_autocomplete(q='zygoca')['results'][0] + >>> first_result["name"] + "Schlumbergera truncata" + >>> first_result["matched_term"] + "Zygocactus truncatus" # An older synonym for Schlumbergera diff --git a/pyinaturalist/node_api.py b/pyinaturalist/node_api.py index df3631b7..8bca5f5c 100644 --- a/pyinaturalist/node_api.py +++ b/pyinaturalist/node_api.py @@ -189,19 +189,22 @@ def get_taxa_autocomplete( json_response = r.json() if minify: - json_response["results"] = format_matches(json_response["results"]) + json_response["results"] = [format_taxon(t) for t in json_response["results"]] return json_response -def format_matches(results: List) -> List[str]: - """Format text search matches into a single string containing taxon ID, rank, and name. - Whitespace-aligned for display purposes. +def format_taxon(taxon: Dict) -> str: + """Format a taxon result into a single string containing taxon ID, rank, and name + (including common name, if available). """ - # Padding in format strings is to visually align taxon IDs (< 7 chars) and ranks (< 11 chars) - return [ - "{:>8}: {:>12} {}".format(match["id"], match["rank"].title(), match["name"]) - for match in results - ] + # Visually align taxon IDs (< 7 chars) and ranks (< 11 chars) + common = taxon.get("preferred_common_name") + return "{:>8}: {:>12} {}{}".format( + taxon["id"], + taxon["rank"].title(), + taxon["name"], + " ({})".format(common) if common else "", + ) def get_rank_range(min_rank: str = None, max_rank: str = None) -> List[str]: diff --git a/test/test_pyinaturalist.py b/test/test_pyinaturalist.py index 4c6a3a2c..2ccf2197 100755 --- a/test/test_pyinaturalist.py +++ b/test/test_pyinaturalist.py @@ -155,12 +155,13 @@ def test_get_taxa_autocomplete_minified(self, requests_mock): json=_load_sample_json("get_taxa_autocomplete.json"), status_code=200, ) + expected_results = [ - " 52747: Family Vespidae", - " 84738: Subfamily Vespinae", - " 131878: Species Nicrophorus vespillo", + " 52747: Family Vespidae (Hornets, Paper Wasps, Potter Wasps, and Allies)", + " 84738: Subfamily Vespinae (Hornets and Yellowjackets)", + " 131878: Species Nicrophorus vespillo (Vespillo Burying Beetle)", " 495392: Species Vespidae st1", - " 70118: Species Nicrophorus vespilloides", + " 70118: Species Nicrophorus vespilloides (Lesser Vespillo Burying Beetle)", " 84737: Genus Vespina", " 621584: Species Vespicula cypho", " 621585: Species Vespicula trachinoides", From 22d4281917e3db9819b87612229f3b3d3a24f4e6 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Wed, 6 May 2020 16:37:03 -0500 Subject: [PATCH 04/25] Convert all date and datetime parameters to timezone-aware ISO 8601 timestamps --- .gitignore | 3 ++- HISTORY.rst | 7 ++++++ pyinaturalist/constants.py | 21 ++++++++++++++++++ pyinaturalist/helpers.py | 45 +++++++++++++++++++++++++++++++++++++- pyinaturalist/node_api.py | 8 ++----- requirements.txt | 1 + setup.py | 2 +- test/test_helpers.py | 33 +++++++++++++++++++++++++--- 8 files changed, 108 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index d941d474..7913c1a0 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,5 @@ docs/_build .DS_Store .idea -.mypy_cache \ No newline at end of file +.mypy_cache +venv/ diff --git a/HISTORY.rst b/HISTORY.rst index 294891a2..04b8d382 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,13 @@ History ------- +0.10.0 (TBD) ++++++++++++++++++ + +* Added more info & examples to README for taxa endpoints +* Added `minify` option to `node_api.get_taxa_autocomplete()` +* Convert all date and datetime parameters to timezone-aware ISO 8601 timestamps + 0.9.1 (2020-05-26) ++++++++++++++++++ diff --git a/pyinaturalist/constants.py b/pyinaturalist/constants.py index 971b3fb8..f50991cb 100644 --- a/pyinaturalist/constants.py +++ b/pyinaturalist/constants.py @@ -3,6 +3,27 @@ THROTTLING_DELAY = 1 # In seconds, support <0 floats such as 0.1 +# All request parameters from both Node API and REST (Rails) API that accept date or datetime strings +DATETIME_PARAMS = [ + "created_after", + "created_d1", + "created_d2", + "created_on", + "d1", + "d2", + "newer_than", + "observation_created_d1", + "observation_created_d2", + "observed_d1", + "observed_d2", + "observed_on", + "older_than", + "on", + "since", + "updated_since", # TODO: test if this one behaves differently in Node API vs REST API +] + + # Taxonomic ranks from Node API Swagger spec RANKS = [ "form", diff --git a/pyinaturalist/helpers.py b/pyinaturalist/helpers.py index 3571102a..09d67b83 100644 --- a/pyinaturalist/helpers.py +++ b/pyinaturalist/helpers.py @@ -1,6 +1,11 @@ -import pyinaturalist +from datetime import date, datetime from typing import Dict, Any +from dateutil.parser import parse as parse_timestamp +from dateutil.tz import tzlocal +from pyinaturalist.constants import DATETIME_PARAMS +import pyinaturalist + # For Python < 3.5 compatibility def merge_two_dicts(x, y): @@ -17,6 +22,18 @@ def get_user_agent(user_agent: str = None) -> str: return pyinaturalist.user_agent +def preprocess_request_params(params: Dict[str, Any]) -> Dict[str, Any]: + """Perform type conversions, sanity checks, etc. on request parameters""" + if not params: + return {} + + params = convert_bool_params(params) + params = convert_datetime_params(params) + params = convert_list_params(params) + params = strip_empty_params(params) + return params + + def convert_bool_params(params: Dict[str, Any]) -> Dict[str, Any]: """Convert any boolean request parameters to javascript-style boolean strings""" for k, v in params.items(): @@ -25,6 +42,23 @@ def convert_bool_params(params: Dict[str, Any]) -> Dict[str, Any]: return params +def convert_datetime_params(params: Dict[str, Any]) -> Dict[str, Any]: + """Convert any dates, datetimes, or timestamps in other formats into ISO 8601 strings. + + API behavior note: params that take date but not time info will accept a full timestamp and + just ignore the time, so it's safe to parse both date and datetime strings into timestamps + + :raises: :py:exc:`dateutil.parser._parser.ParserError` if a date/datetime format is invalid + """ + for k, v in params.items(): + if isinstance(v, datetime) or isinstance(v, date): + params[k] = _isoformat(v) + if k in DATETIME_PARAMS: + params[k] = _isoformat(parse_timestamp(v)) + + return params + + def convert_list_params(params: Dict[str, Any]) -> Dict[str, Any]: """Convert any list parameters into an API-compatible (comma-delimited) string. Will be url-encoded by requests. For example: `['k1', 'k2', 'k3'] -> k1%2Ck2%2Ck3` @@ -38,3 +72,12 @@ def convert_list_params(params: Dict[str, Any]) -> Dict[str, Any]: def strip_empty_params(params: Dict[str, Any]) -> Dict[str, Any]: """Remove any request parameters with empty or ``None`` values.""" return {k: v for k, v in params.items() if v or v is False} + + +def _isoformat(d): + """Return a date or datetime in ISO format. + If it's a datetime and doesn't already have tzinfo, set it to the system's local timezone. + """ + if isinstance(d, datetime) and not d.tzinfo: + d = d.replace(tzinfo=tzlocal()) + return d.isoformat() diff --git a/pyinaturalist/node_api.py b/pyinaturalist/node_api.py index 8bca5f5c..3bb2f33e 100644 --- a/pyinaturalist/node_api.py +++ b/pyinaturalist/node_api.py @@ -12,9 +12,7 @@ from pyinaturalist.helpers import ( merge_two_dicts, get_user_agent, - convert_bool_params, - convert_list_params, - strip_empty_params, + preprocess_request_params, ) PER_PAGE_RESULTS = 30 # Paginated queries: how many records do we ask per page? @@ -32,9 +30,7 @@ def make_inaturalist_api_get_call( kwargs are passed to requests.request Returns a requests.Response object """ - params = convert_bool_params(params) - params = convert_list_params(params) - params = strip_empty_params(params) + params = preprocess_request_params(params) headers = {"Accept": "application/json", "User-Agent": get_user_agent(user_agent)} response = requests.get( diff --git a/requirements.txt b/requirements.txt index e10dd17e..f51a892a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,6 +24,7 @@ py==1.7.0 pycparser==2.19 Pygments==2.2.0 pyparsing==2.2.2 +python-dateutil==2.8.1 pytz==2018.5 readme-renderer==22.0 requests==2.20.0 diff --git a/setup.py b/setup.py index 618d7060..a185be28 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ packages=["pyinaturalist"], package_dir={"pyinaturalist": "pyinaturalist"}, include_package_data=True, - install_requires=["requests>=2.21.0", "typing>=3.7.4"], + install_requires=["python-dateutil>=2.0", "requests>=2.21.0", "typing>=3.7.4"], extras_require={ "dev": [ "black", diff --git a/test/test_helpers.py b/test/test_helpers.py index 6b4ca500..84361af1 100644 --- a/test/test_helpers.py +++ b/test/test_helpers.py @@ -1,18 +1,25 @@ +import pytest +from datetime import date, datetime +from dateutil.tz import gettz +from unittest.mock import patch + from pyinaturalist.helpers import ( + preprocess_request_params, convert_bool_params, + convert_datetime_params, convert_list_params, strip_empty_params, ) TEST_PARAMS = { - "parent_id": 1, - "rank": ["phylum", "class"], "is_active": False, "only_id": "true", + "preferred_place_id": [1, 2], + "rank": ["phylum", "class"], "q": "", "locale": None, - "preferred_place_id": [1, 2], + "parent_id": 1, } @@ -22,6 +29,26 @@ def test_convert_bool_params(): assert params["only_id"] == "true" +# Test some recognized date(time) formats, with and without TZ info, in date and non-date params +@pytest.mark.parametrize( + "param, value, expected", + [ + ("created_d1", "19951231T235959", "1995-12-31T23:59:59-08:00"), + ("created_d2", "2008-08-08 08:08:08Z", "2008-08-08T08:08:08+00:00"), + ("created_on", "2010-10-10 10:10:10-05:00", "2010-10-10T10:10:10-05:00"), + ("created_on", "Jan 1 2000", "2000-01-01T00:00:00-08:00"), + ("d1", "19970716", "1997-07-16T00:00:00-07:00"), + ("q", date(1954, 2, 5), "1954-02-05"), + ("q", datetime(1954, 2, 5), "1954-02-05T00:00:00-08:00"), + ("q", "not a datetime", "not a datetime"), + ], +) +@patch("pyinaturalist.helpers.tzlocal", return_value=gettz("US/Pacific")) +def test_convert_datetime_params(tzlocal, param, value, expected): + converted = convert_datetime_params({param: value}) + assert converted[param] == expected + + def test_convert_list_params(): params = convert_list_params(TEST_PARAMS) assert params["preferred_place_id"] == "1,2" From b79a0300f33931c76780b41cc3c58f5c9bb11139 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Tue, 26 May 2020 18:53:49 -0500 Subject: [PATCH 05/25] Add some additional tests that will fail if request param conversion is accidentally missed or changed --- test/test_helpers.py | 35 ++++++++++++++++++++++----------- test/test_pyinaturalist.py | 40 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/test/test_helpers.py b/test/test_helpers.py index 84361af1..21674ce2 100644 --- a/test/test_helpers.py +++ b/test/test_helpers.py @@ -29,6 +29,20 @@ def test_convert_bool_params(): assert params["only_id"] == "true" +# Test both int and string lists +def test_convert_list_params(): + params = convert_list_params(TEST_PARAMS) + assert params["preferred_place_id"] == "1,2" + assert params["rank"] == "phylum,class" + + +def test_strip_empty_params(): + params = strip_empty_params(TEST_PARAMS) + assert len(params) == 5 + assert "q" not in params and "locale" not in params + assert "is_active" in params and "only_id" in params + + # Test some recognized date(time) formats, with and without TZ info, in date and non-date params @pytest.mark.parametrize( "param, value, expected", @@ -49,14 +63,13 @@ def test_convert_datetime_params(tzlocal, param, value, expected): assert converted[param] == expected -def test_convert_list_params(): - params = convert_list_params(TEST_PARAMS) - assert params["preferred_place_id"] == "1,2" - assert params["rank"] == "phylum,class" - - -def test_strip_empty_params(): - params = strip_empty_params(TEST_PARAMS) - assert len(params) == 5 - assert "q" not in params and "locale" not in params - assert "is_active" in params and "only_id" in params +# This is just here so that tests will fail if one of the conversion steps is removed +@patch("pyinaturalist.helpers.convert_bool_params") +@patch("pyinaturalist.helpers.convert_datetime_params") +@patch("pyinaturalist.helpers.convert_list_params") +@patch("pyinaturalist.helpers.strip_empty_params") +def test_preprocess_request_params(mock_bool, mock_datetime, mock_list, mock_strip): + preprocess_request_params({"id": 1}) + assert all( + [mock_bool.called, mock_datetime.called, mock_list.called, mock_strip.called] + ) diff --git a/test/test_pyinaturalist.py b/test/test_pyinaturalist.py index 2ccf2197..57d337b8 100755 --- a/test/test_pyinaturalist.py +++ b/test/test_pyinaturalist.py @@ -4,6 +4,7 @@ import json import os from datetime import datetime, timedelta +from inspect import getmembers, isfunction from unittest.mock import patch import pytest @@ -31,6 +32,15 @@ from pyinaturalist.exceptions import AuthenticationError, ObservationNotFound +def _get_module_functions(module): + """ Get all functions belonging to a module """ + return { + name: member + for name, member in getmembers(module) + if isfunction(member) and member.__module__ == module.__name__ + } + + def _sample_data_path(filename): return os.path.join(os.path.dirname(__file__), "sample_data", filename) @@ -110,6 +120,13 @@ def test_get_taxa_by_rank_range( "taxa", {"rank": expected_ranks}, user_agent=None ) + # This is just a spot test of a case in which boolean params should be converted + @patch("pyinaturalist.node_api.requests") + def test_get_taxa_by_name_and_is_active(self, requests): + get_taxa(q="Lixus bardanae", is_active=False) + request_args = requests.get.call_args[0] + assert request_args[1] == {"q": "Lixus bardanae", "is_active": "false"} + def test_get_taxa_by_id(self, requests_mock): taxon_id = 70118 requests_mock.get( @@ -149,6 +166,7 @@ def test_get_taxa_autocomplete(self, requests_mock): assert first_result["is_active"] is True assert len(first_result["ancestor_ids"]) == 11 + # Test usage of format_taxon() with get_taxa_autocomplete() def test_get_taxa_autocomplete_minified(self, requests_mock): requests_mock.get( urljoin(INAT_NODE_API_BASE_URL, "taxa/autocomplete?q=vespi"), @@ -172,6 +190,28 @@ def test_get_taxa_autocomplete_minified(self, requests_mock): response = get_taxa_autocomplete(q="vespi", minify=True) assert response["results"] == expected_results + # This test just ensures that all GET requests call preprocess_request_params() at some point + @patch("pyinaturalist.node_api.get_rank_range") + @patch("pyinaturalist.node_api.merge_two_dicts") + @patch("pyinaturalist.node_api.preprocess_request_params") + @patch("pyinaturalist.node_api.requests") + def test_all_get_requests_use_param_conversion( + self, requests, preprocess_request_params, merge_two_dicts, get_rank_range + ): + requests.get().json.return_value = {"total_results": 1, "results": [{}]} + + # This dynamically gets all functions named pyinaturalist.node_api.get_* + http_get_functions = [ + func + for name, func in _get_module_functions(pyinaturalist.node_api).items() + if name.startswith("get_") + ] + + # With most other logic mocked out, just make sure all GETs call preprocess_request_params() + for func in http_get_functions: + func(1) + assert preprocess_request_params.call_count == len(http_get_functions) + class TestRestApi(object): @requests_mock.Mocker(kw="mock") From 4edf7dc4fa9b3c9c9903c4c9f83e4a98359c15df Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Tue, 26 May 2020 21:15:27 -0500 Subject: [PATCH 06/25] Make generic requests wrappers to use for both API modules, and apply parameter conversion to REST API endpoints --- pyinaturalist/api_requests.py | 55 +++++++++++++++++++++++++++++++++++ pyinaturalist/helpers.py | 9 ------ pyinaturalist/node_api.py | 17 +++++------ pyinaturalist/rest_api.py | 53 ++++++++++++++------------------- test/test_api_requests.py | 38 ++++++++++++++++++++++++ test/test_pyinaturalist.py | 16 +++++----- 6 files changed, 130 insertions(+), 58 deletions(-) create mode 100644 pyinaturalist/api_requests.py create mode 100644 test/test_api_requests.py diff --git a/pyinaturalist/api_requests.py b/pyinaturalist/api_requests.py new file mode 100644 index 00000000..e67f2189 --- /dev/null +++ b/pyinaturalist/api_requests.py @@ -0,0 +1,55 @@ +# Some common functions for HTTP requests used by both the Node and REST API modules +import requests +from typing import Dict + +import pyinaturalist +from pyinaturalist.helpers import preprocess_request_params + + +def delete(url: str, **kwargs) -> requests.Response: + """Wrapper around :py:func:`requests.delete` that supports dry-run mode""" + return request("DELETE", url, **kwargs) + + +def get(url: str, **kwargs) -> requests.Response: + """Wrapper around :py:func:`requests.get` that supports dry-run mode""" + return request("GET", url, **kwargs) + + +def post(url: str, **kwargs) -> requests.Response: + """Wrapper around :py:func:`requests.post` that supports dry-run mode""" + return request("POST", url, **kwargs) + + +def put(url: str, **kwargs) -> requests.Response: + """Wrapper around :py:func:`requests.put` that supports dry-run mode""" + return request("PUT", url, **kwargs) + + +def request( + method: str, + url: str, + access_token: str = None, + user_agent: str = None, + params: Dict = None, + headers: Dict = None, + **kwargs +) -> requests.Response: + """Wrapper around :py:func:`requests.request` that supports dry-run mode and + adds appropriate headers. + + :param method: HTTP method + :param url: Request URL + :param access_token: access_token: the access token, as returned by :func:`get_access_token()` + :param user_agent: a user-agent string that will be passed to iNaturalist + + """ + # Set user agent and authentication headers, if specified + headers = headers or {} + headers["Accept"] = "application/json" + headers["User-Agent"] = user_agent or pyinaturalist.user_agent + if access_token: + headers["Authorization"] = "Bearer %s" % access_token + params = preprocess_request_params(params) + + return requests.request(method, url, params=params, headers=headers, **kwargs) diff --git a/pyinaturalist/helpers.py b/pyinaturalist/helpers.py index 09d67b83..4734482d 100644 --- a/pyinaturalist/helpers.py +++ b/pyinaturalist/helpers.py @@ -4,7 +4,6 @@ from dateutil.parser import parse as parse_timestamp from dateutil.tz import tzlocal from pyinaturalist.constants import DATETIME_PARAMS -import pyinaturalist # For Python < 3.5 compatibility @@ -14,14 +13,6 @@ def merge_two_dicts(x, y): return z -def get_user_agent(user_agent: str = None) -> str: - """Return the user agent to be used.""" - if user_agent is not None: # If we explicitly provide one, use it - return user_agent - else: # Otherwise we have a global one in __init__.py (configurable, with sensible defaults) - return pyinaturalist.user_agent - - def preprocess_request_params(params: Dict[str, Any]) -> Dict[str, Any]: """Perform type conversions, sanity checks, etc. on request parameters""" if not params: diff --git a/pyinaturalist/node_api.py b/pyinaturalist/node_api.py index 3bb2f33e..cc609067 100644 --- a/pyinaturalist/node_api.py +++ b/pyinaturalist/node_api.py @@ -9,11 +9,8 @@ from pyinaturalist.constants import THROTTLING_DELAY, INAT_NODE_API_BASE_URL, RANKS from pyinaturalist.exceptions import ObservationNotFound -from pyinaturalist.helpers import ( - merge_two_dicts, - get_user_agent, - preprocess_request_params, -) +from pyinaturalist.helpers import merge_two_dicts +from pyinaturalist.api_requests import get PER_PAGE_RESULTS = 30 # Paginated queries: how many records do we ask per page? @@ -30,11 +27,11 @@ def make_inaturalist_api_get_call( kwargs are passed to requests.request Returns a requests.Response object """ - params = preprocess_request_params(params) - headers = {"Accept": "application/json", "User-Agent": get_user_agent(user_agent)} - - response = requests.get( - urljoin(INAT_NODE_API_BASE_URL, endpoint), params, headers=headers, **kwargs + response = get( + urljoin(INAT_NODE_API_BASE_URL, endpoint), + params=params, + user_agent=user_agent, + **kwargs ) return response diff --git a/pyinaturalist/rest_api.py b/pyinaturalist/rest_api.py index 6bd22f02..447fe434 100644 --- a/pyinaturalist/rest_api.py +++ b/pyinaturalist/rest_api.py @@ -3,20 +3,9 @@ from time import sleep from typing import Dict, Any, List, BinaryIO, Union # noqa: F401 -import requests - from pyinaturalist.constants import THROTTLING_DELAY, INAT_BASE_URL from pyinaturalist.exceptions import AuthenticationError, ObservationNotFound -from pyinaturalist.helpers import get_user_agent - - -def _build_headers(access_token: str = None, user_agent: str = None) -> Dict[str, str]: - headers = {"User-Agent": get_user_agent(user_agent)} - - if access_token: - headers["Authorization"] = "Bearer %s" % access_token - - return headers +from pyinaturalist.api_requests import delete, get, post, put def get_observation_fields( @@ -33,10 +22,10 @@ def get_observation_fields( """ payload = {"q": search_query, "page": page} # type: Dict[str, Union[int, str]] - response = requests.get( + response = get( "{base_url}/observation_fields.json".format(base_url=INAT_BASE_URL), params=payload, - headers=_build_headers(user_agent=user_agent), + user_agent=user_agent, ) return response.json() @@ -112,11 +101,12 @@ def put_observation_field_values( } } - response = requests.put( + response = put( "{base_url}/observation_field_values/{id}".format( base_url=INAT_BASE_URL, id=observation_field_id ), - headers=_build_headers(access_token=access_token, user_agent=user_agent), + access_token=access_token, + user_agent=user_agent, json=payload, ) @@ -148,10 +138,10 @@ def get_access_token( "password": password, } - response = requests.post( + response = post( "{base_url}/oauth/token".format(base_url=INAT_BASE_URL), - payload, - headers=_build_headers(user_agent=user_agent), + json=payload, + user_agent=user_agent, ) try: return response.json()["access_token"] @@ -175,9 +165,10 @@ def add_photo_to_observation( data = {"observation_photo[observation_id]": observation_id} file_data = {"file": file_object} - response = requests.post( + response = post( url="{base_url}/observation_photos".format(base_url=INAT_BASE_URL), - headers=_build_headers(access_token=access_token, user_agent=user_agent), + access_token=access_token, + user_agent=user_agent, data=data, files=file_data, ) @@ -210,10 +201,11 @@ def create_observations( TODO investigate: according to the doc, we should be able to pass multiple observations (in an array, and in renaming observation to observations, but as far as I saw they are not created (while a status of 200 is returned) """ - response = requests.post( + response = post( url="{base_url}/observations.json".format(base_url=INAT_BASE_URL), json=params, - headers=_build_headers(access_token=access_token, user_agent=user_agent), + access_token=access_token, + user_agent=user_agent, ) response.raise_for_status() return response.json() @@ -238,12 +230,13 @@ def update_observation( doesn't exists or belongs to another user (as of November 2018). """ - response = requests.put( + response = put( url="{base_url}/observations/{id}.json".format( base_url=INAT_BASE_URL, id=observation_id ), json=params, - headers=_build_headers(access_token=access_token, user_agent=user_agent), + access_token=access_token, + user_agent=user_agent, ) response.raise_for_status() return response.json() @@ -266,15 +259,13 @@ def delete_observation( :raise: ObservationNotFound if the requested observation doesn't exists, requests.HTTPError (403) if the observation belongs to another user """ - - headers = _build_headers(access_token=access_token, user_agent=user_agent) - headers["Content-type"] = "application/json" - - response = requests.delete( + response = delete( url="{base_url}/observations/{id}.json".format( base_url=INAT_BASE_URL, id=observation_id ), - headers=headers, + access_token=access_token, + user_agent=user_agent, + headers={"Content-type": "application/json"}, ) if response.status_code == 404: raise ObservationNotFound diff --git a/test/test_api_requests.py b/test/test_api_requests.py new file mode 100644 index 00000000..993ef44c --- /dev/null +++ b/test/test_api_requests.py @@ -0,0 +1,38 @@ +import pytest +from unittest.mock import patch + +import pyinaturalist +from pyinaturalist.api_requests import delete, get, post, put, request + + +# Just test that the wrapper methods call requests.request with the appropriate HTTP method +@pytest.mark.parametrize( + "function, http_method", + [(delete, "DELETE"), (get, "GET"), (post, "POST"), (put, "PUT")], +) +@patch("pyinaturalist.api_requests.request") +def test_http_methods(mock_request, function, http_method): + function("https://url", param="value") + mock_request.assert_called_with(http_method, "https://url", param="value") + + +# Test that the requests() wrapper passes along expected headers; just tests kwargs, not mock response +@pytest.mark.parametrize( + "input_kwargs, expected_headers", + [ + ({}, {"Accept": "application/json", "User-Agent": pyinaturalist.user_agent}), + ( + {"user_agent": "CustomUA"}, + {"Accept": "application/json", "User-Agent": "CustomUA"}, + ), + ( + {"access_token": "token"}, + {"Accept": "application/json", "User-Agent": pyinaturalist.user_agent, "Authorization": "Bearer token",}, + ), + ], +) +@patch("pyinaturalist.api_requests.requests.request") +def test_request_headers(mock_request, input_kwargs, expected_headers): + request("GET", "https://url", **input_kwargs) + request_kwargs = mock_request.call_args[1] + assert request_kwargs["headers"] == expected_headers diff --git a/test/test_pyinaturalist.py b/test/test_pyinaturalist.py index 57d337b8..5ad0d7aa 100755 --- a/test/test_pyinaturalist.py +++ b/test/test_pyinaturalist.py @@ -121,11 +121,11 @@ def test_get_taxa_by_rank_range( ) # This is just a spot test of a case in which boolean params should be converted - @patch("pyinaturalist.node_api.requests") - def test_get_taxa_by_name_and_is_active(self, requests): + @patch("pyinaturalist.api_requests.requests.request") + def test_get_taxa_by_name_and_is_active(self, request): get_taxa(q="Lixus bardanae", is_active=False) - request_args = requests.get.call_args[0] - assert request_args[1] == {"q": "Lixus bardanae", "is_active": "false"} + request_kwargs = request.call_args[1] + assert request_kwargs["params"] == {"q": "Lixus bardanae", "is_active": "false"} def test_get_taxa_by_id(self, requests_mock): taxon_id = 70118 @@ -193,12 +193,12 @@ def test_get_taxa_autocomplete_minified(self, requests_mock): # This test just ensures that all GET requests call preprocess_request_params() at some point @patch("pyinaturalist.node_api.get_rank_range") @patch("pyinaturalist.node_api.merge_two_dicts") - @patch("pyinaturalist.node_api.preprocess_request_params") - @patch("pyinaturalist.node_api.requests") + @patch("pyinaturalist.api_requests.preprocess_request_params") + @patch("pyinaturalist.api_requests.requests.request") def test_all_get_requests_use_param_conversion( - self, requests, preprocess_request_params, merge_two_dicts, get_rank_range + self, request, preprocess_request_params, merge_two_dicts, get_rank_range ): - requests.get().json.return_value = {"total_results": 1, "results": [{}]} + request().json.return_value = {"total_results": 1, "results": [{}]} # This dynamically gets all functions named pyinaturalist.node_api.get_* http_get_functions = [ From b23c126c573095207676fcf7bb2e49a7b3e0c716 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Wed, 27 May 2020 01:40:36 -0500 Subject: [PATCH 07/25] Expand tests to ensure that any API request in either node_api or rest_api module uses request param conversion --- pyinaturalist/node_api.py | 4 +-- pyinaturalist/rest_api.py | 2 +- test/conftest.py | 52 ++++++++++++++++++++++++++++ test/test_api_requests.py | 2 +- test/test_helpers.py | 52 ++++++++++++++++++++++++++++ test/test_pyinaturalist.py | 71 ++++++++------------------------------ 6 files changed, 122 insertions(+), 61 deletions(-) create mode 100644 test/conftest.py diff --git a/pyinaturalist/node_api.py b/pyinaturalist/node_api.py index cc609067..fb3c0710 100644 --- a/pyinaturalist/node_api.py +++ b/pyinaturalist/node_api.py @@ -146,7 +146,7 @@ def get_taxa( :returns: A list of dicts containing taxa results """ if min_rank or max_rank: - params["rank"] = get_rank_range(min_rank, max_rank) + params["rank"] = _get_rank_range(min_rank, max_rank) r = make_inaturalist_api_get_call("taxa", params, user_agent=user_agent) r.raise_for_status() return r.json() @@ -200,7 +200,7 @@ def format_taxon(taxon: Dict) -> str: ) -def get_rank_range(min_rank: str = None, max_rank: str = None) -> List[str]: +def _get_rank_range(min_rank: str = None, max_rank: str = None) -> List[str]: """ Translate min and/or max rank into a list of ranks """ min_rank_index = _get_rank_index(min_rank) if min_rank else 0 max_rank_index = _get_rank_index(max_rank) + 1 if max_rank else len(RANKS) diff --git a/pyinaturalist/rest_api.py b/pyinaturalist/rest_api.py index 447fe434..09752a67 100644 --- a/pyinaturalist/rest_api.py +++ b/pyinaturalist/rest_api.py @@ -1,7 +1,7 @@ # Code used to access the (read/write, but slow) Rails based API of iNaturalist # See: https://www.inaturalist.org/pages/api+reference from time import sleep -from typing import Dict, Any, List, BinaryIO, Union # noqa: F401 +from typing import Dict, Any, List, BinaryIO, Union from pyinaturalist.constants import THROTTLING_DELAY, INAT_BASE_URL from pyinaturalist.exceptions import AuthenticationError, ObservationNotFound diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 00000000..7d6122b9 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,52 @@ +""" +Shared unit test-related utilities. +Pytest will also automatically pick up any fixtures defined here. +""" +import json +import os +import re +from inspect import getmembers, isfunction, signature, Parameter +from unittest.mock import MagicMock + +HTTP_FUNC_PATTERN = re.compile(r"(get|put|post|delete)_.+") + + +def get_module_functions(module): + """ Get all functions belonging to a module (excluding imports) """ + return { + name: member + for name, member in getmembers(module) + if isfunction(member) and member.__module__ == module.__name__ + } + + +def get_module_http_functions(module): + """ Get all functions belonging to a module and prefixed with an HTTP method """ + return { + name: func + for name, func in getmembers(module) + if HTTP_FUNC_PATTERN.match(name.lower()) + } + + +def get_mock_args_for_signature(func): + """ Automagically get a list of mock objects corresponding to the required arguments + in a function's signature. Using ``inspect.Signature``, 'Required' is defined by: + 1. positional and 2. no default + """ + required_args = [ + p + for p in signature(func).parameters.values() + if p.kind in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD) + and p.default is Parameter.empty + ] + return [MagicMock()] * len(required_args) + + +def _sample_data_path(filename): + return os.path.join(os.path.dirname(__file__), "sample_data", filename) + + +def load_sample_json(filename): + with open(_sample_data_path(filename), encoding="utf-8") as fh: + return json.load(fh) diff --git a/test/test_api_requests.py b/test/test_api_requests.py index 993ef44c..b36004b6 100644 --- a/test/test_api_requests.py +++ b/test/test_api_requests.py @@ -27,7 +27,7 @@ def test_http_methods(mock_request, function, http_method): ), ( {"access_token": "token"}, - {"Accept": "application/json", "User-Agent": pyinaturalist.user_agent, "Authorization": "Bearer token",}, + {"Accept": "application/json", "User-Agent": pyinaturalist.user_agent, "Authorization": "Bearer token"}, ), ], ) diff --git a/test/test_helpers.py b/test/test_helpers.py index 21674ce2..9de4f8e8 100644 --- a/test/test_helpers.py +++ b/test/test_helpers.py @@ -10,6 +10,9 @@ convert_list_params, strip_empty_params, ) +import pyinaturalist.rest_api +import pyinaturalist.node_api +from test.conftest import get_module_http_functions, get_mock_args_for_signature TEST_PARAMS = { @@ -73,3 +76,52 @@ def test_preprocess_request_params(mock_bool, mock_datetime, mock_list, mock_str assert all( [mock_bool.called, mock_datetime.called, mock_list.called, mock_strip.called] ) + + +# The following tests ensure that all API requests call preprocess_request_params() at some point +# Almost all logic except the request is mocked out so this can generically apply to all API functions +# Using parametrization here so that on failure, pytest will show the specific function that failed +@pytest.mark.parametrize( + "http_function", get_module_http_functions(pyinaturalist.node_api).values() +) +@patch("pyinaturalist.node_api._get_rank_range") +@patch("pyinaturalist.node_api.isinstance") +@patch("pyinaturalist.node_api.merge_two_dicts") +@patch("pyinaturalist.api_requests.preprocess_request_params") +@patch("pyinaturalist.api_requests.requests.request") +def test_all_node_requests_use_param_conversion( + request, + preprocess_request_params, + merge_two_dicts, + get_rank_range, + isinstance, + http_function, +): + request().json.return_value = {"total_results": 1, "results": [{}]} + mock_args = get_mock_args_for_signature(http_function) + http_function(*mock_args) + preprocess_request_params.assert_called() + + +@pytest.mark.parametrize( + "http_function", get_module_http_functions(pyinaturalist.rest_api).values() +) +@patch("pyinaturalist.rest_api.sleep") +@patch("pyinaturalist.api_requests.preprocess_request_params") +@patch("pyinaturalist.api_requests.requests.request") +def test_all_rest_requests_use_param_conversion( + request, preprocess_request_params, sleep, http_function +): + # Handle the one API response that returns a list instead of a dict + if http_function == pyinaturalist.rest_api.get_all_observation_fields: + request().json.return_value = [] + else: + request().json.return_value = { + "total_results": 1, + "access_token": "", + "results": [], + } + + mock_args = get_mock_args_for_signature(http_function) + http_function(*mock_args) + preprocess_request_params.assert_called() diff --git a/test/test_pyinaturalist.py b/test/test_pyinaturalist.py index 5ad0d7aa..37e2dfd4 100755 --- a/test/test_pyinaturalist.py +++ b/test/test_pyinaturalist.py @@ -1,10 +1,7 @@ """ Tests for `pyinaturalist` module. """ -import json -import os from datetime import datetime, timedelta -from inspect import getmembers, isfunction from unittest.mock import patch import pytest @@ -30,35 +27,17 @@ delete_observation, ) from pyinaturalist.exceptions import AuthenticationError, ObservationNotFound +from test.conftest import load_sample_json - -def _get_module_functions(module): - """ Get all functions belonging to a module """ - return { - name: member - for name, member in getmembers(module) - if isfunction(member) and member.__module__ == module.__name__ - } - - -def _sample_data_path(filename): - return os.path.join(os.path.dirname(__file__), "sample_data", filename) - - -def _load_sample_json(filename): - with open(_sample_data_path(filename), encoding="utf-8") as fh: - return json.load(fh) - - -PAGE_1_JSON_RESPONSE = _load_sample_json("get_observation_fields_page1.json") -PAGE_2_JSON_RESPONSE = _load_sample_json("get_observation_fields_page2.json") +PAGE_1_JSON_RESPONSE = load_sample_json("get_observation_fields_page1.json") +PAGE_2_JSON_RESPONSE = load_sample_json("get_observation_fields_page2.json") class TestNodeApi(object): def test_get_observation(self, requests_mock): requests_mock.get( urljoin(INAT_NODE_API_BASE_URL, "observations?id=16227955"), - json=_load_sample_json("get_observation.json"), + json=load_sample_json("get_observation.json"), status_code=200, ) @@ -71,7 +50,7 @@ def test_get_observation(self, requests_mock): def test_get_non_existent_observation(self, requests_mock): requests_mock.get( urljoin(INAT_NODE_API_BASE_URL, "observations?id=99999999"), - json=_load_sample_json("get_nonexistent_observation.json"), + json=load_sample_json("get_nonexistent_observation.json"), status_code=200, ) with pytest.raises(ObservationNotFound): @@ -81,7 +60,7 @@ def test_get_taxa(self, requests_mock): params = urlencode({"q": "vespi", "rank": "genus,subgenus,species"}) requests_mock.get( urljoin(INAT_NODE_API_BASE_URL, "taxa?" + params), - json=_load_sample_json("get_taxa.json"), + json=load_sample_json("get_taxa.json"), status_code=200, ) @@ -131,7 +110,7 @@ def test_get_taxa_by_id(self, requests_mock): taxon_id = 70118 requests_mock.get( urljoin(INAT_NODE_API_BASE_URL, "taxa/" + str(taxon_id)), - json=_load_sample_json("get_taxa_by_id.json"), + json=load_sample_json("get_taxa_by_id.json"), status_code=200, ) @@ -150,7 +129,7 @@ def test_get_taxa_by_id(self, requests_mock): def test_get_taxa_autocomplete(self, requests_mock): requests_mock.get( urljoin(INAT_NODE_API_BASE_URL, "taxa/autocomplete?q=vespi"), - json=_load_sample_json("get_taxa_autocomplete.json"), + json=load_sample_json("get_taxa_autocomplete.json"), status_code=200, ) @@ -170,7 +149,7 @@ def test_get_taxa_autocomplete(self, requests_mock): def test_get_taxa_autocomplete_minified(self, requests_mock): requests_mock.get( urljoin(INAT_NODE_API_BASE_URL, "taxa/autocomplete?q=vespi"), - json=_load_sample_json("get_taxa_autocomplete.json"), + json=load_sample_json("get_taxa_autocomplete.json"), status_code=200, ) @@ -190,28 +169,6 @@ def test_get_taxa_autocomplete_minified(self, requests_mock): response = get_taxa_autocomplete(q="vespi", minify=True) assert response["results"] == expected_results - # This test just ensures that all GET requests call preprocess_request_params() at some point - @patch("pyinaturalist.node_api.get_rank_range") - @patch("pyinaturalist.node_api.merge_two_dicts") - @patch("pyinaturalist.api_requests.preprocess_request_params") - @patch("pyinaturalist.api_requests.requests.request") - def test_all_get_requests_use_param_conversion( - self, request, preprocess_request_params, merge_two_dicts, get_rank_range - ): - request().json.return_value = {"total_results": 1, "results": [{}]} - - # This dynamically gets all functions named pyinaturalist.node_api.get_* - http_get_functions = [ - func - for name, func in _get_module_functions(pyinaturalist.node_api).items() - if name.startswith("get_") - ] - - # With most other logic mocked out, just make sure all GETs call preprocess_request_params() - for func in http_get_functions: - func(1) - assert preprocess_request_params.call_count == len(http_get_functions) - class TestRestApi(object): @requests_mock.Mocker(kw="mock") @@ -319,7 +276,7 @@ def test_update_observation(self, **kwargs): mock = kwargs["mock"] mock.put( "https://www.inaturalist.org/observations/17932425.json", - json=_load_sample_json("update_observation_result.json"), + json=load_sample_json("update_observation_result.json"), status_code=200, ) @@ -392,7 +349,7 @@ def test_create_observation(self, **kwargs): mock.post( "https://www.inaturalist.org/observations.json", - json=_load_sample_json("create_observation_result.json"), + json=load_sample_json("create_observation_result.json"), status_code=200, ) @@ -421,7 +378,7 @@ def test_create_observation_fail(self, **kwargs): mock.post( "https://www.inaturalist.org/observations.json", - json=_load_sample_json("create_observation_fail.json"), + json=load_sample_json("create_observation_fail.json"), status_code=422, ) @@ -437,7 +394,7 @@ def test_put_observation_field_values(self, **kwargs): mock = kwargs["mock"] mock.put( "https://www.inaturalist.org/observation_field_values/31", - json=_load_sample_json("put_observation_field_value_result.json"), + json=load_sample_json("put_observation_field_value_result.json"), status_code=200, ) @@ -475,7 +432,7 @@ def test_user_agent(self, **kwargs): mock = kwargs["mock"] mock.get( urljoin(INAT_NODE_API_BASE_URL, "observations?id=16227955"), - json=_load_sample_json("get_observation.json"), + json=load_sample_json("get_observation.json"), status_code=200, ) accepted_json = { From 8399fab6ede199d4dbd3acf470e86ffca5ee4ee7 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Wed, 27 May 2020 01:52:04 -0500 Subject: [PATCH 08/25] Use requests_mock pytest fixture for all REST API unit tests, and make tests compatible with python < 3.6 --- pyinaturalist/helpers.py | 9 +++ pyinaturalist/node_api.py | 4 +- test/test_helpers.py | 6 +- test/test_pyinaturalist.py | 111 ++++++++++++++----------------------- 4 files changed, 54 insertions(+), 76 deletions(-) diff --git a/pyinaturalist/helpers.py b/pyinaturalist/helpers.py index 4734482d..aec87fee 100644 --- a/pyinaturalist/helpers.py +++ b/pyinaturalist/helpers.py @@ -25,6 +25,15 @@ def preprocess_request_params(params: Dict[str, Any]) -> Dict[str, Any]: return params +def is_int(value: Any) -> bool: + """Determine if a value is a valid integer""" + try: + int(value) + return True + except (TypeError, ValueError): + return False + + def convert_bool_params(params: Dict[str, Any]) -> Dict[str, Any]: """Convert any boolean request parameters to javascript-style boolean strings""" for k, v in params.items(): diff --git a/pyinaturalist/node_api.py b/pyinaturalist/node_api.py index fb3c0710..1ebca6cb 100644 --- a/pyinaturalist/node_api.py +++ b/pyinaturalist/node_api.py @@ -9,7 +9,7 @@ from pyinaturalist.constants import THROTTLING_DELAY, INAT_NODE_API_BASE_URL, RANKS from pyinaturalist.exceptions import ObservationNotFound -from pyinaturalist.helpers import merge_two_dicts +from pyinaturalist.helpers import is_int, merge_two_dicts from pyinaturalist.api_requests import get PER_PAGE_RESULTS = 30 # Paginated queries: how many records do we ask per page? @@ -110,7 +110,7 @@ def get_taxa_by_id(taxon_id: int, user_agent: str = None) -> Dict[str, Any]: :returns: A list of dicts containing taxa results """ - if not isinstance(taxon_id, int): + if not is_int(taxon_id): raise ValueError("Please specify a single integer for the taxon ID") r = make_inaturalist_api_get_call( "taxa/{}".format(taxon_id), {}, user_agent=user_agent diff --git a/test/test_helpers.py b/test/test_helpers.py index 9de4f8e8..2992a91a 100644 --- a/test/test_helpers.py +++ b/test/test_helpers.py @@ -85,7 +85,6 @@ def test_preprocess_request_params(mock_bool, mock_datetime, mock_list, mock_str "http_function", get_module_http_functions(pyinaturalist.node_api).values() ) @patch("pyinaturalist.node_api._get_rank_range") -@patch("pyinaturalist.node_api.isinstance") @patch("pyinaturalist.node_api.merge_two_dicts") @patch("pyinaturalist.api_requests.preprocess_request_params") @patch("pyinaturalist.api_requests.requests.request") @@ -94,13 +93,12 @@ def test_all_node_requests_use_param_conversion( preprocess_request_params, merge_two_dicts, get_rank_range, - isinstance, http_function, ): request().json.return_value = {"total_results": 1, "results": [{}]} mock_args = get_mock_args_for_signature(http_function) http_function(*mock_args) - preprocess_request_params.assert_called() + assert preprocess_request_params.call_count == 1 @pytest.mark.parametrize( @@ -124,4 +122,4 @@ def test_all_rest_requests_use_param_conversion( mock_args = get_mock_args_for_signature(http_function) http_function(*mock_args) - preprocess_request_params.assert_called() + assert preprocess_request_params.call_count == 1 diff --git a/test/test_pyinaturalist.py b/test/test_pyinaturalist.py index 37e2dfd4..2f48643a 100755 --- a/test/test_pyinaturalist.py +++ b/test/test_pyinaturalist.py @@ -5,7 +5,6 @@ from unittest.mock import patch import pytest -import requests_mock from requests import HTTPError from urllib.parse import urlencode, urljoin @@ -171,12 +170,10 @@ def test_get_taxa_autocomplete_minified(self, requests_mock): class TestRestApi(object): - @requests_mock.Mocker(kw="mock") - def test_get_observation_fields(self, **kwargs): + def test_get_observation_fields(self, requests_mock): """ get_observation_fields() work as expected (basic use)""" - mock = kwargs["mock"] - mock.get( + requests_mock.get( "https://www.inaturalist.org/observation_fields.json?q=sex&page=2", json=PAGE_2_JSON_RESPONSE, status_code=200, @@ -185,25 +182,23 @@ def test_get_observation_fields(self, **kwargs): obs_fields = get_observation_fields(search_query="sex", page=2) assert obs_fields == PAGE_2_JSON_RESPONSE - @requests_mock.Mocker(kw="mock") - def test_get_all_observation_fields(self, **kwargs): + def test_get_all_observation_fields(self, requests_mock): """get_all_observation_fields() is able to paginate, accepts a search query and return correct results""" - mock = kwargs["mock"] - mock.get( + requests_mock.get( "https://www.inaturalist.org/observation_fields.json?q=sex&page=1", json=PAGE_1_JSON_RESPONSE, status_code=200, ) - mock.get( + requests_mock.get( "https://www.inaturalist.org/observation_fields.json?q=sex&page=2", json=PAGE_2_JSON_RESPONSE, status_code=200, ) page_3_json_response = [] - mock.get( + requests_mock.get( "https://www.inaturalist.org/observation_fields.json?q=sex&page=3", json=page_3_json_response, status_code=200, @@ -212,12 +207,9 @@ def test_get_all_observation_fields(self, **kwargs): all_fields = get_all_observation_fields(search_query="sex") assert all_fields == PAGE_1_JSON_RESPONSE + PAGE_2_JSON_RESPONSE - @requests_mock.Mocker(kw="mock") - def test_get_all_observation_fields_noparam(self, **kwargs): + def test_get_all_observation_fields_noparam(self, requests_mock): """get_all_observation_fields() can also be called without a search query without errors""" - - mock = kwargs["mock"] - mock.get( + requests_mock.get( "https://www.inaturalist.org/observation_fields.json?page=1", json=[], status_code=200, @@ -225,11 +217,9 @@ def test_get_all_observation_fields_noparam(self, **kwargs): get_all_observation_fields() - @requests_mock.Mocker(kw="mock") - def test_get_access_token_fail(self, **kwargs): + def test_get_access_token_fail(self, requests_mock): """ If we provide incorrect credentials to get_access_token(), an AuthenticationError is raised""" - mock = kwargs["mock"] rejection_json = { "error": "invalid_client", "error_description": "Client authentication failed due to " @@ -237,7 +227,7 @@ def test_get_access_token_fail(self, **kwargs): "included, or unsupported authentication " "method.", } - mock.post( + requests_mock.post( "https://www.inaturalist.org/oauth/token", json=rejection_json, status_code=401, @@ -246,18 +236,16 @@ def test_get_access_token_fail(self, **kwargs): with pytest.raises(AuthenticationError): get_access_token("username", "password", "app_id", "app_secret") - @requests_mock.Mocker(kw="mock") - def test_get_access_token(self, **kwargs): + def test_get_access_token(self, requests_mock): """ Test a successful call to get_access_token() """ - mock = kwargs["mock"] accepted_json = { "access_token": "604e5df329b98eecd22bb0a84f88b68a075a023ac437f2317b02f3a9ba414a08", "token_type": "Bearer", "scope": "write", "created_at": 1539352135, } - mock.post( + requests_mock.post( "https://www.inaturalist.org/oauth/token", json=accepted_json, status_code=200, @@ -271,10 +259,8 @@ def test_get_access_token(self, **kwargs): token == "604e5df329b98eecd22bb0a84f88b68a075a023ac437f2317b02f3a9ba414a08" ) - @requests_mock.Mocker(kw="mock") - def test_update_observation(self, **kwargs): - mock = kwargs["mock"] - mock.put( + def test_update_observation(self, requests_mock): + requests_mock.put( "https://www.inaturalist.org/observations/17932425.json", json=load_sample_json("update_observation_result.json"), status_code=200, @@ -293,11 +279,9 @@ def test_update_observation(self, **kwargs): assert r[0]["id"] == 17932425 assert r[0]["description"] == "updated description v2 !" - @requests_mock.Mocker(kw="mock") - def test_update_nonexistent_observation(self, **kwargs): + def test_update_nonexistent_observation(self, requests_mock): """When we try to update a non-existent observation, iNat returns an error 410 with "obs does not longer exists". """ - mock = kwargs["mock"] - mock.put( + requests_mock.put( "https://www.inaturalist.org/observations/999999999.json", json={"error": "Cette observation n’existe plus."}, status_code=410, @@ -317,11 +301,9 @@ def test_update_nonexistent_observation(self, **kwargs): "error": "Cette observation n’existe plus." } - @requests_mock.Mocker(kw="mock") - def test_update_observation_not_mine(self, **kwargs): + def test_update_observation_not_mine(self, requests_mock): """When we try to update the obs of another user, iNat returns an error 410 with "obs does not longer exists".""" - mock = kwargs["mock"] - mock.put( + requests_mock.put( "https://www.inaturalist.org/observations/16227955.json", json={"error": "Cette observation n’existe plus."}, status_code=410, @@ -343,11 +325,8 @@ def test_update_observation_not_mine(self, **kwargs): "error": "Cette observation n’existe plus." } - @requests_mock.Mocker(kw="mock") - def test_create_observation(self, **kwargs): - mock = kwargs["mock"] - - mock.post( + def test_create_observation(self, requests_mock): + requests_mock.post( "https://www.inaturalist.org/observations.json", json=load_sample_json("create_observation_result.json"), status_code=200, @@ -364,9 +343,7 @@ def test_create_observation(self, **kwargs): ) # We have the field, but it's none since we didn't submitted anything assert r[0]["taxon_id"] == 55626 # Pieris Rapae @ iNaturalist - @requests_mock.Mocker(kw="mock") - def test_create_observation_fail(self, **kwargs): - mock = kwargs["mock"] + def test_create_observation_fail(self, requests_mock): params = { "observation": { "species_guess": "Pieris rapae", @@ -376,7 +353,7 @@ def test_create_observation_fail(self, **kwargs): } } - mock.post( + requests_mock.post( "https://www.inaturalist.org/observations.json", json=load_sample_json("create_observation_fail.json"), status_code=422, @@ -389,10 +366,8 @@ def test_create_observation_fail(self, **kwargs): "errors" in excinfo.value.response.json() ) # iNat also give details about the errors - @requests_mock.Mocker(kw="mock") - def test_put_observation_field_values(self, **kwargs): - mock = kwargs["mock"] - mock.put( + def test_put_observation_field_values(self, requests_mock): + requests_mock.put( "https://www.inaturalist.org/observation_field_values/31", json=load_sample_json("put_observation_field_value_result.json"), status_code=200, @@ -415,22 +390,18 @@ def test_delete_observation(self): # https://github.com/inaturalist/inaturalist/issues/2252 pass - @requests_mock.Mocker(kw="mock") - def test_delete_unexisting_observation(self, **kwargs): + def test_delete_unexisting_observation(self, requests_mock): """ObservationNotFound is raised if the observation doesn't exists""" - mock = kwargs["mock"] - mock.delete( + requests_mock.delete( "https://www.inaturalist.org/observations/24774619.json", status_code=404 ) with pytest.raises(ObservationNotFound): delete_observation(observation_id=24774619, access_token="valid token") - @requests_mock.Mocker(kw="mock") - def test_user_agent(self, **kwargs): + def test_user_agent(self, requests_mock): # TODO: test for all functions that access the inaturalist API? - mock = kwargs["mock"] - mock.get( + requests_mock.get( urljoin(INAT_NODE_API_BASE_URL, "observations?id=16227955"), json=load_sample_json("get_observation.json"), status_code=200, @@ -441,7 +412,7 @@ def test_user_agent(self, **kwargs): "scope": "write", "created_at": 1539352135, } - mock.post( + requests_mock.post( "https://www.inaturalist.org/oauth/token", json=accepted_json, status_code=200, @@ -451,15 +422,15 @@ def test_user_agent(self, **kwargs): # By default, we have a 'Pyinaturalist' user agent: get_observation(observation_id=16227955) - assert mock._adapter.last_request._request.headers["User-Agent"] == default_ua + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == default_ua get_access_token( "valid_username", "valid_password", "valid_app_id", "valid_app_secret" ) - assert mock._adapter.last_request._request.headers["User-Agent"] == default_ua + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == default_ua # But if the user sets a custom one, it is indeed used: get_observation(observation_id=16227955, user_agent="CustomUA") - assert mock._adapter.last_request._request.headers["User-Agent"] == "CustomUA" + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "CustomUA" get_access_token( "valid_username", "valid_password", @@ -467,28 +438,28 @@ def test_user_agent(self, **kwargs): "valid_app_secret", user_agent="CustomUA", ) - assert mock._adapter.last_request._request.headers["User-Agent"] == "CustomUA" + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "CustomUA" # We can also set it globally: pyinaturalist.user_agent = "GlobalUA" get_observation(observation_id=16227955) - assert mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" get_access_token( "valid_username", "valid_password", "valid_app_id", "valid_app_secret" ) - assert mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" # And it persists across requests: get_observation(observation_id=16227955) - assert mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" get_access_token( "valid_username", "valid_password", "valid_app_id", "valid_app_secret" ) - assert mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" # But if we have a global and local one, the local has priority get_observation(observation_id=16227955, user_agent="CustomUA 2") - assert mock._adapter.last_request._request.headers["User-Agent"] == "CustomUA 2" + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "CustomUA 2" get_access_token( "valid_username", "valid_password", @@ -496,13 +467,13 @@ def test_user_agent(self, **kwargs): "valid_app_secret", user_agent="CustomUA 2", ) - assert mock._adapter.last_request._request.headers["User-Agent"] == "CustomUA 2" + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "CustomUA 2" # We can reset the global settings to the default: pyinaturalist.user_agent = pyinaturalist.DEFAULT_USER_AGENT get_observation(observation_id=16227955) - assert mock._adapter.last_request._request.headers["User-Agent"] == default_ua + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == default_ua get_access_token( "valid_username", "valid_password", "valid_app_id", "valid_app_secret" ) - assert mock._adapter.last_request._request.headers["User-Agent"] == default_ua + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == default_ua From b57de39736961789f113065761365f49061ad000 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Tue, 26 May 2020 21:16:27 -0500 Subject: [PATCH 09/25] Add a dry-run mode for testing using `dryable` package --- pyinaturalist/api_requests.py | 12 ++++++++++++ pyinaturalist/constants.py | 12 +++++++++++- requirements.txt | 1 + setup.py | 1 + test/test_api_requests.py | 32 +++++++++++++++++++++++++++++++- 5 files changed, 56 insertions(+), 2 deletions(-) diff --git a/pyinaturalist/api_requests.py b/pyinaturalist/api_requests.py index e67f2189..990b5406 100644 --- a/pyinaturalist/api_requests.py +++ b/pyinaturalist/api_requests.py @@ -3,6 +3,7 @@ from typing import Dict import pyinaturalist +from pyinaturalist.constants import DRY_RUN_ENABLED, MOCK_RESPONSE from pyinaturalist.helpers import preprocess_request_params @@ -53,3 +54,14 @@ def request( params = preprocess_request_params(params) return requests.request(method, url, params=params, headers=headers, **kwargs) + + +# Make dryable an optional dependency; if it is not installed, its decorator will not be applied. +# Dryable must be both installed and enabled before requests are mocked. +try: + import dryable + + dryable.set(DRY_RUN_ENABLED) + request = dryable.Dryable(value=MOCK_RESPONSE)(request) +except ImportError: + pass diff --git a/pyinaturalist/constants.py b/pyinaturalist/constants.py index f50991cb..6ae70f3b 100644 --- a/pyinaturalist/constants.py +++ b/pyinaturalist/constants.py @@ -1,7 +1,17 @@ +import os +import requests +from unittest.mock import Mock + INAT_NODE_API_BASE_URL = "https://api.inaturalist.org/v1/" INAT_BASE_URL = "https://www.inaturalist.org" -THROTTLING_DELAY = 1 # In seconds, support <0 floats such as 0.1 +THROTTLING_DELAY = 1 # In seconds, support <1 floats such as 0.1 + +# Toggle dry-run mode: this will run and log mock HTTP requests instead of real ones +DRY_RUN_ENABLED = bool(os.getenv("DRY_RUN_ENABLED")) +# Mock response content to return in dry-run mode +MOCK_RESPONSE = Mock(spec=requests.Response) +MOCK_RESPONSE.json.return_value = {"results": ["nodata"]} # All request parameters from both Node API and REST (Rails) API that accept date or datetime strings DATETIME_PARAMS = [ diff --git a/requirements.txt b/requirements.txt index f51a892a..e461062a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ cffi==1.11.5 chardet==3.0.4 cmarkgfm==0.4.2 docutils==0.14 +dryable==1.0.5 filelock==3.0.9 future==0.16.0 idna==2.7 diff --git a/setup.py b/setup.py index a185be28..80b81447 100644 --- a/setup.py +++ b/setup.py @@ -32,6 +32,7 @@ extras_require={ "dev": [ "black", + "dryable", "flake8", "mypy", "pytest", diff --git a/test/test_api_requests.py b/test/test_api_requests.py index b36004b6..9abd39f4 100644 --- a/test/test_api_requests.py +++ b/test/test_api_requests.py @@ -1,8 +1,10 @@ +import os import pytest from unittest.mock import patch import pyinaturalist from pyinaturalist.api_requests import delete, get, post, put, request +from pyinaturalist.constants import MOCK_RESPONSE # Just test that the wrapper methods call requests.request with the appropriate HTTP method @@ -27,7 +29,11 @@ def test_http_methods(mock_request, function, http_method): ), ( {"access_token": "token"}, - {"Accept": "application/json", "User-Agent": pyinaturalist.user_agent, "Authorization": "Bearer token"}, + { + "Accept": "application/json", + "User-Agent": pyinaturalist.user_agent, + "Authorization": "Bearer token", + }, ), ], ) @@ -36,3 +42,27 @@ def test_request_headers(mock_request, input_kwargs, expected_headers): request("GET", "https://url", **input_kwargs) request_kwargs = mock_request.call_args[1] assert request_kwargs["headers"] == expected_headers + + +def test_request_dry_run_disabled(requests_mock): + real_response = {"results": ["I'm a real response object!"]} + requests_mock.get( + "http://url", json={"results": ["I'm a real response object!"]}, status_code=200 + ) + + assert request("GET", "http://url",).json() == real_response + + +# TODO: Figure out the least ugly method of mocking this +# @patch("pyinaturalist.api_requests.DRY_RUN_ENABLED") +def test_request_dry_run_enabled(): + real_response = {"results": ["I'm a real response object!"]} + # requests_mock.get("http://url", json=real_response, status_code=200) + # with patch.dict(os.environ, {"DRY_RUN_ENABLED": "True"}): + # with patch("pyinaturalist.api_requests.DRY_RUN_ENABLED", True): + + import dryable + + dryable.set(True) + assert request("GET", "http://url") == MOCK_RESPONSE + dryable.set(False) From b464d2ad37f40e15db0e12ad9eb8336a11500c19 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Tue, 26 May 2020 21:29:05 -0500 Subject: [PATCH 10/25] Add an alternative dry-run option without `dryable` --- pyinaturalist/api_requests.py | 41 ++++++++++++++++++++++------------- pyinaturalist/constants.py | 3 +-- requirements.txt | 1 - setup.py | 3 +-- test/test_api_requests.py | 20 ++++++++--------- 5 files changed, 38 insertions(+), 30 deletions(-) diff --git a/pyinaturalist/api_requests.py b/pyinaturalist/api_requests.py index 990b5406..dcac7544 100644 --- a/pyinaturalist/api_requests.py +++ b/pyinaturalist/api_requests.py @@ -1,29 +1,34 @@ # Some common functions for HTTP requests used by both the Node and REST API modules -import requests +from logging import getLogger +from os import getenv from typing import Dict +import requests + import pyinaturalist from pyinaturalist.constants import DRY_RUN_ENABLED, MOCK_RESPONSE from pyinaturalist.helpers import preprocess_request_params +logger = getLogger(__name__) + def delete(url: str, **kwargs) -> requests.Response: - """Wrapper around :py:func:`requests.delete` that supports dry-run mode""" + """ Wrapper around :py:func:`requests.delete` that supports dry-run mode """ return request("DELETE", url, **kwargs) def get(url: str, **kwargs) -> requests.Response: - """Wrapper around :py:func:`requests.get` that supports dry-run mode""" + """ Wrapper around :py:func:`requests.get` that supports dry-run mode """ return request("GET", url, **kwargs) def post(url: str, **kwargs) -> requests.Response: - """Wrapper around :py:func:`requests.post` that supports dry-run mode""" + """ Wrapper around :py:func:`requests.post` that supports dry-run mode """ return request("POST", url, **kwargs) def put(url: str, **kwargs) -> requests.Response: - """Wrapper around :py:func:`requests.put` that supports dry-run mode""" + """ Wrapper around :py:func:`requests.put` that supports dry-run mode """ return request("PUT", url, **kwargs) @@ -36,7 +41,7 @@ def request( headers: Dict = None, **kwargs ) -> requests.Response: - """Wrapper around :py:func:`requests.request` that supports dry-run mode and + """ Wrapper around :py:func:`requests.request` that supports dry-run mode and adds appropriate headers. :param method: HTTP method @@ -53,15 +58,21 @@ def request( headers["Authorization"] = "Bearer %s" % access_token params = preprocess_request_params(params) - return requests.request(method, url, params=params, headers=headers, **kwargs) + if is_dry_run_enabled(): + log_request(method, url, params=params, headers=headers, **kwargs) + return MOCK_RESPONSE + else: + return requests.request(method, url, params=params, headers=headers, **kwargs) + +def is_dry_run_enabled() -> bool: + """ A wrapper to determine if dry-run (aka test mode) has been enabled via either + the constant or the environment variable + """ + return DRY_RUN_ENABLED or getenv("DRY_RUN_ENABLED") -# Make dryable an optional dependency; if it is not installed, its decorator will not be applied. -# Dryable must be both installed and enabled before requests are mocked. -try: - import dryable - dryable.set(DRY_RUN_ENABLED) - request = dryable.Dryable(value=MOCK_RESPONSE)(request) -except ImportError: - pass +def log_request(*args, **kwargs): + """ Log all relevant information about an HTTP request """ + kwargs_strs = ["{}={}".format(k, v) for k, v in kwargs.items()] + logger.info('Request: {}'.format(', '.join(list(args) + kwargs_strs))) diff --git a/pyinaturalist/constants.py b/pyinaturalist/constants.py index 6ae70f3b..ac0f8e86 100644 --- a/pyinaturalist/constants.py +++ b/pyinaturalist/constants.py @@ -1,4 +1,3 @@ -import os import requests from unittest.mock import Mock @@ -8,7 +7,7 @@ THROTTLING_DELAY = 1 # In seconds, support <1 floats such as 0.1 # Toggle dry-run mode: this will run and log mock HTTP requests instead of real ones -DRY_RUN_ENABLED = bool(os.getenv("DRY_RUN_ENABLED")) +DRY_RUN_ENABLED = False # Mock response content to return in dry-run mode MOCK_RESPONSE = Mock(spec=requests.Response) MOCK_RESPONSE.json.return_value = {"results": ["nodata"]} diff --git a/requirements.txt b/requirements.txt index e461062a..f51a892a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,6 @@ cffi==1.11.5 chardet==3.0.4 cmarkgfm==0.4.2 docutils==0.14 -dryable==1.0.5 filelock==3.0.9 future==0.16.0 idna==2.7 diff --git a/setup.py b/setup.py index 80b81447..b2c9d8eb 100644 --- a/setup.py +++ b/setup.py @@ -28,11 +28,10 @@ packages=["pyinaturalist"], package_dir={"pyinaturalist": "pyinaturalist"}, include_package_data=True, - install_requires=["python-dateutil>=2.0", "requests>=2.21.0", "typing>=3.7.4"], + install_requires=["python-dateutil>=2.0", "requests>=2.21.0", "typing>=3.7.4",], extras_require={ "dev": [ "black", - "dryable", "flake8", "mypy", "pytest", diff --git a/test/test_api_requests.py b/test/test_api_requests.py index 9abd39f4..63e1a975 100644 --- a/test/test_api_requests.py +++ b/test/test_api_requests.py @@ -53,16 +53,16 @@ def test_request_dry_run_disabled(requests_mock): assert request("GET", "http://url",).json() == real_response -# TODO: Figure out the least ugly method of mocking this -# @patch("pyinaturalist.api_requests.DRY_RUN_ENABLED") -def test_request_dry_run_enabled(): - real_response = {"results": ["I'm a real response object!"]} - # requests_mock.get("http://url", json=real_response, status_code=200) - # with patch.dict(os.environ, {"DRY_RUN_ENABLED": "True"}): - # with patch("pyinaturalist.api_requests.DRY_RUN_ENABLED", True): +@patch("pyinaturalist.api_requests.DRY_RUN_ENABLED") +@patch("pyinaturalist.api_requests.requests") +def test_request_dry_run_enabled__by_constant(mock_requests, mock_dry_run_enabled): + assert request("GET", "http://url") == MOCK_RESPONSE + assert mock_requests.call_count == 0 - import dryable - dryable.set(True) +@patch("pyinaturalist.api_requests.getenv", return_value = 'True') +@patch("pyinaturalist.api_requests.requests") +def test_request_dry_run_enabled__by_envar(mock_requests, mock_os): + # mock_os.getenv.return_value = 'True' assert request("GET", "http://url") == MOCK_RESPONSE - dryable.set(False) + assert mock_requests.call_count == 0 From 03407cec898c3f08441392bbd9b6c91f9150390b Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Tue, 9 Jun 2020 12:42:54 -0500 Subject: [PATCH 11/25] Add additional settings to dry-run only write requests (`POST`, `DELETE`, etc.); add more exhaustive unit tests --- pyinaturalist/__init__.py | 4 +- pyinaturalist/api_requests.py | 29 +++++++++--- pyinaturalist/constants.py | 4 +- setup.py | 4 +- test/test_api_requests.py | 83 ++++++++++++++++++++++++++++------- test/test_helpers.py | 6 +-- 6 files changed, 100 insertions(+), 30 deletions(-) diff --git a/pyinaturalist/__init__.py b/pyinaturalist/__init__.py index 119e8a46..29c012e7 100644 --- a/pyinaturalist/__init__.py +++ b/pyinaturalist/__init__.py @@ -5,8 +5,10 @@ __version__ = "0.9.1" DEFAULT_USER_AGENT = "Pyinaturalist/{version}".format(version=__version__) - user_agent = DEFAULT_USER_AGENT +# These are imported here so they can be set with pyinaturalist. +from pyinaturalist.constants import DRY_RUN_ENABLED, DRY_RUN_WRITE_ONLY + # Enable logging for urllib and other external loggers logging.basicConfig(level="DEBUG") diff --git a/pyinaturalist/api_requests.py b/pyinaturalist/api_requests.py index dcac7544..3a567f12 100644 --- a/pyinaturalist/api_requests.py +++ b/pyinaturalist/api_requests.py @@ -6,7 +6,7 @@ import requests import pyinaturalist -from pyinaturalist.constants import DRY_RUN_ENABLED, MOCK_RESPONSE +from pyinaturalist.constants import WRITE_HTTP_METHODS, MOCK_RESPONSE from pyinaturalist.helpers import preprocess_request_params logger = getLogger(__name__) @@ -58,21 +58,38 @@ def request( headers["Authorization"] = "Bearer %s" % access_token params = preprocess_request_params(params) - if is_dry_run_enabled(): + if is_dry_run_enabled(method): + logger.debug("Dry-run mode enabled; mocking request") log_request(method, url, params=params, headers=headers, **kwargs) return MOCK_RESPONSE else: return requests.request(method, url, params=params, headers=headers, **kwargs) -def is_dry_run_enabled() -> bool: +def is_dry_run_enabled(method: str) -> bool: """ A wrapper to determine if dry-run (aka test mode) has been enabled via either - the constant or the environment variable + a constant or an environment variable. Dry-run mode may be enabled for either write + requests, or all requests. """ - return DRY_RUN_ENABLED or getenv("DRY_RUN_ENABLED") + dry_run_enabled = pyinaturalist.DRY_RUN_ENABLED or env_to_bool("DRY_RUN_ENABLED") + if method in WRITE_HTTP_METHODS: + return ( + dry_run_enabled + or pyinaturalist.DRY_RUN_WRITE_ONLY + or env_to_bool("DRY_RUN_WRITE_ONLY") + ) + return dry_run_enabled + + +def env_to_bool(environment_variable: str) -> bool: + """ Translate an environment variable to a boolean value, accounting for minor + variations (case, None vs. False, etc.) + """ + env_value = getenv(environment_variable) + return env_value and str(env_value).lower() not in ["false", "none"] def log_request(*args, **kwargs): """ Log all relevant information about an HTTP request """ kwargs_strs = ["{}={}".format(k, v) for k, v in kwargs.items()] - logger.info('Request: {}'.format(', '.join(list(args) + kwargs_strs))) + logger.info("Request: {}".format(", ".join(list(args) + kwargs_strs))) diff --git a/pyinaturalist/constants.py b/pyinaturalist/constants.py index ac0f8e86..1d04507c 100644 --- a/pyinaturalist/constants.py +++ b/pyinaturalist/constants.py @@ -7,7 +7,9 @@ THROTTLING_DELAY = 1 # In seconds, support <1 floats such as 0.1 # Toggle dry-run mode: this will run and log mock HTTP requests instead of real ones -DRY_RUN_ENABLED = False +DRY_RUN_ENABLED = False # Mock all requests, including GET +DRY_RUN_WRITE_ONLY = False # Only mock 'write' requests +WRITE_HTTP_METHODS = ["PATCH", "POST", "PUT", "DELETE"] # Mock response content to return in dry-run mode MOCK_RESPONSE = Mock(spec=requests.Response) MOCK_RESPONSE.json.return_value = {"results": ["nodata"]} diff --git a/setup.py b/setup.py index b2c9d8eb..8fbff55d 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ packages=["pyinaturalist"], package_dir={"pyinaturalist": "pyinaturalist"}, include_package_data=True, - install_requires=["python-dateutil>=2.0", "requests>=2.21.0", "typing>=3.7.4",], + install_requires=["python-dateutil>=2.0", "requests>=2.21.0", "typing>=3.7.4"], extras_require={ "dev": [ "black", @@ -40,7 +40,7 @@ "sphinx-autodoc-typehints", "sphinx-rtd-theme", "tox", - "twine" + "twine", ] }, license="MIT", diff --git a/test/test_api_requests.py b/test/test_api_requests.py index 63e1a975..0cc65df3 100644 --- a/test/test_api_requests.py +++ b/test/test_api_requests.py @@ -44,6 +44,74 @@ def test_request_headers(mock_request, input_kwargs, expected_headers): assert request_kwargs["headers"] == expected_headers +# Test relevant combinations of dry-run settings and HTTP methods +@pytest.mark.parametrize( + "enabled_const, enabled_env, write_only_const, write_only_env, method, expected_real_request", + [ + # DRY_RUN_ENABLED constant or envar should mock GETs, but not DRY_RUN_WRITE_ONLY + (False, False, False, False, "GET", True), + (False, False, True, True, "GET", True), + (False, False, False, False, "HEAD", True), + (True, False, False, False, "GET", False), + (False, True, False, False, "GET", False), + # Either DRY_RUN_ENABLED or DRY_RUN_WRITE_ONLY should mock POST requests + (False, False, False, False, "POST", True), + (True, False, False, False, "POST", False), + (False, True, False, False, "POST", False), + (False, False, True, False, "POST", False), + (False, False, False, True, "POST", False), + (False, False, True, False, "POST", False), + # Same for the other write methods + (False, False, False, False, "PUT", True), + (False, False, False, False, "DELETE", True), + (False, False, False, True, "PUT", False), + (False, False, False, True, "DELETE", False), + # Truthy environment variable strings should be respected + (False, "true", False, False, "GET", False), + (False, "True", False, "False", "PUT", False), + (False, False, False, "True", "DELETE", False), + # As well as "falsy" environment variable strings + (False, "false", False, False, "GET", True), + (False, "none", False, "False", "POST", True), + (False, False, False, "None", "DELETE", True), + ], +) +@patch("pyinaturalist.api_requests.getenv") +@patch("pyinaturalist.api_requests.requests") +def test_request_dry_run( + mock_requests, + mock_getenv, + enabled_const, + enabled_env, + write_only_const, + write_only_env, + method, + expected_real_request, +): + # Mock any environment variables specified + env_vars = {} + if enabled_env is not None: + env_vars["DRY_RUN_ENABLED"] = enabled_env + if write_only_env is not None: + env_vars["DRY_RUN_WRITE_ONLY"] = write_only_env + mock_getenv.side_effect = env_vars.__getitem__ + + # Mock constants and run request + with patch("pyinaturalist.api_requests.pyinaturalist") as settings: + settings.DRY_RUN_ENABLED = enabled_const + settings.DRY_RUN_WRITE_ONLY = write_only_const + response = request(method, "http://url") + + # Verify that the request was or wasn""t mocked based on settings + if expected_real_request: + assert mock_requests.request.call_count == 1 + assert response == mock_requests.request() + else: + assert response == MOCK_RESPONSE + assert mock_requests.request.call_count == 0 + + +# In addition to the test cases above, ensure that the request/response isn't altered with dry-run disabled def test_request_dry_run_disabled(requests_mock): real_response = {"results": ["I'm a real response object!"]} requests_mock.get( @@ -51,18 +119,3 @@ def test_request_dry_run_disabled(requests_mock): ) assert request("GET", "http://url",).json() == real_response - - -@patch("pyinaturalist.api_requests.DRY_RUN_ENABLED") -@patch("pyinaturalist.api_requests.requests") -def test_request_dry_run_enabled__by_constant(mock_requests, mock_dry_run_enabled): - assert request("GET", "http://url") == MOCK_RESPONSE - assert mock_requests.call_count == 0 - - -@patch("pyinaturalist.api_requests.getenv", return_value = 'True') -@patch("pyinaturalist.api_requests.requests") -def test_request_dry_run_enabled__by_envar(mock_requests, mock_os): - # mock_os.getenv.return_value = 'True' - assert request("GET", "http://url") == MOCK_RESPONSE - assert mock_requests.call_count == 0 diff --git a/test/test_helpers.py b/test/test_helpers.py index 2992a91a..27f61c9d 100644 --- a/test/test_helpers.py +++ b/test/test_helpers.py @@ -89,11 +89,7 @@ def test_preprocess_request_params(mock_bool, mock_datetime, mock_list, mock_str @patch("pyinaturalist.api_requests.preprocess_request_params") @patch("pyinaturalist.api_requests.requests.request") def test_all_node_requests_use_param_conversion( - request, - preprocess_request_params, - merge_two_dicts, - get_rank_range, - http_function, + request, preprocess_request_params, merge_two_dicts, get_rank_range, http_function, ): request().json.return_value = {"total_results": 1, "results": [{}]} mock_args = get_mock_args_for_signature(http_function) From 3cfdefc65e317c93230b5ef8948653d921545230 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Tue, 9 Jun 2020 14:09:13 -0500 Subject: [PATCH 12/25] Update documentation; closes #9 --- HISTORY.rst | 1 + README.rst | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/HISTORY.rst b/HISTORY.rst index 04b8d382..1e12d82d 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -9,6 +9,7 @@ History * Added more info & examples to README for taxa endpoints * Added `minify` option to `node_api.get_taxa_autocomplete()` * Convert all date and datetime parameters to timezone-aware ISO 8601 timestamps +* Added a dry-run mode to mock out API requests for testing 0.9.1 (2020-05-26) ++++++++++++++++++ diff --git a/README.rst b/README.rst index 191466db..88f2a402 100644 --- a/README.rst +++ b/README.rst @@ -236,3 +236,46 @@ info. For example: "Schlumbergera truncata" >>> first_result["matched_term"] "Zygocactus truncatus" # An older synonym for Schlumbergera + + +Dry-run mode +------------ +While developing & testing an application that uses an API client like pyinaturalist, it can be +useful to temporarily mock out HTTP requests, especially requests that add, modify, or delete +real data. Pyinaturalist has some settings to make this easier. + +Dry-run all requests +^^^^^^^^^^^^^^^^^^^^ +To enable dry-run mode, set the ``DRY_RUN_ENABLED`` variable. When set, requests will not be sent +but will be logged instead: + +.. code-block:: python + + >>> import pyinaturalist + >>> pyinaturalist.DRY_RUN_ENABLED = True + >>> get_taxa(q='warbler', locale=1) + {'results': ['nodata']} + INFO:pyinaturalist.api_requests:Request: GET, https://api.inaturalist.org/v1/taxa, + params={'q': 'warbler', 'locale': 1}, + headers={'Accept': 'application/json', 'User-Agent': 'Pyinaturalist/0.9.1'} + +Or, if you are running your application in a command-line environment, you can set this as an +environment variable instead (case-insensitive): + +.. code-block:: bash + + $ export DRY_RUN_ENABLED=true + $ python my_script.py + +Dry-run only write requests +^^^^^^^^^^^^^^^^^^^^^^^^^^^ +If you would like to run ``GET`` requests but mock out any requests that modify data +(``POST``, ``PUT``, ``DELETE``, etc.), you can use the ``DRY_RUN_WRITE_ONLY`` variable +instead: + +.. code-block:: python + + >>> pyinaturalist.DRY_RUN_WRITE_ONLY = True + # Also works as an environment variable + >>> import os + >>> os.environ["DRY_RUN_WRITE_ONLY"] = 'True' From dbaf2f814524ccfb516ea6c9c320f3264f179af3 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Tue, 9 Jun 2020 14:52:56 -0500 Subject: [PATCH 13/25] Updates to CI config & general project config Deployments ============================== * Update Travis CI config to automatically build & deploy packages to PyPi for tagged releases * Note: using an encrypted token under my PyPi account for deployment (which is what Travis recommends) * Update 'Infrastructure' section and merge into 'Contributing' section Test Coverage ============================== * Register pyinaturalist on coveralls: https://coveralls.io/github/niconoe/pyinaturalist * Add coverage report using `pytest-cov` * Send test coverage results to coveralls * Add a shiny new coveralls badge to README Other CI Config ============================== * Rename `flake8` env from `style` to `lint` * Add `black --check` to `style` env * Relax black a bit with a max line length of 100 * Run `style` env in Travis CI * Run `mypy` env in Travis CI * Fix a couple type annotation issues reported by mypy (oops!) * Update `3.8-dev` env to `3.8` * Run `coverage`, `style`, `mypy`, and `coveralls` only once, in a separate CI job under py3.8 General Config ============================== * Require `setuptools` * Use `setuptools.find_packages()` * Import existing `__version__` to specify `setuptools` version * Move static project metadata from `setup.py` to `setup.cfg` (which supports slightly cleaner syntax for metadata) * Let's call the development stage 'Alpha' now instead of 'Pre-Alpha' * Remove python 3.3 from classifiers (not tested/supported) * Move `MOCK_RESPONSE` out of `constants.py`, which is imported by `__init__.py`. Otherwise, the external import (`requests`) would cause, for example, `python setup.py --version` to fail outside a virtualenv --- .travis.yml | 27 ++++++++++--- CONTRIBUTING.rst | 74 ++++++++++++++++++++++++++++++++++- HISTORY.rst | 5 +-- README.rst | 4 ++ docs/conf.py | 12 +----- docs/index.rst | 1 - docs/infrastructure.rst | 56 -------------------------- pyinaturalist/api_requests.py | 13 +++--- pyinaturalist/constants.py | 6 --- pyinaturalist/helpers.py | 4 +- pyinaturalist/node_api.py | 33 ++++------------ pyinaturalist/rest_api.py | 22 +++-------- pyproject.toml | 2 + requirements.txt | 2 +- setup.cfg | 19 ++++++++- setup.py | 41 +++---------------- test/conftest.py | 4 +- test/test_api_requests.py | 11 ++---- test/test_helpers.py | 4 +- test/test_pyinaturalist.py | 60 +++++++--------------------- tox.ini | 52 +++++++++++++++++------- 21 files changed, 208 insertions(+), 244 deletions(-) delete mode 100644 docs/infrastructure.rst create mode 100644 pyproject.toml diff --git a/.travis.yml b/.travis.yml index 1caf922a..87ffdcc9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,13 +10,28 @@ matrix: - python: "3.5" - python: "3.6" - python: "3.7" - - python: "3.8-dev" - env: TOXENV=py38 + - python: "3.8" + # Run a separate job for combined code quality checks + - python: "3.8" + env: TOXENV=coverage,mypy,style -# command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors +# Install dependencies & run tests install: - - pip install tox-travis - -# command to run tests, e.g. python setup.py test + - pip install tox-travis coveralls script: - tox +# Run coveralls after 'coverage' env +after_success: + - if [[ $TOXENV == *"coverage"* ]]; then coveralls; fi + +# Deploy to PyPI on git tags only (and master branch only by default) +deploy: + provider: pypi + user: __token__ + password: + secure: s7FY53bvG0e+xS/pVQC+SX/eGnZR7alAqpE3n6HZvGQec0kGCE3VofCpGVUyguUiUGmm027xIIdSq6Gpx+lBhWRGmf36RDUAaBOV2DVCgEdPf8nX8Gv4vinOepXWVTYal7QNXiDZCiryyA2O+/28A4abGTS37w+zUQyM70UgJwI9hO1zC4dj4maDY2WE1XHS0WHuI3+iRYc/48d5Pc58gDuGWB4adZ7lODB81/d6StxVJkFCCcVcDwpOAJPldHywULxhfQDu3+vwfchP0V7bMOd+eBwK799gfXERmuZROqxVRuNSRgd8a+TBOzOL7ckW66xyCBN+PT8sVro3P6ZJ9FE65f+opS+9Nz+nUK4Y7QhRNu8D4aUpwfJW8UrxUVl474Ni5YqdSPaARHzJsRi2H+Ft288mawzctsoV6xY/LUDh9d+p+qFR3BTVwyUnQC9NcrrBJl9CnHlsqMH2BXSP5Hr+UCP0Mnjq8UqBRIxk4WSx+4UmrtDzSAO4q1GA/Zo9SaXUyl2D/TodpkWqhgYJ9SdcyXITIhMISTCOtOAVHs1dzYkwKNf2Y+rvopfFXS037sAQm+k9MxyBpQyYaObZj+HS/k/QVwuIWOncyZOqcSb/DrLGiwEVe2aTSg/7YEFshnp0tswDhsNLv6jT9gRd4cB+cnVv3siZisvPpYIamH0= + distributions: sdist bdist_wheel + skip_cleanup: true + skip_existing: true + on: + tags: true diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index ca0cdf08..376c8c66 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -5,10 +5,80 @@ Contributing Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. -You can contribute in many ways: +Contribution Guidelines +----------------------- + +Documentation +~~~~~~~~~~~~~ + +We use `Sphinx `_, and the references page is automatically generated thanks to +``sphinx.ext.autodoc`` and ``sphinx_autodoc_typehints`` extensions. All functions / methods / classes should have a +proper docstrings. + +To build the docs locally:: + + $ tox -e docs + +To preview:: + + # MacOS: + $ open docs/_build/index.html + # Linux: + $ xdg-open docs/_build/index.html + +`Hosted documentation `_ is automatically updated when code gets pushed to +the ``master`` branch. + +For any new or changed behavior, add a brief high-level summary to ``HISTORY.rst``. +This isn't needed for internal changes (tests, other docs, refactoring, etc.). + +Testing +~~~~~~~ + +We use the `pytest framework `_. + +To run locally:: + + $ pytest + +It is however always good to run ``tox`` frequently, to run the tests against multiple Python versions, as well as some +style and type annotations checks:: + + $ tox + +Travis CI will run tests, coverage, black, and mypy when code is pushed to GitHub. + +Type Annotations +~~~~~~~~~~~~~~~~ + +All functions / methods should have parameters and return value type annotations. +Those type annotations are checked by MyPy (``tox -e mypy``) and will appear in the documentation. + +Pull Requests +~~~~~~~~~~~~~ +Here are some general guidelines for submitting a pull request: + +- If the changes are trivial, just briefly explain the changes in the PR description. +- Otherwise, please submit an issue describing the proposed change prior to submitting a PR. +- Make sure the code is tested, annotated and documented as described above. +- Submit the PR to be merged into the ``dev`` branch. + +Releases +~~~~~~~~ +Releases are based on git tags. Travis CI will build and deploy packages to PyPi on tagged commits +on the ``master`` branch. Release steps: + +- Update the version in ``pyinaturalist/__init__.py`` +- Update the release notes in ``HISTORY.rst`` +- Merge changes into the ``master`` branch +- Push a new tag, e.g.: ``git tag v0.1 && git push origin --tags`` +- This will trigger a deployment. Verify that this completes successfully and that the new version + can be installed from pypi with ``pip install`` + Types of Contributions ---------------------- +You can contribute in many ways: Report Bugs ~~~~~~~~~~~ @@ -50,4 +120,4 @@ If you are proposing a feature: * Explain in detail how it would work. * Keep the scope as narrow as possible, to make it easier to implement. * Remember that this is a volunteer-driven project, and that contributions - are welcome :) \ No newline at end of file + are welcome :) diff --git a/HISTORY.rst b/HISTORY.rst index 1e12d82d..b8adba08 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,10 +1,9 @@ -.. :changelog: History ------- -0.10.0 (TBD) -+++++++++++++++++ +0.10.0 (2020-06-TBD) +++++++++++++++++++++ * Added more info & examples to README for taxa endpoints * Added `minify` option to `node_api.get_taxa_autocomplete()` diff --git a/README.rst b/README.rst index 88f2a402..79424880 100644 --- a/README.rst +++ b/README.rst @@ -12,6 +12,10 @@ pyinaturalist :target: https://pyinaturalist.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status +.. image:: https://coveralls.io/repos/github/niconoe/pyinaturalist/badge.svg?branch=dev + :target: https://coveralls.io/github/niconoe/pyinaturalist?branch=dev + + Python client for the `iNaturalist APIs `_. Status diff --git a/docs/conf.py b/docs/conf.py index c7edd6fa..2176c556 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -190,13 +190,7 @@ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ( - "index", - "pyinaturalist.tex", - u"pyinaturalist Documentation", - u"Nicolas Noé", - "manual", - ), + ("index", "pyinaturalist.tex", u"pyinaturalist Documentation", u"Nicolas Noé", "manual",), ] # The name of an image file (relative to this directory) to place at the top of @@ -224,9 +218,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ("index", "pyinaturalist", u"pyinaturalist Documentation", [u"Nicolas Noé"], 1) -] +man_pages = [("index", "pyinaturalist", u"pyinaturalist Documentation", [u"Nicolas Noé"], 1)] # If true, show URL addresses after external links. # man_show_urls = False diff --git a/docs/index.rst b/docs/index.rst index 1efbb54d..0e1b4f5c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,7 +15,6 @@ Contents: reference contributing - infrastructure authors history diff --git a/docs/infrastructure.rst b/docs/infrastructure.rst deleted file mode 100644 index 80c57143..00000000 --- a/docs/infrastructure.rst +++ /dev/null @@ -1,56 +0,0 @@ -Infrastructure -============== - -Documentation -------------- - -We use `Sphinx `_, and the references page is automatically generated thanks to -``sphinx.ext.autodoc`` and ``sphinx_autodoc_typehints`` extensions. All functions / methods / classes should have a -proper docstring. - -To build the doc locally:: - - $ tox -e docs - -To preview:: - - # MacOS: - $ open docs/_build/index.html - # Linux: - $ xdg-open docs/_build/index.html - -Hosted documentation (https://pyinaturalist.readthedocs.io/) is automatically updated when code gets pushed to GitHub. - -Testing -------- - -We use the `pytest framework `_. - -To run locally:: - - $ pytest - -It is however always good to run ``tox`` frequently, to run the tests against multiple Python versions, as well as some -style and type annotations checks:: - - $ tox - -Travis-CI is run when code is pushed to GitHub. - -Type annotations ----------------- - -All functions / methods should have parameters and return value type annotations. Those type annotations are checked by -MyPy (``tox -e mypy``) and will appear in the documentation. - -Releasing at PyPI ------------------ - -Release checklist: - -- Make sure the code is tested, annotated and documented. -- Update version in HISTORY.rst, setup.py and pyinaturalist/__init__.py -- Create the distributions: ``python setup.py sdist bdist_wheel`` -- Use twine to upload the package to PyPI: ``twine upload dist/*`` -- Push a vX.Y.Z tag to GitHub: ``git tag vX.Y.Z && git push origin --tags`` - diff --git a/pyinaturalist/api_requests.py b/pyinaturalist/api_requests.py index 3a567f12..381dea0d 100644 --- a/pyinaturalist/api_requests.py +++ b/pyinaturalist/api_requests.py @@ -2,13 +2,18 @@ from logging import getLogger from os import getenv from typing import Dict +from unittest.mock import Mock import requests import pyinaturalist -from pyinaturalist.constants import WRITE_HTTP_METHODS, MOCK_RESPONSE +from pyinaturalist.constants import WRITE_HTTP_METHODS from pyinaturalist.helpers import preprocess_request_params +# Mock response content to return in dry-run mode +MOCK_RESPONSE = Mock(spec=requests.Response) +MOCK_RESPONSE.json.return_value = {"results": ["nodata"]} + logger = getLogger(__name__) @@ -74,9 +79,7 @@ def is_dry_run_enabled(method: str) -> bool: dry_run_enabled = pyinaturalist.DRY_RUN_ENABLED or env_to_bool("DRY_RUN_ENABLED") if method in WRITE_HTTP_METHODS: return ( - dry_run_enabled - or pyinaturalist.DRY_RUN_WRITE_ONLY - or env_to_bool("DRY_RUN_WRITE_ONLY") + dry_run_enabled or pyinaturalist.DRY_RUN_WRITE_ONLY or env_to_bool("DRY_RUN_WRITE_ONLY") ) return dry_run_enabled @@ -86,7 +89,7 @@ def env_to_bool(environment_variable: str) -> bool: variations (case, None vs. False, etc.) """ env_value = getenv(environment_variable) - return env_value and str(env_value).lower() not in ["false", "none"] + return bool(env_value) and str(env_value).lower() not in ["false", "none"] def log_request(*args, **kwargs): diff --git a/pyinaturalist/constants.py b/pyinaturalist/constants.py index 1d04507c..53262bfb 100644 --- a/pyinaturalist/constants.py +++ b/pyinaturalist/constants.py @@ -1,6 +1,3 @@ -import requests -from unittest.mock import Mock - INAT_NODE_API_BASE_URL = "https://api.inaturalist.org/v1/" INAT_BASE_URL = "https://www.inaturalist.org" @@ -10,9 +7,6 @@ DRY_RUN_ENABLED = False # Mock all requests, including GET DRY_RUN_WRITE_ONLY = False # Only mock 'write' requests WRITE_HTTP_METHODS = ["PATCH", "POST", "PUT", "DELETE"] -# Mock response content to return in dry-run mode -MOCK_RESPONSE = Mock(spec=requests.Response) -MOCK_RESPONSE.json.return_value = {"results": ["nodata"]} # All request parameters from both Node API and REST (Rails) API that accept date or datetime strings DATETIME_PARAMS = [ diff --git a/pyinaturalist/helpers.py b/pyinaturalist/helpers.py index aec87fee..212ee351 100644 --- a/pyinaturalist/helpers.py +++ b/pyinaturalist/helpers.py @@ -1,5 +1,5 @@ from datetime import date, datetime -from typing import Dict, Any +from typing import Any, Dict, Optional from dateutil.parser import parse as parse_timestamp from dateutil.tz import tzlocal @@ -13,7 +13,7 @@ def merge_two_dicts(x, y): return z -def preprocess_request_params(params: Dict[str, Any]) -> Dict[str, Any]: +def preprocess_request_params(params: Optional[Dict[str, Any]]) -> Dict[str, Any]: """Perform type conversions, sanity checks, etc. on request parameters""" if not params: return {} diff --git a/pyinaturalist/node_api.py b/pyinaturalist/node_api.py index 1ebca6cb..8af9ed46 100644 --- a/pyinaturalist/node_api.py +++ b/pyinaturalist/node_api.py @@ -28,10 +28,7 @@ def make_inaturalist_api_get_call( Returns a requests.Response object """ response = get( - urljoin(INAT_NODE_API_BASE_URL, endpoint), - params=params, - user_agent=user_agent, - **kwargs + urljoin(INAT_NODE_API_BASE_URL, endpoint), params=params, user_agent=user_agent, **kwargs ) return response @@ -59,9 +56,7 @@ def get_observations(params: Dict, user_agent: str = None) -> Dict[str, Any]: Returns the parsed JSON returned by iNaturalist (observations in r['results'], a list of dicts) """ - r = make_inaturalist_api_get_call( - "observations", params=params, user_agent=user_agent - ) + r = make_inaturalist_api_get_call("observations", params=params, user_agent=user_agent) return r.json() @@ -83,12 +78,7 @@ def get_all_observations(params: Dict, user_agent: str = None) -> List[Dict[str, while True: iteration_params = merge_two_dicts( params, - { - "order_by": "id", - "order": "asc", - "per_page": PER_PAGE_RESULTS, - "id_above": id_above, - }, + {"order_by": "id", "order": "asc", "per_page": PER_PAGE_RESULTS, "id_above": id_above,}, ) page_obs = get_observations(params=iteration_params, user_agent=user_agent) @@ -112,9 +102,7 @@ def get_taxa_by_id(taxon_id: int, user_agent: str = None) -> Dict[str, Any]: """ if not is_int(taxon_id): raise ValueError("Please specify a single integer for the taxon ID") - r = make_inaturalist_api_get_call( - "taxa/{}".format(taxon_id), {}, user_agent=user_agent - ) + r = make_inaturalist_api_get_call("taxa/{}".format(taxon_id), {}, user_agent=user_agent) r.raise_for_status() return r.json() @@ -152,9 +140,7 @@ def get_taxa( return r.json() -def get_taxa_autocomplete( - user_agent: str = None, minify: bool = False, **params -) -> Dict[str, Any]: +def get_taxa_autocomplete(user_agent: str = None, minify: bool = False, **params) -> Dict[str, Any]: """Given a query string, returns taxa with names starting with the search term See: https://api.inaturalist.org/v1/docs/#!/Taxa/get_taxa_autocomplete @@ -175,9 +161,7 @@ def get_taxa_autocomplete( :returns: A list of dicts containing taxa results """ - r = make_inaturalist_api_get_call( - "taxa/autocomplete", params, user_agent=user_agent - ) + r = make_inaturalist_api_get_call("taxa/autocomplete", params, user_agent=user_agent) r.raise_for_status() json_response = r.json() @@ -193,10 +177,7 @@ def format_taxon(taxon: Dict) -> str: # Visually align taxon IDs (< 7 chars) and ranks (< 11 chars) common = taxon.get("preferred_common_name") return "{:>8}: {:>12} {}{}".format( - taxon["id"], - taxon["rank"].title(), - taxon["name"], - " ({})".format(common) if common else "", + taxon["id"], taxon["rank"].title(), taxon["name"], " ({})".format(common) if common else "", ) diff --git a/pyinaturalist/rest_api.py b/pyinaturalist/rest_api.py index 09752a67..506b1617 100644 --- a/pyinaturalist/rest_api.py +++ b/pyinaturalist/rest_api.py @@ -43,9 +43,7 @@ def get_all_observation_fields( page = 1 while True: - r = get_observation_fields( - search_query=search_query, page=page, user_agent=user_agent - ) + r = get_observation_fields(search_query=search_query, page=page, user_agent=user_agent) if not r: return results @@ -150,10 +148,7 @@ def get_access_token( def add_photo_to_observation( - observation_id: int, - file_object: BinaryIO, - access_token: str, - user_agent: str = None, + observation_id: int, file_object: BinaryIO, access_token: str, user_agent: str = None, ): """Upload a picture and assign it to an existing observation. @@ -212,10 +207,7 @@ def create_observations( def update_observation( - observation_id: int, - params: Dict[str, Any], - access_token: str, - user_agent: str = None, + observation_id: int, params: Dict[str, Any], access_token: str, user_agent: str = None, ) -> List[Dict[str, Any]]: """ Update a single observation. See https://www.inaturalist.org/pages/api+reference#put-observations-id @@ -231,9 +223,7 @@ def update_observation( """ response = put( - url="{base_url}/observations/{id}.json".format( - base_url=INAT_BASE_URL, id=observation_id - ), + url="{base_url}/observations/{id}.json".format(base_url=INAT_BASE_URL, id=observation_id), json=params, access_token=access_token, user_agent=user_agent, @@ -260,9 +250,7 @@ def delete_observation( observation belongs to another user """ response = delete( - url="{base_url}/observations/{id}.json".format( - base_url=INAT_BASE_URL, id=observation_id - ), + url="{base_url}/observations/{id}.json".format(base_url=INAT_BASE_URL, id=observation_id), access_token=access_token, user_agent=user_agent, headers={"Content-type": "application/json"}, diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..aa4949aa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +line-length = 100 diff --git a/requirements.txt b/requirements.txt index f51a892a..52b25d75 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ py==1.7.0 pycparser==2.19 Pygments==2.2.0 pyparsing==2.2.2 -python-dateutil==2.8.1 +python-dateutil>=2.0 pytz==2018.5 readme-renderer==22.0 requests==2.20.0 diff --git a/setup.cfg b/setup.cfg index 0a8df87a..63e586d7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,19 @@ +[metadata] +description = Python client for the iNaturalist APIs +long_description = file: README.md, HISTORY.rst +keywords = pyinaturalist, iNaturalist +license = MIT license +license_files = LICENSE +classifiers = + Development Status :: 3 - Alpha + Intended Audience :: Developers + License :: OSI Approved :: MIT License + Natural Language :: English + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.4 + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + [wheel] -universal = 1 \ No newline at end of file +universal = 1 diff --git a/setup.py b/setup.py index 8fbff55d..3b8da718 100644 --- a/setup.py +++ b/setup.py @@ -1,40 +1,25 @@ #!/usr/bin/env python +from setuptools import setup, find_packages +from pyinaturalist import __version__ -import os -import sys - -try: - from setuptools import setup -except ImportError: - from distutils.core import setup - - -if sys.argv[-1] == "publish": - os.system("python setup.py sdist upload") - sys.exit() - -readme = open("README.rst").read() - -history = open("HISTORY.rst").read().replace(".. :changelog:", "") setup( name="pyinaturalist", - version="0.9.1", - description="Python client for the iNaturalist APIs", - long_description=readme + "\n\n" + history, + version=__version__, author="Nicolas Noé", author_email="nicolas@niconoe.eu", url="https://github.com/niconoe/pyinaturalist", - packages=["pyinaturalist"], - package_dir={"pyinaturalist": "pyinaturalist"}, + packages=find_packages(), include_package_data=True, install_requires=["python-dateutil>=2.0", "requests>=2.21.0", "typing>=3.7.4"], extras_require={ "dev": [ "black", + "coveralls", "flake8", "mypy", "pytest", + "pytest-cov", "requests-mock>=1.7", "Sphinx", "sphinx-autodoc-typehints", @@ -43,19 +28,5 @@ "twine", ] }, - license="MIT", zip_safe=False, - keywords="pyinaturalist", - classifiers=[ - "Development Status :: 2 - Pre-Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Natural Language :: English", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - ], ) diff --git a/test/conftest.py b/test/conftest.py index 7d6122b9..468ee860 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -23,9 +23,7 @@ def get_module_functions(module): def get_module_http_functions(module): """ Get all functions belonging to a module and prefixed with an HTTP method """ return { - name: func - for name, func in getmembers(module) - if HTTP_FUNC_PATTERN.match(name.lower()) + name: func for name, func in getmembers(module) if HTTP_FUNC_PATTERN.match(name.lower()) } diff --git a/test/test_api_requests.py b/test/test_api_requests.py index 0cc65df3..479d4c19 100644 --- a/test/test_api_requests.py +++ b/test/test_api_requests.py @@ -3,14 +3,12 @@ from unittest.mock import patch import pyinaturalist -from pyinaturalist.api_requests import delete, get, post, put, request -from pyinaturalist.constants import MOCK_RESPONSE +from pyinaturalist.api_requests import MOCK_RESPONSE, delete, get, post, put, request # Just test that the wrapper methods call requests.request with the appropriate HTTP method @pytest.mark.parametrize( - "function, http_method", - [(delete, "DELETE"), (get, "GET"), (post, "POST"), (put, "PUT")], + "function, http_method", [(delete, "DELETE"), (get, "GET"), (post, "POST"), (put, "PUT")], ) @patch("pyinaturalist.api_requests.request") def test_http_methods(mock_request, function, http_method): @@ -23,10 +21,7 @@ def test_http_methods(mock_request, function, http_method): "input_kwargs, expected_headers", [ ({}, {"Accept": "application/json", "User-Agent": pyinaturalist.user_agent}), - ( - {"user_agent": "CustomUA"}, - {"Accept": "application/json", "User-Agent": "CustomUA"}, - ), + ({"user_agent": "CustomUA"}, {"Accept": "application/json", "User-Agent": "CustomUA"},), ( {"access_token": "token"}, { diff --git a/test/test_helpers.py b/test/test_helpers.py index 27f61c9d..e81e5a39 100644 --- a/test/test_helpers.py +++ b/test/test_helpers.py @@ -73,9 +73,7 @@ def test_convert_datetime_params(tzlocal, param, value, expected): @patch("pyinaturalist.helpers.strip_empty_params") def test_preprocess_request_params(mock_bool, mock_datetime, mock_list, mock_strip): preprocess_request_params({"id": 1}) - assert all( - [mock_bool.called, mock_datetime.called, mock_list.called, mock_strip.called] - ) + assert all([mock_bool.called, mock_datetime.called, mock_list.called, mock_strip.called]) # The following tests ensure that all API requests call preprocess_request_params() at some point diff --git a/test/test_pyinaturalist.py b/test/test_pyinaturalist.py index 2f48643a..b1651491 100755 --- a/test/test_pyinaturalist.py +++ b/test/test_pyinaturalist.py @@ -210,9 +210,7 @@ def test_get_all_observation_fields(self, requests_mock): def test_get_all_observation_fields_noparam(self, requests_mock): """get_all_observation_fields() can also be called without a search query without errors""" requests_mock.get( - "https://www.inaturalist.org/observation_fields.json?page=1", - json=[], - status_code=200, + "https://www.inaturalist.org/observation_fields.json?page=1", json=[], status_code=200, ) get_all_observation_fields() @@ -228,9 +226,7 @@ def test_get_access_token_fail(self, requests_mock): "method.", } requests_mock.post( - "https://www.inaturalist.org/oauth/token", - json=rejection_json, - status_code=401, + "https://www.inaturalist.org/oauth/token", json=rejection_json, status_code=401, ) with pytest.raises(AuthenticationError): @@ -246,18 +242,14 @@ def test_get_access_token(self, requests_mock): "created_at": 1539352135, } requests_mock.post( - "https://www.inaturalist.org/oauth/token", - json=accepted_json, - status_code=200, + "https://www.inaturalist.org/oauth/token", json=accepted_json, status_code=200, ) token = get_access_token( "valid_username", "valid_password", "valid_app_id", "valid_app_secret" ) - assert ( - token == "604e5df329b98eecd22bb0a84f88b68a075a023ac437f2317b02f3a9ba414a08" - ) + assert token == "604e5df329b98eecd22bb0a84f88b68a075a023ac437f2317b02f3a9ba414a08" def test_update_observation(self, requests_mock): requests_mock.put( @@ -270,9 +262,7 @@ def test_update_observation(self, requests_mock): "ignore_photos": 1, "observation": {"description": "updated description v2 !"}, } - r = update_observation( - observation_id=17932425, params=p, access_token="valid token" - ) + r = update_observation(observation_id=17932425, params=p, access_token="valid token") # If all goes well we got a single element representing the updated observation, enclosed in a list. assert len(r) == 1 @@ -293,13 +283,9 @@ def test_update_nonexistent_observation(self, requests_mock): } with pytest.raises(HTTPError) as excinfo: - update_observation( - observation_id=999999999, params=p, access_token="valid token" - ) + update_observation(observation_id=999999999, params=p, access_token="valid token") assert excinfo.value.response.status_code == 410 - assert excinfo.value.response.json() == { - "error": "Cette observation n’existe plus." - } + assert excinfo.value.response.json() == {"error": "Cette observation n’existe plus."} def test_update_observation_not_mine(self, requests_mock): """When we try to update the obs of another user, iNat returns an error 410 with "obs does not longer exists".""" @@ -316,14 +302,10 @@ def test_update_observation_not_mine(self, requests_mock): with pytest.raises(HTTPError) as excinfo: update_observation( - observation_id=16227955, - params=p, - access_token="valid token for another user", + observation_id=16227955, params=p, access_token="valid token for another user", ) assert excinfo.value.response.status_code == 410 - assert excinfo.value.response.json() == { - "error": "Cette observation n’existe plus." - } + assert excinfo.value.response.json() == {"error": "Cette observation n’existe plus."} def test_create_observation(self, requests_mock): requests_mock.post( @@ -362,9 +344,7 @@ def test_create_observation_fail(self, requests_mock): with pytest.raises(HTTPError) as excinfo: create_observations(params=params, access_token="valid token") assert excinfo.value.response.status_code == 422 - assert ( - "errors" in excinfo.value.response.json() - ) # iNat also give details about the errors + assert "errors" in excinfo.value.response.json() # iNat also give details about the errors def test_put_observation_field_values(self, requests_mock): requests_mock.put( @@ -413,9 +393,7 @@ def test_user_agent(self, requests_mock): "created_at": 1539352135, } requests_mock.post( - "https://www.inaturalist.org/oauth/token", - json=accepted_json, - status_code=200, + "https://www.inaturalist.org/oauth/token", json=accepted_json, status_code=200, ) default_ua = "Pyinaturalist/{v}".format(v=pyinaturalist.__version__) @@ -423,9 +401,7 @@ def test_user_agent(self, requests_mock): # By default, we have a 'Pyinaturalist' user agent: get_observation(observation_id=16227955) assert requests_mock._adapter.last_request._request.headers["User-Agent"] == default_ua - get_access_token( - "valid_username", "valid_password", "valid_app_id", "valid_app_secret" - ) + get_access_token("valid_username", "valid_password", "valid_app_id", "valid_app_secret") assert requests_mock._adapter.last_request._request.headers["User-Agent"] == default_ua # But if the user sets a custom one, it is indeed used: @@ -444,17 +420,13 @@ def test_user_agent(self, requests_mock): pyinaturalist.user_agent = "GlobalUA" get_observation(observation_id=16227955) assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" - get_access_token( - "valid_username", "valid_password", "valid_app_id", "valid_app_secret" - ) + get_access_token("valid_username", "valid_password", "valid_app_id", "valid_app_secret") assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" # And it persists across requests: get_observation(observation_id=16227955) assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" - get_access_token( - "valid_username", "valid_password", "valid_app_id", "valid_app_secret" - ) + get_access_token("valid_username", "valid_password", "valid_app_id", "valid_app_secret") assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" # But if we have a global and local one, the local has priority @@ -473,7 +445,5 @@ def test_user_agent(self, requests_mock): pyinaturalist.user_agent = pyinaturalist.DEFAULT_USER_AGENT get_observation(observation_id=16227955) assert requests_mock._adapter.last_request._request.headers["User-Agent"] == default_ua - get_access_token( - "valid_username", "valid_password", "valid_app_id", "valid_app_secret" - ) + get_access_token("valid_username", "valid_password", "valid_app_id", "valid_app_secret") assert requests_mock._adapter.last_request._request.headers["User-Agent"] == default_ua diff --git a/tox.ini b/tox.ini index 8a4e14aa..f0c8bd20 100644 --- a/tox.ini +++ b/tox.ini @@ -1,21 +1,47 @@ [tox] -envlist = py33, py34, py35, py36, py37, py38, style, docs, mypy +# TODO: Generative sections were introduced in 3.15, but 3.14 was the last release that supported python 3.4 +minversion = 3.14 +envlist = + py34, py35, py36, py37, py38, + coverage, mypy, style, docs, lint [testenv] setenv = - PYTHONPATH = {toxinidir}:{toxinidir}/pyinaturalist -deps = - pytest==4.6.9 - requests-mock==1.7.0 + PYTHONPATH = {toxinidir}/pyinaturalist +extras = dev +commands = + pytest --basetemp={envtmpdir} +whitelist_externals = printf + +# Run all "code quality" checks: coverage, annotations, and style +[testenv:coverage] +commands = + pytest --basetemp={envtmpdir} --cov={toxinidir}/pyinaturalist + printf '====================\n\n' + +[testenv:mypy] commands = - py.test --basetemp={envtmpdir} + mypy --config-file={toxinidir}/mypy.ini . + printf '====================\n\n' [testenv:style] -deps = - -r{toxinidir}/requirements.txt - flake8 commands = - python setup.py flake8 + black --check . + printf '====================\n\n' + +# Install only minimal dependencies for older interpreters +# pytest==4.6.9 is the latest release that supports python 3.4 +[testenv:py34] +deps = + pytest==4.6.9 + requests-mock>=1.7 +extras = + +[testenv:py35] +deps = + pytest==4.6.9 + requests-mock>=1.7 +extras = [testenv:docs] changedir=docs/ @@ -27,11 +53,9 @@ commands = sphinx-build -b linkcheck ./ _build/ sphinx-build -b html ./ _build/ -[testenv:mypy] -deps = - -r{toxinidir}/requirements.txt +[testenv:lint] commands = - mypy --config-file=mypy.ini . + python setup.py flake8 [flake8] max-line-length = 119 From 0767bf9a09a464da5cd274f4df06ab13b702396a Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Wed, 10 Jun 2020 14:27:24 -0500 Subject: [PATCH 14/25] Enable pre-release builds from dev branch: PyPi Deployments ================ * Add function to get a pre-release version number; only used by Travis jobs on the dev branch * Add a separate deploy target for dev branch only * Create & encrypt a new pypi token with a scope of pyinaturalist repo only Dependencies ============ * Add separate `build` setuptools extra for build-specific packages * Remove `twine` from dependencies since Travis uses this already * Remove twine from dependencies since Travis automatically installs this during deployment * Add `semantic-version` package, only used for Travis pre-release builds * Note: Also added this to dev dependencies, but only because there are unit tests for pre-release version numbers * Only install the `typing` backport if we're on python < 3.5 Other Config ============ * Move `mypy.ini` config into `setup.cfg` * Update pypi version badge to match the format/resolution of the others * Add badges for package format and supported python version --- .travis.yml | 49 ++++++++++++++++++++++++--------------- README.rst | 16 ++++++++----- mypy.ini | 10 -------- pyinaturalist/__init__.py | 35 +++++++++++++++++++++++----- setup.cfg | 13 +++++++++++ setup.py | 13 +++++++---- test/conftest.py | 3 +++ test/test_version.py | 21 +++++++++++++++++ tox.ini | 2 +- 9 files changed, 116 insertions(+), 46 deletions(-) delete mode 100644 mypy.ini create mode 100644 test/test_version.py diff --git a/.travis.yml b/.travis.yml index 87ffdcc9..85b02539 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,28 +10,39 @@ matrix: - python: "3.5" - python: "3.6" - python: "3.7" - - python: "3.8" # Run a separate job for combined code quality checks - python: "3.8" env: TOXENV=coverage,mypy,style + # Only this job will contain an encrypted PYPI_TOKEN for deployment + - python: "3.8" + env: + - secure: QFRSBbtAPfFxO2GONfI2KCrq7gdSI6zj4wI5QEunjz8xVjvb60TD2Q4ULmoMPBqgFkqV6NZr6bB49MpNcpfw9aeBkUqjYmWWkLfZZsFWvJeRnkPV4EcfmMKJ9pph9QUYQ1BUcqWPZbPYk0VQdDxMVwDKETW2nzVNgRFaJm4O9kM6qV462/3dN53Q0NNTBfSju80nV8YQycP7onEQQILFityDOThKqR3FctDiwrfMo3ALWSfghkyvCgvrvpf6uAWncM1hwDwga+1Luo3vDG6reDE+M3AitFwo5XsPXy8hER8BwuUh8MRrr/HbakH66e83vvBX593DoGZr7UMqwEg+Xzpa2yUvSszW2U9ksh9BaYPbZG3nzsJJlTg61k6Sh7OU4AXglsDBPVX5aoLVz6XSAL97p+Ec85yo8dzpxzBc31ow8rI6JS9yycRnHXGATKqTIQ7Z3QiVaNKrIJVKhMdqvng1zb8RFR49MbPFT5QQuxOJLe/S1qkzOjclp2scZV17TPa6hkfTuDz01+rPXKh3US0WbR689TtNCBB/UwuoX5vnFqcvmqi0sZtl0G4eqXPvik4r7JnuX3d81LIGzF6e45FyO8sSvicZxCu8KmQQ7xI0hXjP6+vfEOnnz4xpAKmHmf2xTIStpuaCm7xqdt3M070P/A4kC0xBGcbcNCq1xU4= -# Install dependencies & run tests -install: - - pip install tox-travis coveralls -script: - - tox -# Run coveralls after 'coverage' env -after_success: - - if [[ $TOXENV == *"coverage"* ]]; then coveralls; fi +# Install dependencies, run tests, and run coveralls after 'coverage' job +install: pip install ".[build]" +script: tox +after_success: if [[ $TOXENV == *"coverage"* ]]; then coveralls; fi -# Deploy to PyPI on git tags only (and master branch only by default) +# Note: These two release types could be combined, but requires a long, ugly custom condition +# See: https://docs.travis-ci.com/user/deployment#conditional-releases-with-on deploy: - provider: pypi - user: __token__ - password: - secure: s7FY53bvG0e+xS/pVQC+SX/eGnZR7alAqpE3n6HZvGQec0kGCE3VofCpGVUyguUiUGmm027xIIdSq6Gpx+lBhWRGmf36RDUAaBOV2DVCgEdPf8nX8Gv4vinOepXWVTYal7QNXiDZCiryyA2O+/28A4abGTS37w+zUQyM70UgJwI9hO1zC4dj4maDY2WE1XHS0WHuI3+iRYc/48d5Pc58gDuGWB4adZ7lODB81/d6StxVJkFCCcVcDwpOAJPldHywULxhfQDu3+vwfchP0V7bMOd+eBwK799gfXERmuZROqxVRuNSRgd8a+TBOzOL7ckW66xyCBN+PT8sVro3P6ZJ9FE65f+opS+9Nz+nUK4Y7QhRNu8D4aUpwfJW8UrxUVl474Ni5YqdSPaARHzJsRi2H+Ft288mawzctsoV6xY/LUDh9d+p+qFR3BTVwyUnQC9NcrrBJl9CnHlsqMH2BXSP5Hr+UCP0Mnjq8UqBRIxk4WSx+4UmrtDzSAO4q1GA/Zo9SaXUyl2D/TodpkWqhgYJ9SdcyXITIhMISTCOtOAVHs1dzYkwKNf2Y+rvopfFXS037sAQm+k9MxyBpQyYaObZj+HS/k/QVwuIWOncyZOqcSb/DrLGiwEVe2aTSg/7YEFshnp0tswDhsNLv6jT9gRd4cB+cnVv3siZisvPpYIamH0= - distributions: sdist bdist_wheel - skip_cleanup: true - skip_existing: true - on: - tags: true + # Stable releases: only git tags on master branch + - provider: pypi + user: __token__ + password: $PYPI_TOKEN + distributions: sdist bdist_wheel + skip_cleanup: true # Don't delete build artifacts + skip_existing: true # Don't overwrite an existing package with the same name + on: + tags: true + condition: $PYPI_TOKEN # Only run in the job containing an encrypted PYPI_TOKEN + # Pre-releases: dev branch only; pre-release version number is set in __init__.py + - provider: pypi + user: __token__ + password: $PYPI_TOKEN + distributions: sdist + skip_cleanup: true + skip_existing: true + on: + branch: dev + condition: $PYPI_TOKEN diff --git a/README.rst b/README.rst index 79424880..7d7ccd6c 100644 --- a/README.rst +++ b/README.rst @@ -2,19 +2,23 @@ pyinaturalist ============================= -.. image:: https://badge.fury.io/py/pyinaturalist.png - :target: http://badge.fury.io/py/pyinaturalist - .. image:: https://www.travis-ci.com/niconoe/pyinaturalist.svg?branch=master :target: https://www.travis-ci.com/niconoe/pyinaturalist - + :alt: Build Status .. image:: https://readthedocs.org/projects/pyinaturalist/badge/?version=latest :target: https://pyinaturalist.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status - .. image:: https://coveralls.io/repos/github/niconoe/pyinaturalist/badge.svg?branch=dev :target: https://coveralls.io/github/niconoe/pyinaturalist?branch=dev - +.. image:: https://img.shields.io/pypi/v/pyinaturalist?color=blue + :target: https://pypi.org/project/pyinaturalist + :alt: PyPI +.. image:: https://img.shields.io/pypi/pyversions/pyinaturalist + :target: https://pypi.org/project/pyinaturalist + :alt: PyPI - Python Version +.. image:: https://img.shields.io/pypi/format/pyinaturalist?color=blue + :target: https://pypi.org/project/pyinaturalist + :alt: PyPI - Format Python client for the `iNaturalist APIs `_. diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 8d54bd8c..00000000 --- a/mypy.ini +++ /dev/null @@ -1,10 +0,0 @@ -[mypy] - -[mypy-setuptools] -ignore_missing_imports = True - -[mypy-requests_mock] -ignore_missing_imports = True - -[mypy-pytest] -ignore_missing_imports = True \ No newline at end of file diff --git a/pyinaturalist/__init__.py b/pyinaturalist/__init__.py index 29c012e7..95068645 100644 --- a/pyinaturalist/__init__.py +++ b/pyinaturalist/__init__.py @@ -1,14 +1,37 @@ -import logging +from logging import getLogger +from os import getenv __author__ = "Nicolas Noé" __email__ = "nicolas@niconoe.eu" -__version__ = "0.9.1" +__version__ = "0.10.0" + +# These are imported here so they can be set with pyinaturalist. +from pyinaturalist.constants import DRY_RUN_ENABLED, DRY_RUN_WRITE_ONLY DEFAULT_USER_AGENT = "Pyinaturalist/{version}".format(version=__version__) user_agent = DEFAULT_USER_AGENT -# These are imported here so they can be set with pyinaturalist. -from pyinaturalist.constants import DRY_RUN_ENABLED, DRY_RUN_WRITE_ONLY -# Enable logging for urllib and other external loggers -logging.basicConfig(level="DEBUG") +def get_prerelease_version(version: str) -> str: + """ If we're running in a Travis CI job on the dev branch, get a prerelease version using the + current build number. For example: ``1.0.0 -> 1.0.0-dev.123`` + + This could also be done in ``.travis.yml``, but it's a bit cleaner to do in python, and + ``semantic_version`` provides some extra sanity checks. + """ + if not (getenv("TRAVIS") == "true" and getenv("TRAVIS_BRANCH") == "dev"): + return version + # If we happen to be in a dev build, this will prevent the initial 'pip install' from failing + try: + from semantic_version import Version + except ImportError: + return version + + new_version = Version(version) + new_version.prerelease = ("dev", getenv("TRAVIS_BUILD_NUMBER", "0")) + getLogger(__name__).info("Using pre-release version: {}".format(new_version)) + return str(new_version) + + +# This won't modify the version outside of Travis +__version__ = get_prerelease_version(__version__) diff --git a/setup.cfg b/setup.cfg index 63e586d7..b6009b2e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,5 +15,18 @@ classifiers = Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 +# Tell mypy to ignore external libraries without type annotations +[mypy-pytest] +ignore_missing_imports = True + +[mypy-requests_mock] +ignore_missing_imports = True + +[mypy-semantic_version] +ignore_missing_imports = True + +[mypy-setuptools] +ignore_missing_imports = True + [wheel] universal = 1 diff --git a/setup.py b/setup.py index 3b8da718..f973a1b0 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,11 @@ #!/usr/bin/env python +from sys import version_info from setuptools import setup, find_packages from pyinaturalist import __version__ +# Only install the typing backport if we're on python < 3.5 +backports = ["typing>=3.7.4"] if version_info < (3, 5) else [] + setup( name="pyinaturalist", @@ -11,22 +15,23 @@ url="https://github.com/niconoe/pyinaturalist", packages=find_packages(), include_package_data=True, - install_requires=["python-dateutil>=2.0", "requests>=2.21.0", "typing>=3.7.4"], + install_requires=["python-dateutil>=2.0", "requests>=2.21.0"] + backports, extras_require={ "dev": [ "black", - "coveralls", "flake8", "mypy", "pytest", "pytest-cov", "requests-mock>=1.7", + "semantic-version", "Sphinx", "sphinx-autodoc-typehints", "sphinx-rtd-theme", "tox", - "twine", - ] + ], + # Additional packages used only within CI jobs + "build": ["coveralls", "semantic-version", "tox-travis"], }, zip_safe=False, ) diff --git a/test/conftest.py b/test/conftest.py index 468ee860..7033390f 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -3,12 +3,15 @@ Pytest will also automatically pick up any fixtures defined here. """ import json +import logging import os import re from inspect import getmembers, isfunction, signature, Parameter from unittest.mock import MagicMock HTTP_FUNC_PATTERN = re.compile(r"(get|put|post|delete)_.+") +# Enable logging for urllib and other external loggers +logging.basicConfig(level="INFO") def get_module_functions(module): diff --git a/test/test_version.py b/test/test_version.py new file mode 100644 index 00000000..73f13bec --- /dev/null +++ b/test/test_version.py @@ -0,0 +1,21 @@ +# A couple tests to make sure that versioning works as expected within Travis +# So, for example, the build would fail before accidentally publishing a bad version +from sys import version_info +from unittest.mock import patch +import pytest + + +# Mocking out getenv() instead of actually setting envars so this doesn't affect other tests +@patch("pyinaturalist.getenv", side_effect=["true", "master", "123"]) +def test_version__stable_release(mock_getenv): + import pyinaturalist + + assert "dev" not in pyinaturalist.__version__ + + +@pytest.mark.skipif(version_info < (3, 6), reason="semantic-version requires python >= 3.6") +@patch("pyinaturalist.getenv", side_effect=["true", "dev", "123"]) +def test_version__pre_release(mock_getenv): + import pyinaturalist + + assert pyinaturalist.get_prerelease_version("1.0.0") == "1.0.0-dev.123" diff --git a/tox.ini b/tox.ini index f0c8bd20..4e9d9a8a 100644 --- a/tox.ini +++ b/tox.ini @@ -21,7 +21,7 @@ commands = [testenv:mypy] commands = - mypy --config-file={toxinidir}/mypy.ini . + mypy --config-file={toxinidir}/setup.cfg . printf '====================\n\n' [testenv:style] From dae7e820900e49ef58b2585813f53a766b9a0192 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Sat, 9 May 2020 17:21:08 -0500 Subject: [PATCH 15/25] Add GET /observations endpoint from original REST API to add support for additional response formats --- README.rst | 15 +- pyinaturalist/api_requests.py | 4 +- pyinaturalist/constants.py | 4 + .../{helpers.py => request_params.py} | 1 + pyinaturalist/rest_api.py | 33 +- test/conftest.py | 7 +- test/sample_data/get_observation.json | 3880 ++++++++++++++++- test/sample_data/get_observations.atom | 21 + test/sample_data/get_observations.csv | 2 + test/sample_data/get_observations.dwc | 73 + test/sample_data/get_observations.js | 11 + test/sample_data/get_observations.json | 131 + test/sample_data/get_observations.kml | 45 + test/test_helpers.py | 12 +- 14 files changed, 4222 insertions(+), 17 deletions(-) rename pyinaturalist/{helpers.py => request_params.py} (97%) create mode 100644 test/sample_data/get_observations.atom create mode 100644 test/sample_data/get_observations.csv create mode 100644 test/sample_data/get_observations.dwc create mode 100644 test/sample_data/get_observations.js create mode 100644 test/sample_data/get_observations.json create mode 100644 test/sample_data/get_observations.kml diff --git a/README.rst b/README.rst index 7d7ccd6c..c1585a31 100644 --- a/README.rst +++ b/README.rst @@ -62,15 +62,13 @@ Search all observations matching a criteria: .. code-block:: python from pyinaturalist.node_api import get_all_observations - obs = get_all_observations(params={'user_id': 'niconoe'}) -see `available parameters `_. +See `available parameters `_. For authenticated API calls, you first need to obtain a token for the user: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - .. code-block:: python from pyinaturalist.rest_api import get_access_token @@ -150,6 +148,17 @@ Sets an observation field value to an existing observation: value=250, access_token=token) +Get observation data in alternative formats: +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +A separate endpoint can provide other data formats, including Darwin Core, KML, and CSV: + +.. code-block:: python + + from pyinaturalist.rest_api import get_observations + obs = get_observations(user_id='niconoe', response_format='dwc') + +See `available parameters and formats `_. + Taxonomy ^^^^^^^^ diff --git a/pyinaturalist/api_requests.py b/pyinaturalist/api_requests.py index 381dea0d..6a5231ee 100644 --- a/pyinaturalist/api_requests.py +++ b/pyinaturalist/api_requests.py @@ -1,4 +1,4 @@ -# Some common functions for HTTP requests used by both the Node and REST API modules +""" Some common functions for HTTP requests used by both the Node and REST API modules """ from logging import getLogger from os import getenv from typing import Dict @@ -8,7 +8,7 @@ import pyinaturalist from pyinaturalist.constants import WRITE_HTTP_METHODS -from pyinaturalist.helpers import preprocess_request_params +from pyinaturalist.request_params import preprocess_request_params # Mock response content to return in dry-run mode MOCK_RESPONSE = Mock(spec=requests.Response) diff --git a/pyinaturalist/constants.py b/pyinaturalist/constants.py index 53262bfb..9e0f636f 100644 --- a/pyinaturalist/constants.py +++ b/pyinaturalist/constants.py @@ -1,6 +1,7 @@ INAT_NODE_API_BASE_URL = "https://api.inaturalist.org/v1/" INAT_BASE_URL = "https://www.inaturalist.org" +PER_PAGE_RESULTS = 30 # Number of records per page for paginated queries THROTTLING_DELAY = 1 # In seconds, support <1 floats such as 0.1 # Toggle dry-run mode: this will run and log mock HTTP requests instead of real ones @@ -28,6 +29,9 @@ "updated_since", # TODO: test if this one behaves differently in Node API vs REST API ] +# Reponse formats supported by GET /observations endpoint +# TODO: custom geojson FeatureCollection format +OBSERVATION_FORMATS = ["atom", "csv", "dwc", "json", "kml", "widget"] # Taxonomic ranks from Node API Swagger spec RANKS = [ diff --git a/pyinaturalist/helpers.py b/pyinaturalist/request_params.py similarity index 97% rename from pyinaturalist/helpers.py rename to pyinaturalist/request_params.py index 212ee351..0ff63500 100644 --- a/pyinaturalist/helpers.py +++ b/pyinaturalist/request_params.py @@ -1,3 +1,4 @@ +""" Helper functions for processing request parameters """ from datetime import date, datetime from typing import Any, Dict, Optional diff --git a/pyinaturalist/rest_api.py b/pyinaturalist/rest_api.py index 506b1617..8ed986d8 100644 --- a/pyinaturalist/rest_api.py +++ b/pyinaturalist/rest_api.py @@ -1,13 +1,40 @@ -# Code used to access the (read/write, but slow) Rails based API of iNaturalist -# See: https://www.inaturalist.org/pages/api+reference +""" +Code used to access the (read/write, but slow) Rails based API of iNaturalist +See: https://www.inaturalist.org/pages/api+reference +""" from time import sleep from typing import Dict, Any, List, BinaryIO, Union -from pyinaturalist.constants import THROTTLING_DELAY, INAT_BASE_URL +from urllib.parse import urljoin + +from pyinaturalist.constants import OBSERVATION_FORMATS, THROTTLING_DELAY, INAT_BASE_URL from pyinaturalist.exceptions import AuthenticationError, ObservationNotFound from pyinaturalist.api_requests import delete, get, post, put +# TODO: Docs, tests +def get_observations(response_format="json", user_agent: str = None, **params) -> Union[Dict, str]: + """Get observation data, optionally in an alternative format. Return type will be + ``dict`` for the ``json`` response format, and ``str`` for all others. + See: https://www.inaturalist.org/pages/api+reference#get-observations + + Example:: + + get_observations(id=45414404, format="dwc") + + """ + if response_format not in OBSERVATION_FORMATS: + raise ValueError("Invalid response format") + + response = get( + urljoin(INAT_BASE_URL, "observations.{}".format(response_format)), + params=params, + user_agent=user_agent, + ) + + return response.json() if response_format == "json" else response.text + + def get_observation_fields( search_query: str = "", page: int = 1, user_agent: str = None ) -> List[Dict[str, Any]]: diff --git a/test/conftest.py b/test/conftest.py index 7033390f..ac456478 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -48,6 +48,9 @@ def _sample_data_path(filename): return os.path.join(os.path.dirname(__file__), "sample_data", filename) -def load_sample_json(filename): +def load_sample_data(filename): with open(_sample_data_path(filename), encoding="utf-8") as fh: - return json.load(fh) + if filename.endswith("json"): + return json.load(fh) + else: + return fh.read() diff --git a/test/sample_data/get_observation.json b/test/sample_data/get_observation.json index 56d40677..c4f967f7 100644 --- a/test/sample_data/get_observation.json +++ b/test/sample_data/get_observation.json @@ -1 +1,3879 @@ -{"total_results":1,"page":1,"per_page":30,"results":[{"out_of_range":null,"quality_grade":"research","time_observed_at":"2018-09-05T14:06:00+02:00","annotations":[{"user_id":886482,"concatenated_attr_val":"1|2","controlled_attribute_id":1,"votes":[],"uuid":"14bbd34f-73f8-4b99-b591-8517913788a1","vote_score":0,"controlled_value_id":2,"user":{"id":886482,"login":"niconoe","spam":false,"suspended":false,"login_autocomplete":"niconoe","login_exact":"niconoe","name":"Nicolas Noé","name_autocomplete":"Nicolas Noé","icon":"https://static.inaturalist.org/attachments/users/icons/886482/thumb.jpg?1529671435","observations_count":223,"identifications_count":27,"journal_posts_count":0,"activity_count":250,"roles":[],"site_id":1,"icon_url":"https://static.inaturalist.org/attachments/users/icons/886482/medium.jpg?1529671435"}}],"uuid":"6448d03a-7f9a-4099-86aa-ca09a7740b00","photos":[{"attribution":"(c) Nicolas Noé, some rights reserved (CC BY)","flags":[],"id":24355315,"license_code":"cc-by","original_dimensions":{"width":1445,"height":1057},"url":"https://static.inaturalist.org/photos/24355315/square.jpeg?1536150664"},{"attribution":"(c) Nicolas Noé, some rights reserved (CC BY)","flags":[],"id":24355313,"license_code":"cc-by","original_dimensions":{"width":2048,"height":1364},"url":"https://static.inaturalist.org/photos/24355313/square.jpeg?1536150659"}],"observed_on_details":{"date":"2018-09-05","week":36,"month":9,"hour":14,"year":2018,"day":5},"id":16227955,"cached_votes_total":0,"identifications_most_agree":true,"created_at_details":{"date":"2018-09-05","week":36,"month":9,"hour":14,"year":2018,"day":5},"species_guess":"Lixus bardanae","identifications_most_disagree":false,"tags":[],"positional_accuracy":23,"comments_count":2,"site_id":1,"created_time_zone":"Europe/Paris","id_please":false,"license_code":"cc0","observed_time_zone":"Europe/Paris","quality_metrics":[],"public_positional_accuracy":23,"reviewed_by":[180811,886482,1226913],"oauth_application_id":null,"flags":[],"created_at":"2018-09-05T14:31:08+02:00","description":"","time_zone_offset":"+01:00","project_ids_with_curator_id":[],"observed_on":"2018-09-05","observed_on_string":"2018/09/05 2:06 PM CEST","updated_at":"2018-09-22T19:19:27+02:00","sounds":[],"place_ids":[7008,8657,14999,59614,67952,80627,81490,96372,96794,97391,97582,108692],"captive":false,"taxon":{"is_active":true,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383/71157","min_species_ancestry":"48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157,493595","endemic":false,"iconic_taxon_id":47158,"threatened":false,"rank_level":10,"introduced":false,"native":false,"parent_id":71157,"name":"Lixus bardanae","rank":"species","extinct":false,"id":493595,"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157,493595],"taxon_schemes_count":1,"wikipedia_url":null,"current_synonymous_taxon_ids":null,"created_at":"2016-04-25T22:35:20+00:00","taxon_changes_count":0,"complete_species_count":null,"observations_count":3,"flag_counts":{"unresolved":0,"resolved":0},"atlas_id":null,"default_photo":{"square_url":"https://static.inaturalist.org/photos/3480841/square.JPG?1461537518","attribution":"(c) Dimitǎr Boevski, some rights reserved (CC BY)","flags":[],"medium_url":"https://static.inaturalist.org/photos/3480841/medium.JPG?1461537518","id":3480841,"license_code":"cc-by","original_dimensions":{"width":1724,"height":1146},"url":"https://static.inaturalist.org/photos/3480841/square.JPG?1461537518"},"iconic_taxon_name":"Insecta"},"outlinks":[{"source":"GBIF","url":"http://www.gbif.org/occurrence/1914197587"}],"faves_count":0,"ofvs":[],"num_identification_agreements":2,"preferences":{"prefers_community_taxon":null},"comments":[{"flags":[],"created_at":"2018-09-05T16:03:40+00:00","id":2071896,"created_at_details":{"date":"2018-09-05","week":36,"month":9,"hour":16,"year":2018,"day":5},"body":"I now see: Bonus species on observation! You may make a duplicate . . . \n(Flea beetle Epitrix pubescens on Solanum bud) \n","uuid":"4d401d20-1b08-464a-8287-351f5b57443e","user":{"id":180811,"login":"borisb","spam":false,"suspended":false,"login_autocomplete":"borisb","login_exact":"borisb","name":"","name_autocomplete":"","icon":null,"observations_count":0,"identifications_count":105317,"journal_posts_count":0,"activity_count":105317,"roles":["curator"],"site_id":1}},{"flags":[],"created_at":"2018-09-05T14:08:09+00:00","id":2071611,"created_at_details":{"date":"2018-09-05","week":36,"month":9,"hour":14,"year":2018,"day":5},"body":"suspect L. bardanae - but sits on Solanum (non-host)","uuid":"e6fc62ea-e22b-4427-bb65-1ba3bc591c77","user":{"id":180811,"login":"borisb","spam":false,"suspended":false,"login_autocomplete":"borisb","login_exact":"borisb","name":"","name_autocomplete":"","icon":null,"observations_count":0,"identifications_count":105317,"journal_posts_count":0,"activity_count":105317,"roles":["curator"],"site_id":1}}],"map_scale":17,"uri":"https://www.inaturalist.org/observations/16227955","project_ids":[],"identifications":[{"disagreement":null,"flags":[],"created_at":"2018-09-05T12:34:22+00:00","taxon_id":71157,"body":"","own_observation":true,"uuid":"f7b99479-5778-44bb-a339-6b5af633724e","taxon_change":null,"vision":true,"current":true,"id":34896306,"created_at_details":{"date":"2018-09-05","week":36,"month":9,"hour":12,"year":2018,"day":5},"category":"improving","spam":false,"user":{"id":886482,"login":"niconoe","spam":false,"suspended":false,"login_autocomplete":"niconoe","login_exact":"niconoe","name":"Nicolas Noé","name_autocomplete":"Nicolas Noé","icon":"https://static.inaturalist.org/attachments/users/icons/886482/thumb.jpg?1529671435","observations_count":223,"identifications_count":27,"journal_posts_count":0,"activity_count":250,"roles":[],"site_id":1,"icon_url":"https://static.inaturalist.org/attachments/users/icons/886482/medium.jpg?1529671435"},"previous_observation_taxon_id":null,"taxon":{"taxon_schemes_count":1,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383","min_species_ancestry":"48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157","wikipedia_url":"http://en.wikipedia.org/wiki/Lixus_(beetle)","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"created_at":"2011-05-11T05:36:06+00:00","taxon_changes_count":0,"complete_species_count":null,"rank":"genus","extinct":false,"id":71157,"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383],"observations_count":618,"is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"rank_level":20,"atlas_id":null,"parent_id":507383,"name":"Lixus","default_photo":{"square_url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg","attribution":"(c) Yvan, some rights reserved (CC BY-NC-SA)","flags":[],"medium_url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03.jpg","id":31486,"license_code":"cc-by-nc-sa","original_dimensions":null,"url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg"},"iconic_taxon_name":"Insecta","ancestors":[{"observations_count":9030721,"taxon_schemes_count":2,"ancestry":"48460","is_active":true,"flag_counts":{"unresolved":0,"resolved":5},"wikipedia_url":"http://en.wikipedia.org/wiki/Animal","current_synonymous_taxon_ids":null,"iconic_taxon_id":1,"rank_level":70,"taxon_changes_count":3,"atlas_id":null,"complete_species_count":null,"parent_id":48460,"name":"Animalia","rank":"kingdom","extinct":false,"id":1,"default_photo":{"square_url":"https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg","attribution":"(c) David Midgley, some rights reserved (CC BY-NC-ND)","flags":[],"medium_url":"https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b.jpg","id":169,"license_code":"cc-by-nc-nd","original_dimensions":null,"url":"https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg"},"ancestor_ids":[48460,1],"iconic_taxon_name":"Animalia","preferred_common_name":"Animals"},{"observations_count":4232787,"taxon_schemes_count":2,"ancestry":"48460/1","is_active":true,"flag_counts":{"unresolved":0,"resolved":1},"wikipedia_url":"http://en.wikipedia.org/wiki/Arthropod","current_synonymous_taxon_ids":null,"iconic_taxon_id":1,"rank_level":60,"taxon_changes_count":2,"atlas_id":null,"complete_species_count":null,"parent_id":1,"name":"Arthropoda","rank":"phylum","extinct":false,"id":47120,"default_photo":{"square_url":"https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg","attribution":"(c) Damien du Toit, some rights reserved (CC BY)","flags":[],"medium_url":"https://farm1.staticflickr.com/1/380353_028542ead3.jpg","id":4115,"license_code":"cc-by","original_dimensions":null,"url":"https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg"},"ancestor_ids":[48460,1,47120],"iconic_taxon_name":"Animalia","preferred_common_name":"Arthropods"},{"observations_count":3701511,"taxon_schemes_count":2,"ancestry":"48460/1/47120","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Hexapoda","current_synonymous_taxon_ids":null,"iconic_taxon_id":1,"rank_level":57,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":47120,"name":"Hexapoda","rank":"subphylum","extinct":false,"id":372739,"default_photo":{"square_url":"https://static.inaturalist.org/photos/1205428/square.?1413275622","attribution":"(c) heni, all rights reserved, uploaded by Jane Percival","flags":[],"medium_url":"https://static.inaturalist.org/photos/1205428/medium.?1413275622","id":1205428,"license_code":null,"original_dimensions":{"width":790,"height":557},"url":"https://static.inaturalist.org/photos/1205428/square.?1413275622"},"ancestor_ids":[48460,1,47120,372739],"iconic_taxon_name":"Animalia","preferred_common_name":"Hexapods"},{"observations_count":3696904,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739","is_active":true,"flag_counts":{"unresolved":0,"resolved":2},"wikipedia_url":"http://en.wikipedia.org/wiki/Insect","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":50,"taxon_changes_count":2,"atlas_id":null,"complete_species_count":null,"parent_id":372739,"name":"Insecta","rank":"class","extinct":false,"id":47158,"default_photo":{"square_url":"https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739","attribution":"(c) Jason Michael Crockwell, some rights reserved (CC BY-NC-ND)","flags":[],"medium_url":"https://static.inaturalist.org/photos/4744725/medium.jpeg?1472813739","id":4744725,"license_code":"cc-by-nc-nd","original_dimensions":{"width":2048,"height":1536},"url":"https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739"},"ancestor_ids":[48460,1,47120,372739,47158],"iconic_taxon_name":"Insecta","preferred_common_name":"Insects"},{"observations_count":3659996,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158","is_active":true,"flag_counts":{"unresolved":0,"resolved":2},"wikipedia_url":"http://en.wikipedia.org/wiki/Pterygota","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":47,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":47158,"name":"Pterygota","rank":"subclass","extinct":false,"id":184884,"default_photo":{"square_url":"https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg","attribution":"(c) gbohne, some rights reserved (CC BY-SA)","flags":[],"medium_url":"https://farm5.staticflickr.com/4126/5064146527_95d67d2cae.jpg","id":1670296,"license_code":"cc-by-sa","original_dimensions":null,"url":"https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884],"iconic_taxon_name":"Insecta","preferred_common_name":"Winged and Once-winged Insects"},{"observations_count":410369,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Beetle","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":40,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":184884,"name":"Coleoptera","rank":"order","extinct":false,"id":47208,"default_photo":{"square_url":"https://static.inaturalist.org/photos/1077943/square.jpg?1444320780","attribution":"(c) Jay Keller, all rights reserved, uploaded by Jay L. Keller","flags":[],"medium_url":"https://static.inaturalist.org/photos/1077943/medium.jpg?1444320780","id":1077943,"license_code":null,"original_dimensions":{"width":1711,"height":1679},"url":"https://static.inaturalist.org/photos/1077943/square.jpg?1444320780"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208],"iconic_taxon_name":"Insecta","preferred_common_name":"Beetles"},{"observations_count":349335,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Polyphaga","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":37,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":47208,"name":"Polyphaga","rank":"suborder","extinct":false,"id":71130,"default_photo":{"square_url":"https://static.inaturalist.org/photos/12466261/square.jpg?1513752231","attribution":"(c) Arnold Wijker, some rights reserved (CC BY-NC)","flags":[],"medium_url":"https://static.inaturalist.org/photos/12466261/medium.jpg?1513752231","id":12466261,"license_code":"cc-by-nc","original_dimensions":{"width":800,"height":714},"url":"https://static.inaturalist.org/photos/12466261/square.jpg?1513752231"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130],"iconic_taxon_name":"Insecta","preferred_common_name":"Water, Rove, Scarab, Long-horned, Leaf, and Snout Beetles"},{"observations_count":222277,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Cucujiformia","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":35,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":71130,"name":"Cucujiformia","rank":"infraorder","extinct":false,"id":372852,"default_photo":{"square_url":"https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg","attribution":"(c) Udo Schmidt, some rights reserved (CC BY-SA)","flags":[],"medium_url":"https://farm5.staticflickr.com/4014/5142992820_7b86576c37.jpg","id":1182110,"license_code":"cc-by-sa","original_dimensions":null,"url":"https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852],"iconic_taxon_name":"Insecta","preferred_common_name":"Cucujiform Beetles"},{"observations_count":25765,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852","is_active":true,"flag_counts":{"unresolved":0,"resolved":1},"wikipedia_url":"http://en.wikipedia.org/wiki/Weevil","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":33,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":372852,"name":"Curculionoidea","rank":"superfamily","extinct":false,"id":60473,"default_photo":{"square_url":"https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg","attribution":"(c) Mick Talbot, some rights reserved (CC BY)","flags":[],"medium_url":"https://farm3.staticflickr.com/2535/3729552832_4a436b62d9.jpg","id":99067,"license_code":"cc-by","original_dimensions":null,"url":"https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473],"iconic_taxon_name":"Insecta","preferred_common_name":"Snout and Bark Beetles"},{"observations_count":19478,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Curculionidae","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":30,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":60473,"name":"Curculionidae","rank":"family","extinct":false,"id":48736,"default_photo":{"square_url":"https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg","attribution":"(c) Mick Talbot, some rights reserved (CC BY)","flags":[],"medium_url":"https://farm4.staticflickr.com/3174/2891245226_93744353d6.jpg","id":24438,"license_code":"cc-by","original_dimensions":null,"url":"https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736],"iconic_taxon_name":"Insecta","preferred_common_name":"True Weevils"},{"observations_count":1593,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Lixinae","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":27,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":48736,"name":"Lixinae","rank":"subfamily","extinct":false,"id":272543,"default_photo":{"square_url":"https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg","attribution":"(c) Ferran Turmo Gort, some rights reserved (CC BY-NC-SA)","flags":[],"medium_url":"https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82.jpg","id":3094335,"license_code":"cc-by-nc-sa","original_dimensions":null,"url":"https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543],"iconic_taxon_name":"Insecta"},{"observations_count":1093,"taxon_schemes_count":1,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":null,"current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":25,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":272543,"name":"Lixini","rank":"tribe","extinct":false,"id":507383,"default_photo":{"square_url":"https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg","attribution":"(c) Alexey Kljatov, some rights reserved (CC BY-NC)","flags":[],"medium_url":"https://farm6.staticflickr.com/5092/5528807700_9da380c55d.jpg","id":5744395,"license_code":"cc-by-nc","original_dimensions":null,"url":"https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383],"iconic_taxon_name":"Insecta"}]}},{"disagreement":false,"flags":[],"created_at":"2018-09-05T22:37:06+00:00","taxon_id":493595,"body":"","own_observation":false,"uuid":"98d454e4-bead-473d-a372-f35b8ba5bf1c","taxon_change":null,"vision":false,"current":true,"id":34926789,"created_at_details":{"date":"2018-09-05","week":36,"month":9,"hour":22,"year":2018,"day":5},"category":"improving","spam":false,"user":{"id":180811,"login":"borisb","spam":false,"suspended":false,"login_autocomplete":"borisb","login_exact":"borisb","name":"","name_autocomplete":"","icon":null,"observations_count":0,"identifications_count":105317,"journal_posts_count":0,"activity_count":105317,"roles":["curator"],"site_id":1},"previous_observation_taxon_id":71157,"taxon":{"taxon_schemes_count":1,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383/71157","min_species_ancestry":"48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157,493595","wikipedia_url":null,"current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"created_at":"2016-04-25T22:35:20+00:00","taxon_changes_count":0,"complete_species_count":null,"rank":"species","extinct":false,"id":493595,"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157],"observations_count":3,"is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"rank_level":10,"atlas_id":null,"parent_id":71157,"name":"Lixus bardanae","default_photo":{"square_url":"https://static.inaturalist.org/photos/3480841/square.JPG?1461537518","attribution":"(c) Dimitǎr Boevski, some rights reserved (CC BY)","flags":[],"medium_url":"https://static.inaturalist.org/photos/3480841/medium.JPG?1461537518","id":3480841,"license_code":"cc-by","original_dimensions":{"width":1724,"height":1146},"url":"https://static.inaturalist.org/photos/3480841/square.JPG?1461537518"},"iconic_taxon_name":"Insecta","ancestors":[{"observations_count":9030721,"taxon_schemes_count":2,"ancestry":"48460","is_active":true,"flag_counts":{"unresolved":0,"resolved":5},"wikipedia_url":"http://en.wikipedia.org/wiki/Animal","current_synonymous_taxon_ids":null,"iconic_taxon_id":1,"rank_level":70,"taxon_changes_count":3,"atlas_id":null,"complete_species_count":null,"parent_id":48460,"name":"Animalia","rank":"kingdom","extinct":false,"id":1,"default_photo":{"square_url":"https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg","attribution":"(c) David Midgley, some rights reserved (CC BY-NC-ND)","flags":[],"medium_url":"https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b.jpg","id":169,"license_code":"cc-by-nc-nd","original_dimensions":null,"url":"https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg"},"ancestor_ids":[48460,1],"iconic_taxon_name":"Animalia","preferred_common_name":"Animals"},{"observations_count":4232787,"taxon_schemes_count":2,"ancestry":"48460/1","is_active":true,"flag_counts":{"unresolved":0,"resolved":1},"wikipedia_url":"http://en.wikipedia.org/wiki/Arthropod","current_synonymous_taxon_ids":null,"iconic_taxon_id":1,"rank_level":60,"taxon_changes_count":2,"atlas_id":null,"complete_species_count":null,"parent_id":1,"name":"Arthropoda","rank":"phylum","extinct":false,"id":47120,"default_photo":{"square_url":"https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg","attribution":"(c) Damien du Toit, some rights reserved (CC BY)","flags":[],"medium_url":"https://farm1.staticflickr.com/1/380353_028542ead3.jpg","id":4115,"license_code":"cc-by","original_dimensions":null,"url":"https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg"},"ancestor_ids":[48460,1,47120],"iconic_taxon_name":"Animalia","preferred_common_name":"Arthropods"},{"observations_count":3701511,"taxon_schemes_count":2,"ancestry":"48460/1/47120","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Hexapoda","current_synonymous_taxon_ids":null,"iconic_taxon_id":1,"rank_level":57,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":47120,"name":"Hexapoda","rank":"subphylum","extinct":false,"id":372739,"default_photo":{"square_url":"https://static.inaturalist.org/photos/1205428/square.?1413275622","attribution":"(c) heni, all rights reserved, uploaded by Jane Percival","flags":[],"medium_url":"https://static.inaturalist.org/photos/1205428/medium.?1413275622","id":1205428,"license_code":null,"original_dimensions":{"width":790,"height":557},"url":"https://static.inaturalist.org/photos/1205428/square.?1413275622"},"ancestor_ids":[48460,1,47120,372739],"iconic_taxon_name":"Animalia","preferred_common_name":"Hexapods"},{"observations_count":3696904,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739","is_active":true,"flag_counts":{"unresolved":0,"resolved":2},"wikipedia_url":"http://en.wikipedia.org/wiki/Insect","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":50,"taxon_changes_count":2,"atlas_id":null,"complete_species_count":null,"parent_id":372739,"name":"Insecta","rank":"class","extinct":false,"id":47158,"default_photo":{"square_url":"https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739","attribution":"(c) Jason Michael Crockwell, some rights reserved (CC BY-NC-ND)","flags":[],"medium_url":"https://static.inaturalist.org/photos/4744725/medium.jpeg?1472813739","id":4744725,"license_code":"cc-by-nc-nd","original_dimensions":{"width":2048,"height":1536},"url":"https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739"},"ancestor_ids":[48460,1,47120,372739,47158],"iconic_taxon_name":"Insecta","preferred_common_name":"Insects"},{"observations_count":3659996,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158","is_active":true,"flag_counts":{"unresolved":0,"resolved":2},"wikipedia_url":"http://en.wikipedia.org/wiki/Pterygota","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":47,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":47158,"name":"Pterygota","rank":"subclass","extinct":false,"id":184884,"default_photo":{"square_url":"https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg","attribution":"(c) gbohne, some rights reserved (CC BY-SA)","flags":[],"medium_url":"https://farm5.staticflickr.com/4126/5064146527_95d67d2cae.jpg","id":1670296,"license_code":"cc-by-sa","original_dimensions":null,"url":"https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884],"iconic_taxon_name":"Insecta","preferred_common_name":"Winged and Once-winged Insects"},{"observations_count":410369,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Beetle","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":40,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":184884,"name":"Coleoptera","rank":"order","extinct":false,"id":47208,"default_photo":{"square_url":"https://static.inaturalist.org/photos/1077943/square.jpg?1444320780","attribution":"(c) Jay Keller, all rights reserved, uploaded by Jay L. Keller","flags":[],"medium_url":"https://static.inaturalist.org/photos/1077943/medium.jpg?1444320780","id":1077943,"license_code":null,"original_dimensions":{"width":1711,"height":1679},"url":"https://static.inaturalist.org/photos/1077943/square.jpg?1444320780"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208],"iconic_taxon_name":"Insecta","preferred_common_name":"Beetles"},{"observations_count":349335,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Polyphaga","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":37,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":47208,"name":"Polyphaga","rank":"suborder","extinct":false,"id":71130,"default_photo":{"square_url":"https://static.inaturalist.org/photos/12466261/square.jpg?1513752231","attribution":"(c) Arnold Wijker, some rights reserved (CC BY-NC)","flags":[],"medium_url":"https://static.inaturalist.org/photos/12466261/medium.jpg?1513752231","id":12466261,"license_code":"cc-by-nc","original_dimensions":{"width":800,"height":714},"url":"https://static.inaturalist.org/photos/12466261/square.jpg?1513752231"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130],"iconic_taxon_name":"Insecta","preferred_common_name":"Water, Rove, Scarab, Long-horned, Leaf, and Snout Beetles"},{"observations_count":222277,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Cucujiformia","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":35,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":71130,"name":"Cucujiformia","rank":"infraorder","extinct":false,"id":372852,"default_photo":{"square_url":"https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg","attribution":"(c) Udo Schmidt, some rights reserved (CC BY-SA)","flags":[],"medium_url":"https://farm5.staticflickr.com/4014/5142992820_7b86576c37.jpg","id":1182110,"license_code":"cc-by-sa","original_dimensions":null,"url":"https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852],"iconic_taxon_name":"Insecta","preferred_common_name":"Cucujiform Beetles"},{"observations_count":25765,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852","is_active":true,"flag_counts":{"unresolved":0,"resolved":1},"wikipedia_url":"http://en.wikipedia.org/wiki/Weevil","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":33,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":372852,"name":"Curculionoidea","rank":"superfamily","extinct":false,"id":60473,"default_photo":{"square_url":"https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg","attribution":"(c) Mick Talbot, some rights reserved (CC BY)","flags":[],"medium_url":"https://farm3.staticflickr.com/2535/3729552832_4a436b62d9.jpg","id":99067,"license_code":"cc-by","original_dimensions":null,"url":"https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473],"iconic_taxon_name":"Insecta","preferred_common_name":"Snout and Bark Beetles"},{"observations_count":19478,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Curculionidae","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":30,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":60473,"name":"Curculionidae","rank":"family","extinct":false,"id":48736,"default_photo":{"square_url":"https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg","attribution":"(c) Mick Talbot, some rights reserved (CC BY)","flags":[],"medium_url":"https://farm4.staticflickr.com/3174/2891245226_93744353d6.jpg","id":24438,"license_code":"cc-by","original_dimensions":null,"url":"https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736],"iconic_taxon_name":"Insecta","preferred_common_name":"True Weevils"},{"observations_count":1593,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Lixinae","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":27,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":48736,"name":"Lixinae","rank":"subfamily","extinct":false,"id":272543,"default_photo":{"square_url":"https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg","attribution":"(c) Ferran Turmo Gort, some rights reserved (CC BY-NC-SA)","flags":[],"medium_url":"https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82.jpg","id":3094335,"license_code":"cc-by-nc-sa","original_dimensions":null,"url":"https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543],"iconic_taxon_name":"Insecta"},{"observations_count":1093,"taxon_schemes_count":1,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":null,"current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":25,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":272543,"name":"Lixini","rank":"tribe","extinct":false,"id":507383,"default_photo":{"square_url":"https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg","attribution":"(c) Alexey Kljatov, some rights reserved (CC BY-NC)","flags":[],"medium_url":"https://farm6.staticflickr.com/5092/5528807700_9da380c55d.jpg","id":5744395,"license_code":"cc-by-nc","original_dimensions":null,"url":"https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383],"iconic_taxon_name":"Insecta"},{"observations_count":618,"taxon_schemes_count":1,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Lixus_(beetle)","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":20,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":507383,"name":"Lixus","rank":"genus","extinct":false,"id":71157,"default_photo":{"square_url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg","attribution":"(c) Yvan, some rights reserved (CC BY-NC-SA)","flags":[],"medium_url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03.jpg","id":31486,"license_code":"cc-by-nc-sa","original_dimensions":null,"url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157],"iconic_taxon_name":"Insecta"}]},"previous_observation_taxon":{"taxon_schemes_count":1,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383","min_species_ancestry":"48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157","wikipedia_url":"http://en.wikipedia.org/wiki/Lixus_(beetle)","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"created_at":"2011-05-11T05:36:06+00:00","taxon_changes_count":0,"complete_species_count":null,"rank":"genus","extinct":false,"id":71157,"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157],"observations_count":618,"is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"rank_level":20,"atlas_id":null,"parent_id":507383,"name":"Lixus","default_photo":{"square_url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg","attribution":"(c) Yvan, some rights reserved (CC BY-NC-SA)","flags":[],"medium_url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03.jpg","id":31486,"license_code":"cc-by-nc-sa","original_dimensions":null,"url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg"},"iconic_taxon_name":"Insecta"}},{"disagreement":false,"flags":[],"created_at":"2018-09-22T17:19:26+00:00","taxon_id":493595,"body":null,"own_observation":false,"uuid":"2c245e63-c1fd-4295-9b95-1d06c339d9a5","taxon_change":null,"vision":false,"current":true,"id":36039221,"created_at_details":{"date":"2018-09-22","week":38,"month":9,"hour":17,"year":2018,"day":22},"category":"supporting","spam":false,"user":{"id":1226913,"login":"jpreudhomme","spam":false,"suspended":false,"login_autocomplete":"jpreudhomme","login_exact":"jpreudhomme","name":"jupreudhomme","name_autocomplete":"jupreudhomme","icon":"https://static.inaturalist.org/attachments/users/icons/1226913/thumb.jpeg?1537623104","observations_count":4,"identifications_count":201,"journal_posts_count":0,"activity_count":205,"roles":[],"site_id":1,"icon_url":"https://static.inaturalist.org/attachments/users/icons/1226913/medium.jpeg?1537623104"},"previous_observation_taxon_id":493595,"taxon":{"taxon_schemes_count":1,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383/71157","min_species_ancestry":"48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157,493595","wikipedia_url":null,"current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"created_at":"2016-04-25T22:35:20+00:00","taxon_changes_count":0,"complete_species_count":null,"rank":"species","extinct":false,"id":493595,"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157],"observations_count":3,"is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"rank_level":10,"atlas_id":null,"parent_id":71157,"name":"Lixus bardanae","default_photo":{"square_url":"https://static.inaturalist.org/photos/3480841/square.JPG?1461537518","attribution":"(c) Dimitǎr Boevski, some rights reserved (CC BY)","flags":[],"medium_url":"https://static.inaturalist.org/photos/3480841/medium.JPG?1461537518","id":3480841,"license_code":"cc-by","original_dimensions":{"width":1724,"height":1146},"url":"https://static.inaturalist.org/photos/3480841/square.JPG?1461537518"},"iconic_taxon_name":"Insecta","ancestors":[{"observations_count":9030721,"taxon_schemes_count":2,"ancestry":"48460","is_active":true,"flag_counts":{"unresolved":0,"resolved":5},"wikipedia_url":"http://en.wikipedia.org/wiki/Animal","current_synonymous_taxon_ids":null,"iconic_taxon_id":1,"rank_level":70,"taxon_changes_count":3,"atlas_id":null,"complete_species_count":null,"parent_id":48460,"name":"Animalia","rank":"kingdom","extinct":false,"id":1,"default_photo":{"square_url":"https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg","attribution":"(c) David Midgley, some rights reserved (CC BY-NC-ND)","flags":[],"medium_url":"https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b.jpg","id":169,"license_code":"cc-by-nc-nd","original_dimensions":null,"url":"https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg"},"ancestor_ids":[48460,1],"iconic_taxon_name":"Animalia","preferred_common_name":"Animals"},{"observations_count":4232787,"taxon_schemes_count":2,"ancestry":"48460/1","is_active":true,"flag_counts":{"unresolved":0,"resolved":1},"wikipedia_url":"http://en.wikipedia.org/wiki/Arthropod","current_synonymous_taxon_ids":null,"iconic_taxon_id":1,"rank_level":60,"taxon_changes_count":2,"atlas_id":null,"complete_species_count":null,"parent_id":1,"name":"Arthropoda","rank":"phylum","extinct":false,"id":47120,"default_photo":{"square_url":"https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg","attribution":"(c) Damien du Toit, some rights reserved (CC BY)","flags":[],"medium_url":"https://farm1.staticflickr.com/1/380353_028542ead3.jpg","id":4115,"license_code":"cc-by","original_dimensions":null,"url":"https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg"},"ancestor_ids":[48460,1,47120],"iconic_taxon_name":"Animalia","preferred_common_name":"Arthropods"},{"observations_count":3701511,"taxon_schemes_count":2,"ancestry":"48460/1/47120","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Hexapoda","current_synonymous_taxon_ids":null,"iconic_taxon_id":1,"rank_level":57,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":47120,"name":"Hexapoda","rank":"subphylum","extinct":false,"id":372739,"default_photo":{"square_url":"https://static.inaturalist.org/photos/1205428/square.?1413275622","attribution":"(c) heni, all rights reserved, uploaded by Jane Percival","flags":[],"medium_url":"https://static.inaturalist.org/photos/1205428/medium.?1413275622","id":1205428,"license_code":null,"original_dimensions":{"width":790,"height":557},"url":"https://static.inaturalist.org/photos/1205428/square.?1413275622"},"ancestor_ids":[48460,1,47120,372739],"iconic_taxon_name":"Animalia","preferred_common_name":"Hexapods"},{"observations_count":3696904,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739","is_active":true,"flag_counts":{"unresolved":0,"resolved":2},"wikipedia_url":"http://en.wikipedia.org/wiki/Insect","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":50,"taxon_changes_count":2,"atlas_id":null,"complete_species_count":null,"parent_id":372739,"name":"Insecta","rank":"class","extinct":false,"id":47158,"default_photo":{"square_url":"https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739","attribution":"(c) Jason Michael Crockwell, some rights reserved (CC BY-NC-ND)","flags":[],"medium_url":"https://static.inaturalist.org/photos/4744725/medium.jpeg?1472813739","id":4744725,"license_code":"cc-by-nc-nd","original_dimensions":{"width":2048,"height":1536},"url":"https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739"},"ancestor_ids":[48460,1,47120,372739,47158],"iconic_taxon_name":"Insecta","preferred_common_name":"Insects"},{"observations_count":3659996,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158","is_active":true,"flag_counts":{"unresolved":0,"resolved":2},"wikipedia_url":"http://en.wikipedia.org/wiki/Pterygota","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":47,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":47158,"name":"Pterygota","rank":"subclass","extinct":false,"id":184884,"default_photo":{"square_url":"https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg","attribution":"(c) gbohne, some rights reserved (CC BY-SA)","flags":[],"medium_url":"https://farm5.staticflickr.com/4126/5064146527_95d67d2cae.jpg","id":1670296,"license_code":"cc-by-sa","original_dimensions":null,"url":"https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884],"iconic_taxon_name":"Insecta","preferred_common_name":"Winged and Once-winged Insects"},{"observations_count":410369,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Beetle","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":40,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":184884,"name":"Coleoptera","rank":"order","extinct":false,"id":47208,"default_photo":{"square_url":"https://static.inaturalist.org/photos/1077943/square.jpg?1444320780","attribution":"(c) Jay Keller, all rights reserved, uploaded by Jay L. Keller","flags":[],"medium_url":"https://static.inaturalist.org/photos/1077943/medium.jpg?1444320780","id":1077943,"license_code":null,"original_dimensions":{"width":1711,"height":1679},"url":"https://static.inaturalist.org/photos/1077943/square.jpg?1444320780"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208],"iconic_taxon_name":"Insecta","preferred_common_name":"Beetles"},{"observations_count":349335,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Polyphaga","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":37,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":47208,"name":"Polyphaga","rank":"suborder","extinct":false,"id":71130,"default_photo":{"square_url":"https://static.inaturalist.org/photos/12466261/square.jpg?1513752231","attribution":"(c) Arnold Wijker, some rights reserved (CC BY-NC)","flags":[],"medium_url":"https://static.inaturalist.org/photos/12466261/medium.jpg?1513752231","id":12466261,"license_code":"cc-by-nc","original_dimensions":{"width":800,"height":714},"url":"https://static.inaturalist.org/photos/12466261/square.jpg?1513752231"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130],"iconic_taxon_name":"Insecta","preferred_common_name":"Water, Rove, Scarab, Long-horned, Leaf, and Snout Beetles"},{"observations_count":222277,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Cucujiformia","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":35,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":71130,"name":"Cucujiformia","rank":"infraorder","extinct":false,"id":372852,"default_photo":{"square_url":"https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg","attribution":"(c) Udo Schmidt, some rights reserved (CC BY-SA)","flags":[],"medium_url":"https://farm5.staticflickr.com/4014/5142992820_7b86576c37.jpg","id":1182110,"license_code":"cc-by-sa","original_dimensions":null,"url":"https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852],"iconic_taxon_name":"Insecta","preferred_common_name":"Cucujiform Beetles"},{"observations_count":25765,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852","is_active":true,"flag_counts":{"unresolved":0,"resolved":1},"wikipedia_url":"http://en.wikipedia.org/wiki/Weevil","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":33,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":372852,"name":"Curculionoidea","rank":"superfamily","extinct":false,"id":60473,"default_photo":{"square_url":"https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg","attribution":"(c) Mick Talbot, some rights reserved (CC BY)","flags":[],"medium_url":"https://farm3.staticflickr.com/2535/3729552832_4a436b62d9.jpg","id":99067,"license_code":"cc-by","original_dimensions":null,"url":"https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473],"iconic_taxon_name":"Insecta","preferred_common_name":"Snout and Bark Beetles"},{"observations_count":19478,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Curculionidae","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":30,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":60473,"name":"Curculionidae","rank":"family","extinct":false,"id":48736,"default_photo":{"square_url":"https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg","attribution":"(c) Mick Talbot, some rights reserved (CC BY)","flags":[],"medium_url":"https://farm4.staticflickr.com/3174/2891245226_93744353d6.jpg","id":24438,"license_code":"cc-by","original_dimensions":null,"url":"https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736],"iconic_taxon_name":"Insecta","preferred_common_name":"True Weevils"},{"observations_count":1593,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Lixinae","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":27,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":48736,"name":"Lixinae","rank":"subfamily","extinct":false,"id":272543,"default_photo":{"square_url":"https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg","attribution":"(c) Ferran Turmo Gort, some rights reserved (CC BY-NC-SA)","flags":[],"medium_url":"https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82.jpg","id":3094335,"license_code":"cc-by-nc-sa","original_dimensions":null,"url":"https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543],"iconic_taxon_name":"Insecta"},{"observations_count":1093,"taxon_schemes_count":1,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":null,"current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":25,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":272543,"name":"Lixini","rank":"tribe","extinct":false,"id":507383,"default_photo":{"square_url":"https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg","attribution":"(c) Alexey Kljatov, some rights reserved (CC BY-NC)","flags":[],"medium_url":"https://farm6.staticflickr.com/5092/5528807700_9da380c55d.jpg","id":5744395,"license_code":"cc-by-nc","original_dimensions":null,"url":"https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383],"iconic_taxon_name":"Insecta"},{"observations_count":618,"taxon_schemes_count":1,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Lixus_(beetle)","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":20,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":507383,"name":"Lixus","rank":"genus","extinct":false,"id":71157,"default_photo":{"square_url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg","attribution":"(c) Yvan, some rights reserved (CC BY-NC-SA)","flags":[],"medium_url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03.jpg","id":31486,"license_code":"cc-by-nc-sa","original_dimensions":null,"url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157],"iconic_taxon_name":"Insecta"}]},"previous_observation_taxon":{"taxon_schemes_count":1,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383/71157","min_species_ancestry":"48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157,493595","wikipedia_url":null,"current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"created_at":"2016-04-25T22:35:20+00:00","taxon_changes_count":0,"complete_species_count":null,"rank":"species","extinct":false,"id":493595,"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157,493595],"observations_count":3,"is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"rank_level":10,"atlas_id":null,"parent_id":71157,"name":"Lixus bardanae","default_photo":{"square_url":"https://static.inaturalist.org/photos/3480841/square.JPG?1461537518","attribution":"(c) Dimitǎr Boevski, some rights reserved (CC BY)","flags":[],"medium_url":"https://static.inaturalist.org/photos/3480841/medium.JPG?1461537518","id":3480841,"license_code":"cc-by","original_dimensions":{"width":1724,"height":1146},"url":"https://static.inaturalist.org/photos/3480841/square.JPG?1461537518"},"iconic_taxon_name":"Insecta"}}],"community_taxon_id":493595,"geojson":{"coordinates":["4.360086","50.646894"],"type":"Point"},"owners_identification_from_vision":true,"identifications_count":2,"obscured":false,"project_observations":[],"num_identification_disagreements":0,"observation_photos":[{"id":22080796,"position":0,"uuid":"76b54495-3497-4e96-b07c-7ad346939a02","photo":{"attribution":"(c) Nicolas Noé, some rights reserved (CC BY)","flags":[],"id":24355315,"license_code":"cc-by","original_dimensions":{"width":1445,"height":1057},"url":"https://static.inaturalist.org/photos/24355315/square.jpeg?1536150664"}},{"id":22080797,"position":1,"uuid":"06b90a88-d98d-4447-b14c-5d354d2d68d1","photo":{"attribution":"(c) Nicolas Noé, some rights reserved (CC BY)","flags":[],"id":24355313,"license_code":"cc-by","original_dimensions":{"width":2048,"height":1364},"url":"https://static.inaturalist.org/photos/24355313/square.jpeg?1536150659"}}],"geoprivacy":null,"location":"50.646894,4.360086","votes":[],"spam":false,"user":{"id":886482,"login":"niconoe","spam":false,"suspended":false,"login_autocomplete":"niconoe","login_exact":"niconoe","name":"Nicolas Noé","name_autocomplete":"Nicolas Noé","icon":"https://static.inaturalist.org/attachments/users/icons/886482/thumb.jpg?1529671435","observations_count":223,"identifications_count":27,"journal_posts_count":0,"activity_count":250,"roles":[],"site_id":1,"icon_url":"https://static.inaturalist.org/attachments/users/icons/886482/medium.jpg?1529671435"},"mappable":true,"identifications_some_agree":true,"project_ids_without_curator_id":[],"place_guess":"54 rue des Badauds","faves":[],"non_owner_ids":[{"disagreement":false,"flags":[],"created_at":"2018-09-05T22:37:06+00:00","taxon_id":493595,"body":"","own_observation":false,"uuid":"98d454e4-bead-473d-a372-f35b8ba5bf1c","taxon_change":null,"vision":false,"current":true,"id":34926789,"created_at_details":{"date":"2018-09-05","week":36,"month":9,"hour":22,"year":2018,"day":5},"category":"improving","spam":false,"user":{"id":180811,"login":"borisb","spam":false,"suspended":false,"login_autocomplete":"borisb","login_exact":"borisb","name":"","name_autocomplete":"","icon":null,"observations_count":0,"identifications_count":105317,"journal_posts_count":0,"activity_count":105317,"roles":["curator"],"site_id":1},"previous_observation_taxon_id":71157,"taxon":{"taxon_schemes_count":1,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383/71157","min_species_ancestry":"48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157,493595","wikipedia_url":null,"current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"created_at":"2016-04-25T22:35:20+00:00","taxon_changes_count":0,"complete_species_count":null,"rank":"species","extinct":false,"id":493595,"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157],"observations_count":3,"is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"rank_level":10,"atlas_id":null,"parent_id":71157,"name":"Lixus bardanae","default_photo":{"square_url":"https://static.inaturalist.org/photos/3480841/square.JPG?1461537518","attribution":"(c) Dimitǎr Boevski, some rights reserved (CC BY)","flags":[],"medium_url":"https://static.inaturalist.org/photos/3480841/medium.JPG?1461537518","id":3480841,"license_code":"cc-by","original_dimensions":{"width":1724,"height":1146},"url":"https://static.inaturalist.org/photos/3480841/square.JPG?1461537518"},"iconic_taxon_name":"Insecta","ancestors":[{"observations_count":9030721,"taxon_schemes_count":2,"ancestry":"48460","is_active":true,"flag_counts":{"unresolved":0,"resolved":5},"wikipedia_url":"http://en.wikipedia.org/wiki/Animal","current_synonymous_taxon_ids":null,"iconic_taxon_id":1,"rank_level":70,"taxon_changes_count":3,"atlas_id":null,"complete_species_count":null,"parent_id":48460,"name":"Animalia","rank":"kingdom","extinct":false,"id":1,"default_photo":{"square_url":"https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg","attribution":"(c) David Midgley, some rights reserved (CC BY-NC-ND)","flags":[],"medium_url":"https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b.jpg","id":169,"license_code":"cc-by-nc-nd","original_dimensions":null,"url":"https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg"},"ancestor_ids":[48460,1],"iconic_taxon_name":"Animalia","preferred_common_name":"Animals"},{"observations_count":4232787,"taxon_schemes_count":2,"ancestry":"48460/1","is_active":true,"flag_counts":{"unresolved":0,"resolved":1},"wikipedia_url":"http://en.wikipedia.org/wiki/Arthropod","current_synonymous_taxon_ids":null,"iconic_taxon_id":1,"rank_level":60,"taxon_changes_count":2,"atlas_id":null,"complete_species_count":null,"parent_id":1,"name":"Arthropoda","rank":"phylum","extinct":false,"id":47120,"default_photo":{"square_url":"https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg","attribution":"(c) Damien du Toit, some rights reserved (CC BY)","flags":[],"medium_url":"https://farm1.staticflickr.com/1/380353_028542ead3.jpg","id":4115,"license_code":"cc-by","original_dimensions":null,"url":"https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg"},"ancestor_ids":[48460,1,47120],"iconic_taxon_name":"Animalia","preferred_common_name":"Arthropods"},{"observations_count":3701511,"taxon_schemes_count":2,"ancestry":"48460/1/47120","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Hexapoda","current_synonymous_taxon_ids":null,"iconic_taxon_id":1,"rank_level":57,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":47120,"name":"Hexapoda","rank":"subphylum","extinct":false,"id":372739,"default_photo":{"square_url":"https://static.inaturalist.org/photos/1205428/square.?1413275622","attribution":"(c) heni, all rights reserved, uploaded by Jane Percival","flags":[],"medium_url":"https://static.inaturalist.org/photos/1205428/medium.?1413275622","id":1205428,"license_code":null,"original_dimensions":{"width":790,"height":557},"url":"https://static.inaturalist.org/photos/1205428/square.?1413275622"},"ancestor_ids":[48460,1,47120,372739],"iconic_taxon_name":"Animalia","preferred_common_name":"Hexapods"},{"observations_count":3696904,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739","is_active":true,"flag_counts":{"unresolved":0,"resolved":2},"wikipedia_url":"http://en.wikipedia.org/wiki/Insect","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":50,"taxon_changes_count":2,"atlas_id":null,"complete_species_count":null,"parent_id":372739,"name":"Insecta","rank":"class","extinct":false,"id":47158,"default_photo":{"square_url":"https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739","attribution":"(c) Jason Michael Crockwell, some rights reserved (CC BY-NC-ND)","flags":[],"medium_url":"https://static.inaturalist.org/photos/4744725/medium.jpeg?1472813739","id":4744725,"license_code":"cc-by-nc-nd","original_dimensions":{"width":2048,"height":1536},"url":"https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739"},"ancestor_ids":[48460,1,47120,372739,47158],"iconic_taxon_name":"Insecta","preferred_common_name":"Insects"},{"observations_count":3659996,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158","is_active":true,"flag_counts":{"unresolved":0,"resolved":2},"wikipedia_url":"http://en.wikipedia.org/wiki/Pterygota","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":47,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":47158,"name":"Pterygota","rank":"subclass","extinct":false,"id":184884,"default_photo":{"square_url":"https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg","attribution":"(c) gbohne, some rights reserved (CC BY-SA)","flags":[],"medium_url":"https://farm5.staticflickr.com/4126/5064146527_95d67d2cae.jpg","id":1670296,"license_code":"cc-by-sa","original_dimensions":null,"url":"https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884],"iconic_taxon_name":"Insecta","preferred_common_name":"Winged and Once-winged Insects"},{"observations_count":410369,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Beetle","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":40,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":184884,"name":"Coleoptera","rank":"order","extinct":false,"id":47208,"default_photo":{"square_url":"https://static.inaturalist.org/photos/1077943/square.jpg?1444320780","attribution":"(c) Jay Keller, all rights reserved, uploaded by Jay L. Keller","flags":[],"medium_url":"https://static.inaturalist.org/photos/1077943/medium.jpg?1444320780","id":1077943,"license_code":null,"original_dimensions":{"width":1711,"height":1679},"url":"https://static.inaturalist.org/photos/1077943/square.jpg?1444320780"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208],"iconic_taxon_name":"Insecta","preferred_common_name":"Beetles"},{"observations_count":349335,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Polyphaga","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":37,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":47208,"name":"Polyphaga","rank":"suborder","extinct":false,"id":71130,"default_photo":{"square_url":"https://static.inaturalist.org/photos/12466261/square.jpg?1513752231","attribution":"(c) Arnold Wijker, some rights reserved (CC BY-NC)","flags":[],"medium_url":"https://static.inaturalist.org/photos/12466261/medium.jpg?1513752231","id":12466261,"license_code":"cc-by-nc","original_dimensions":{"width":800,"height":714},"url":"https://static.inaturalist.org/photos/12466261/square.jpg?1513752231"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130],"iconic_taxon_name":"Insecta","preferred_common_name":"Water, Rove, Scarab, Long-horned, Leaf, and Snout Beetles"},{"observations_count":222277,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Cucujiformia","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":35,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":71130,"name":"Cucujiformia","rank":"infraorder","extinct":false,"id":372852,"default_photo":{"square_url":"https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg","attribution":"(c) Udo Schmidt, some rights reserved (CC BY-SA)","flags":[],"medium_url":"https://farm5.staticflickr.com/4014/5142992820_7b86576c37.jpg","id":1182110,"license_code":"cc-by-sa","original_dimensions":null,"url":"https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852],"iconic_taxon_name":"Insecta","preferred_common_name":"Cucujiform Beetles"},{"observations_count":25765,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852","is_active":true,"flag_counts":{"unresolved":0,"resolved":1},"wikipedia_url":"http://en.wikipedia.org/wiki/Weevil","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":33,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":372852,"name":"Curculionoidea","rank":"superfamily","extinct":false,"id":60473,"default_photo":{"square_url":"https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg","attribution":"(c) Mick Talbot, some rights reserved (CC BY)","flags":[],"medium_url":"https://farm3.staticflickr.com/2535/3729552832_4a436b62d9.jpg","id":99067,"license_code":"cc-by","original_dimensions":null,"url":"https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473],"iconic_taxon_name":"Insecta","preferred_common_name":"Snout and Bark Beetles"},{"observations_count":19478,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Curculionidae","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":30,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":60473,"name":"Curculionidae","rank":"family","extinct":false,"id":48736,"default_photo":{"square_url":"https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg","attribution":"(c) Mick Talbot, some rights reserved (CC BY)","flags":[],"medium_url":"https://farm4.staticflickr.com/3174/2891245226_93744353d6.jpg","id":24438,"license_code":"cc-by","original_dimensions":null,"url":"https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736],"iconic_taxon_name":"Insecta","preferred_common_name":"True Weevils"},{"observations_count":1593,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Lixinae","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":27,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":48736,"name":"Lixinae","rank":"subfamily","extinct":false,"id":272543,"default_photo":{"square_url":"https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg","attribution":"(c) Ferran Turmo Gort, some rights reserved (CC BY-NC-SA)","flags":[],"medium_url":"https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82.jpg","id":3094335,"license_code":"cc-by-nc-sa","original_dimensions":null,"url":"https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543],"iconic_taxon_name":"Insecta"},{"observations_count":1093,"taxon_schemes_count":1,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":null,"current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":25,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":272543,"name":"Lixini","rank":"tribe","extinct":false,"id":507383,"default_photo":{"square_url":"https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg","attribution":"(c) Alexey Kljatov, some rights reserved (CC BY-NC)","flags":[],"medium_url":"https://farm6.staticflickr.com/5092/5528807700_9da380c55d.jpg","id":5744395,"license_code":"cc-by-nc","original_dimensions":null,"url":"https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383],"iconic_taxon_name":"Insecta"},{"observations_count":618,"taxon_schemes_count":1,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Lixus_(beetle)","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":20,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":507383,"name":"Lixus","rank":"genus","extinct":false,"id":71157,"default_photo":{"square_url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg","attribution":"(c) Yvan, some rights reserved (CC BY-NC-SA)","flags":[],"medium_url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03.jpg","id":31486,"license_code":"cc-by-nc-sa","original_dimensions":null,"url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157],"iconic_taxon_name":"Insecta"}]},"previous_observation_taxon":{"taxon_schemes_count":1,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383","min_species_ancestry":"48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157","wikipedia_url":"http://en.wikipedia.org/wiki/Lixus_(beetle)","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"created_at":"2011-05-11T05:36:06+00:00","taxon_changes_count":0,"complete_species_count":null,"rank":"genus","extinct":false,"id":71157,"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157],"observations_count":618,"is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"rank_level":20,"atlas_id":null,"parent_id":507383,"name":"Lixus","default_photo":{"square_url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg","attribution":"(c) Yvan, some rights reserved (CC BY-NC-SA)","flags":[],"medium_url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03.jpg","id":31486,"license_code":"cc-by-nc-sa","original_dimensions":null,"url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg"},"iconic_taxon_name":"Insecta"}},{"disagreement":false,"flags":[],"created_at":"2018-09-22T17:19:26+00:00","taxon_id":493595,"body":null,"own_observation":false,"uuid":"2c245e63-c1fd-4295-9b95-1d06c339d9a5","taxon_change":null,"vision":false,"current":true,"id":36039221,"created_at_details":{"date":"2018-09-22","week":38,"month":9,"hour":17,"year":2018,"day":22},"category":"supporting","spam":false,"user":{"id":1226913,"login":"jpreudhomme","spam":false,"suspended":false,"login_autocomplete":"jpreudhomme","login_exact":"jpreudhomme","name":"jupreudhomme","name_autocomplete":"jupreudhomme","icon":"https://static.inaturalist.org/attachments/users/icons/1226913/thumb.jpeg?1537623104","observations_count":4,"identifications_count":201,"journal_posts_count":0,"activity_count":205,"roles":[],"site_id":1,"icon_url":"https://static.inaturalist.org/attachments/users/icons/1226913/medium.jpeg?1537623104"},"previous_observation_taxon_id":493595,"taxon":{"taxon_schemes_count":1,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383/71157","min_species_ancestry":"48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157,493595","wikipedia_url":null,"current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"created_at":"2016-04-25T22:35:20+00:00","taxon_changes_count":0,"complete_species_count":null,"rank":"species","extinct":false,"id":493595,"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157],"observations_count":3,"is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"rank_level":10,"atlas_id":null,"parent_id":71157,"name":"Lixus bardanae","default_photo":{"square_url":"https://static.inaturalist.org/photos/3480841/square.JPG?1461537518","attribution":"(c) Dimitǎr Boevski, some rights reserved (CC BY)","flags":[],"medium_url":"https://static.inaturalist.org/photos/3480841/medium.JPG?1461537518","id":3480841,"license_code":"cc-by","original_dimensions":{"width":1724,"height":1146},"url":"https://static.inaturalist.org/photos/3480841/square.JPG?1461537518"},"iconic_taxon_name":"Insecta","ancestors":[{"observations_count":9030721,"taxon_schemes_count":2,"ancestry":"48460","is_active":true,"flag_counts":{"unresolved":0,"resolved":5},"wikipedia_url":"http://en.wikipedia.org/wiki/Animal","current_synonymous_taxon_ids":null,"iconic_taxon_id":1,"rank_level":70,"taxon_changes_count":3,"atlas_id":null,"complete_species_count":null,"parent_id":48460,"name":"Animalia","rank":"kingdom","extinct":false,"id":1,"default_photo":{"square_url":"https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg","attribution":"(c) David Midgley, some rights reserved (CC BY-NC-ND)","flags":[],"medium_url":"https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b.jpg","id":169,"license_code":"cc-by-nc-nd","original_dimensions":null,"url":"https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg"},"ancestor_ids":[48460,1],"iconic_taxon_name":"Animalia","preferred_common_name":"Animals"},{"observations_count":4232787,"taxon_schemes_count":2,"ancestry":"48460/1","is_active":true,"flag_counts":{"unresolved":0,"resolved":1},"wikipedia_url":"http://en.wikipedia.org/wiki/Arthropod","current_synonymous_taxon_ids":null,"iconic_taxon_id":1,"rank_level":60,"taxon_changes_count":2,"atlas_id":null,"complete_species_count":null,"parent_id":1,"name":"Arthropoda","rank":"phylum","extinct":false,"id":47120,"default_photo":{"square_url":"https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg","attribution":"(c) Damien du Toit, some rights reserved (CC BY)","flags":[],"medium_url":"https://farm1.staticflickr.com/1/380353_028542ead3.jpg","id":4115,"license_code":"cc-by","original_dimensions":null,"url":"https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg"},"ancestor_ids":[48460,1,47120],"iconic_taxon_name":"Animalia","preferred_common_name":"Arthropods"},{"observations_count":3701511,"taxon_schemes_count":2,"ancestry":"48460/1/47120","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Hexapoda","current_synonymous_taxon_ids":null,"iconic_taxon_id":1,"rank_level":57,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":47120,"name":"Hexapoda","rank":"subphylum","extinct":false,"id":372739,"default_photo":{"square_url":"https://static.inaturalist.org/photos/1205428/square.?1413275622","attribution":"(c) heni, all rights reserved, uploaded by Jane Percival","flags":[],"medium_url":"https://static.inaturalist.org/photos/1205428/medium.?1413275622","id":1205428,"license_code":null,"original_dimensions":{"width":790,"height":557},"url":"https://static.inaturalist.org/photos/1205428/square.?1413275622"},"ancestor_ids":[48460,1,47120,372739],"iconic_taxon_name":"Animalia","preferred_common_name":"Hexapods"},{"observations_count":3696904,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739","is_active":true,"flag_counts":{"unresolved":0,"resolved":2},"wikipedia_url":"http://en.wikipedia.org/wiki/Insect","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":50,"taxon_changes_count":2,"atlas_id":null,"complete_species_count":null,"parent_id":372739,"name":"Insecta","rank":"class","extinct":false,"id":47158,"default_photo":{"square_url":"https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739","attribution":"(c) Jason Michael Crockwell, some rights reserved (CC BY-NC-ND)","flags":[],"medium_url":"https://static.inaturalist.org/photos/4744725/medium.jpeg?1472813739","id":4744725,"license_code":"cc-by-nc-nd","original_dimensions":{"width":2048,"height":1536},"url":"https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739"},"ancestor_ids":[48460,1,47120,372739,47158],"iconic_taxon_name":"Insecta","preferred_common_name":"Insects"},{"observations_count":3659996,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158","is_active":true,"flag_counts":{"unresolved":0,"resolved":2},"wikipedia_url":"http://en.wikipedia.org/wiki/Pterygota","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":47,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":47158,"name":"Pterygota","rank":"subclass","extinct":false,"id":184884,"default_photo":{"square_url":"https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg","attribution":"(c) gbohne, some rights reserved (CC BY-SA)","flags":[],"medium_url":"https://farm5.staticflickr.com/4126/5064146527_95d67d2cae.jpg","id":1670296,"license_code":"cc-by-sa","original_dimensions":null,"url":"https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884],"iconic_taxon_name":"Insecta","preferred_common_name":"Winged and Once-winged Insects"},{"observations_count":410369,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Beetle","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":40,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":184884,"name":"Coleoptera","rank":"order","extinct":false,"id":47208,"default_photo":{"square_url":"https://static.inaturalist.org/photos/1077943/square.jpg?1444320780","attribution":"(c) Jay Keller, all rights reserved, uploaded by Jay L. Keller","flags":[],"medium_url":"https://static.inaturalist.org/photos/1077943/medium.jpg?1444320780","id":1077943,"license_code":null,"original_dimensions":{"width":1711,"height":1679},"url":"https://static.inaturalist.org/photos/1077943/square.jpg?1444320780"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208],"iconic_taxon_name":"Insecta","preferred_common_name":"Beetles"},{"observations_count":349335,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Polyphaga","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":37,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":47208,"name":"Polyphaga","rank":"suborder","extinct":false,"id":71130,"default_photo":{"square_url":"https://static.inaturalist.org/photos/12466261/square.jpg?1513752231","attribution":"(c) Arnold Wijker, some rights reserved (CC BY-NC)","flags":[],"medium_url":"https://static.inaturalist.org/photos/12466261/medium.jpg?1513752231","id":12466261,"license_code":"cc-by-nc","original_dimensions":{"width":800,"height":714},"url":"https://static.inaturalist.org/photos/12466261/square.jpg?1513752231"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130],"iconic_taxon_name":"Insecta","preferred_common_name":"Water, Rove, Scarab, Long-horned, Leaf, and Snout Beetles"},{"observations_count":222277,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Cucujiformia","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":35,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":71130,"name":"Cucujiformia","rank":"infraorder","extinct":false,"id":372852,"default_photo":{"square_url":"https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg","attribution":"(c) Udo Schmidt, some rights reserved (CC BY-SA)","flags":[],"medium_url":"https://farm5.staticflickr.com/4014/5142992820_7b86576c37.jpg","id":1182110,"license_code":"cc-by-sa","original_dimensions":null,"url":"https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852],"iconic_taxon_name":"Insecta","preferred_common_name":"Cucujiform Beetles"},{"observations_count":25765,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852","is_active":true,"flag_counts":{"unresolved":0,"resolved":1},"wikipedia_url":"http://en.wikipedia.org/wiki/Weevil","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":33,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":372852,"name":"Curculionoidea","rank":"superfamily","extinct":false,"id":60473,"default_photo":{"square_url":"https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg","attribution":"(c) Mick Talbot, some rights reserved (CC BY)","flags":[],"medium_url":"https://farm3.staticflickr.com/2535/3729552832_4a436b62d9.jpg","id":99067,"license_code":"cc-by","original_dimensions":null,"url":"https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473],"iconic_taxon_name":"Insecta","preferred_common_name":"Snout and Bark Beetles"},{"observations_count":19478,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Curculionidae","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":30,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":60473,"name":"Curculionidae","rank":"family","extinct":false,"id":48736,"default_photo":{"square_url":"https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg","attribution":"(c) Mick Talbot, some rights reserved (CC BY)","flags":[],"medium_url":"https://farm4.staticflickr.com/3174/2891245226_93744353d6.jpg","id":24438,"license_code":"cc-by","original_dimensions":null,"url":"https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736],"iconic_taxon_name":"Insecta","preferred_common_name":"True Weevils"},{"observations_count":1593,"taxon_schemes_count":2,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Lixinae","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":27,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":48736,"name":"Lixinae","rank":"subfamily","extinct":false,"id":272543,"default_photo":{"square_url":"https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg","attribution":"(c) Ferran Turmo Gort, some rights reserved (CC BY-NC-SA)","flags":[],"medium_url":"https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82.jpg","id":3094335,"license_code":"cc-by-nc-sa","original_dimensions":null,"url":"https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543],"iconic_taxon_name":"Insecta"},{"observations_count":1093,"taxon_schemes_count":1,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":null,"current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":25,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":272543,"name":"Lixini","rank":"tribe","extinct":false,"id":507383,"default_photo":{"square_url":"https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg","attribution":"(c) Alexey Kljatov, some rights reserved (CC BY-NC)","flags":[],"medium_url":"https://farm6.staticflickr.com/5092/5528807700_9da380c55d.jpg","id":5744395,"license_code":"cc-by-nc","original_dimensions":null,"url":"https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383],"iconic_taxon_name":"Insecta"},{"observations_count":618,"taxon_schemes_count":1,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383","is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"wikipedia_url":"http://en.wikipedia.org/wiki/Lixus_(beetle)","current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"rank_level":20,"taxon_changes_count":0,"atlas_id":null,"complete_species_count":null,"parent_id":507383,"name":"Lixus","rank":"genus","extinct":false,"id":71157,"default_photo":{"square_url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg","attribution":"(c) Yvan, some rights reserved (CC BY-NC-SA)","flags":[],"medium_url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03.jpg","id":31486,"license_code":"cc-by-nc-sa","original_dimensions":null,"url":"https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg"},"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157],"iconic_taxon_name":"Insecta"}]},"previous_observation_taxon":{"taxon_schemes_count":1,"ancestry":"48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383/71157","min_species_ancestry":"48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157,493595","wikipedia_url":null,"current_synonymous_taxon_ids":null,"iconic_taxon_id":47158,"created_at":"2016-04-25T22:35:20+00:00","taxon_changes_count":0,"complete_species_count":null,"rank":"species","extinct":false,"id":493595,"ancestor_ids":[48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157,493595],"observations_count":3,"is_active":true,"flag_counts":{"unresolved":0,"resolved":0},"rank_level":10,"atlas_id":null,"parent_id":71157,"name":"Lixus bardanae","default_photo":{"square_url":"https://static.inaturalist.org/photos/3480841/square.JPG?1461537518","attribution":"(c) Dimitǎr Boevski, some rights reserved (CC BY)","flags":[],"medium_url":"https://static.inaturalist.org/photos/3480841/medium.JPG?1461537518","id":3480841,"license_code":"cc-by","original_dimensions":{"width":1724,"height":1146},"url":"https://static.inaturalist.org/photos/3480841/square.JPG?1461537518"},"iconic_taxon_name":"Insecta"}}]}]} \ No newline at end of file +{ + "total_results": 1, + "page": 1, + "per_page": 30, + "results": [ + { + "out_of_range": null, + "quality_grade": "research", + "time_observed_at": "2018-09-05T14:06:00+02:00", + "annotations": [ + + ], + "uuid": "6448d03a-7f9a-4099-86aa-ca09a7740b00", + "photos": [ + { + "attribution": "(c) Nicolas Noé, some rights reserved (CC BY)", + "flags": [], + "id": 24355315, + "license_code": "cc-by", + "original_dimensions": { + "width": 1445, + "height": 1057 + }, + "url": "https://static.inaturalist.org/photos/24355315/square.jpeg?1536150664" + }, + { + "attribution": "(c) Nicolas Noé, some rights reserved (CC BY)", + "flags": [], + "id": 24355313, + "license_code": "cc-by", + "original_dimensions": { + "width": 2048, + "height": 1364 + }, + "url": "https://static.inaturalist.org/photos/24355313/square.jpeg?1536150659" + } + ], + "observed_on_details": { + "date": "2018-09-05", + "week": 36, + "month": 9, + "hour": 14, + "year": 2018, + "day": 5 + }, + "id": 16227955, + "cached_votes_total": 0, + "identifications_most_agree": true, + "created_at_details": { + "date": "2018-09-05", + "week": 36, + "month": 9, + "hour": 14, + "year": 2018, + "day": 5 + }, + "species_guess": "Lixus bardanae", + "identifications_most_disagree": false, + "tags": [], + "positional_accuracy": 23, + "comments_count": 2, + "site_id": 1, + "created_time_zone": "Europe/Paris", + "id_please": false, + "license_code": "cc0", + "observed_time_zone": "Europe/Paris", + "quality_metrics": [], + "public_positional_accuracy": 23, + "reviewed_by": [ + 180811, + 886482, + 1226913 + ], + "oauth_application_id": null, + "flags": [], + "created_at": "2018-09-05T14:31:08+02:00", + "description": "", + "time_zone_offset": "+01:00", + "project_ids_with_curator_id": [], + "observed_on": "2018-09-05", + "observed_on_string": "2018/09/05 2:06 PM CEST", + "updated_at": "2018-09-22T19:19:27+02:00", + "sounds": [], + "place_ids": [ + 7008, + 8657, + 14999, + 59614, + 67952, + 80627, + 81490, + 96372, + 96794, + 97391, + 97582, + 108692 + ], + "captive": false, + "taxon": { + "is_active": true, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383/71157", + "min_species_ancestry": "48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157,493595", + "endemic": false, + "iconic_taxon_id": 47158, + "threatened": false, + "rank_level": 10, + "introduced": false, + "native": false, + "parent_id": 71157, + "name": "Lixus bardanae", + "rank": "species", + "extinct": false, + "id": 493595, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383, + 71157, + 493595 + ], + "taxon_schemes_count": 1, + "wikipedia_url": null, + "current_synonymous_taxon_ids": null, + "created_at": "2016-04-25T22:35:20+00:00", + "taxon_changes_count": 0, + "complete_species_count": null, + "observations_count": 3, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "atlas_id": null, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/3480841/square.JPG?1461537518", + "attribution": "(c) Dimitǎr Boevski, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/3480841/medium.JPG?1461537518", + "id": 3480841, + "license_code": "cc-by", + "original_dimensions": { + "width": 1724, + "height": 1146 + }, + "url": "https://static.inaturalist.org/photos/3480841/square.JPG?1461537518" + }, + "iconic_taxon_name": "Insecta" + }, + "outlinks": [ + { + "source": "GBIF", + "url": "http://www.gbif.org/occurrence/1914197587" + } + ], + "faves_count": 0, + "ofvs": [], + "num_identification_agreements": 2, + "preferences": { + "prefers_community_taxon": null + }, + "comments": [ + { + "flags": [], + "created_at": "2018-09-05T16:03:40+00:00", + "id": 2071896, + "created_at_details": { + "date": "2018-09-05", + "week": 36, + "month": 9, + "hour": 16, + "year": 2018, + "day": 5 + }, + "body": "I now see: Bonus species on observation! You may make a duplicate . . . \n(Flea beetle Epitrix pubescens on Solanum bud) \n", + "uuid": "4d401d20-1b08-464a-8287-351f5b57443e", + "user": { + "id": 180811, + "login": "borisb", + "spam": false, + "suspended": false, + "login_autocomplete": "borisb", + "login_exact": "borisb", + "name": "", + "name_autocomplete": "", + "icon": null, + "observations_count": 0, + "identifications_count": 105317, + "journal_posts_count": 0, + "activity_count": 105317, + "roles": [ + "curator" + ], + "site_id": 1 + } + }, + { + "flags": [], + "created_at": "2018-09-05T14:08:09+00:00", + "id": 2071611, + "created_at_details": { + "date": "2018-09-05", + "week": 36, + "month": 9, + "hour": 14, + "year": 2018, + "day": 5 + }, + "body": "suspect L. bardanae - but sits on Solanum (non-host)", + "uuid": "e6fc62ea-e22b-4427-bb65-1ba3bc591c77", + "user": { + "id": 180811, + "login": "borisb", + "spam": false, + "suspended": false, + "login_autocomplete": "borisb", + "login_exact": "borisb", + "name": "", + "name_autocomplete": "", + "icon": null, + "observations_count": 0, + "identifications_count": 105317, + "journal_posts_count": 0, + "activity_count": 105317, + "roles": [ + "curator" + ], + "site_id": 1 + } + } + ], + "map_scale": 17, + "uri": "https://www.inaturalist.org/observations/16227955", + "project_ids": [], + "identifications": [ + { + "disagreement": null, + "flags": [], + "created_at": "2018-09-05T12:34:22+00:00", + "taxon_id": 71157, + "body": "", + "own_observation": true, + "uuid": "f7b99479-5778-44bb-a339-6b5af633724e", + "taxon_change": null, + "vision": true, + "current": true, + "id": 34896306, + "created_at_details": { + "date": "2018-09-05", + "week": 36, + "month": 9, + "hour": 12, + "year": 2018, + "day": 5 + }, + "category": "improving", + "spam": false, + "user": { + "id": 886482, + "login": "niconoe", + "spam": false, + "suspended": false, + "login_autocomplete": "niconoe", + "login_exact": "niconoe", + "name": "Nicolas Noé", + "name_autocomplete": "Nicolas Noé", + "icon": "https://static.inaturalist.org/attachments/users/icons/886482/thumb.jpg?1529671435", + "observations_count": 223, + "identifications_count": 27, + "journal_posts_count": 0, + "activity_count": 250, + "roles": [], + "site_id": 1, + "icon_url": "https://static.inaturalist.org/attachments/users/icons/886482/medium.jpg?1529671435" + }, + "previous_observation_taxon_id": null, + "taxon": { + "taxon_schemes_count": 1, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383", + "min_species_ancestry": "48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157", + "wikipedia_url": "http://en.wikipedia.org/wiki/Lixus_(beetle)", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "created_at": "2011-05-11T05:36:06+00:00", + "taxon_changes_count": 0, + "complete_species_count": null, + "rank": "genus", + "extinct": false, + "id": 71157, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383 + ], + "observations_count": 618, + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "rank_level": 20, + "atlas_id": null, + "parent_id": 507383, + "name": "Lixus", + "default_photo": { + "square_url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg", + "attribution": "(c) Yvan, some rights reserved (CC BY-NC-SA)", + "flags": [], + "medium_url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03.jpg", + "id": 31486, + "license_code": "cc-by-nc-sa", + "original_dimensions": null, + "url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg" + }, + "iconic_taxon_name": "Insecta", + "ancestors": [ + { + "observations_count": 9030721, + "taxon_schemes_count": 2, + "ancestry": "48460", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 5 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Animal", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 1, + "rank_level": 70, + "taxon_changes_count": 3, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 48460, + "name": "Animalia", + "rank": "kingdom", + "extinct": false, + "id": 1, + "default_photo": { + "square_url": "https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg", + "attribution": "(c) David Midgley, some rights reserved (CC BY-NC-ND)", + "flags": [], + "medium_url": "https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b.jpg", + "id": 169, + "license_code": "cc-by-nc-nd", + "original_dimensions": null, + "url": "https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1 + ], + "iconic_taxon_name": "Animalia", + "preferred_common_name": "Animals" + }, + { + "observations_count": 4232787, + "taxon_schemes_count": 2, + "ancestry": "48460/1", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 1 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Arthropod", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 1, + "rank_level": 60, + "taxon_changes_count": 2, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 1, + "name": "Arthropoda", + "rank": "phylum", + "extinct": false, + "id": 47120, + "default_photo": { + "square_url": "https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg", + "attribution": "(c) Damien du Toit, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://farm1.staticflickr.com/1/380353_028542ead3.jpg", + "id": 4115, + "license_code": "cc-by", + "original_dimensions": null, + "url": "https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120 + ], + "iconic_taxon_name": "Animalia", + "preferred_common_name": "Arthropods" + }, + { + "observations_count": 3701511, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Hexapoda", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 1, + "rank_level": 57, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 47120, + "name": "Hexapoda", + "rank": "subphylum", + "extinct": false, + "id": 372739, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/1205428/square.?1413275622", + "attribution": "(c) heni, all rights reserved, uploaded by Jane Percival", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/1205428/medium.?1413275622", + "id": 1205428, + "license_code": null, + "original_dimensions": { + "width": 790, + "height": 557 + }, + "url": "https://static.inaturalist.org/photos/1205428/square.?1413275622" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739 + ], + "iconic_taxon_name": "Animalia", + "preferred_common_name": "Hexapods" + }, + { + "observations_count": 3696904, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 2 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Insect", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 50, + "taxon_changes_count": 2, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 372739, + "name": "Insecta", + "rank": "class", + "extinct": false, + "id": 47158, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739", + "attribution": "(c) Jason Michael Crockwell, some rights reserved (CC BY-NC-ND)", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/4744725/medium.jpeg?1472813739", + "id": 4744725, + "license_code": "cc-by-nc-nd", + "original_dimensions": { + "width": 2048, + "height": 1536 + }, + "url": "https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Insects" + }, + { + "observations_count": 3659996, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 2 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Pterygota", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 47, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 47158, + "name": "Pterygota", + "rank": "subclass", + "extinct": false, + "id": 184884, + "default_photo": { + "square_url": "https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg", + "attribution": "(c) gbohne, some rights reserved (CC BY-SA)", + "flags": [], + "medium_url": "https://farm5.staticflickr.com/4126/5064146527_95d67d2cae.jpg", + "id": 1670296, + "license_code": "cc-by-sa", + "original_dimensions": null, + "url": "https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Winged and Once-winged Insects" + }, + { + "observations_count": 410369, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Beetle", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 40, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 184884, + "name": "Coleoptera", + "rank": "order", + "extinct": false, + "id": 47208, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/1077943/square.jpg?1444320780", + "attribution": "(c) Jay Keller, all rights reserved, uploaded by Jay L. Keller", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/1077943/medium.jpg?1444320780", + "id": 1077943, + "license_code": null, + "original_dimensions": { + "width": 1711, + "height": 1679 + }, + "url": "https://static.inaturalist.org/photos/1077943/square.jpg?1444320780" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Beetles" + }, + { + "observations_count": 349335, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Polyphaga", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 37, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 47208, + "name": "Polyphaga", + "rank": "suborder", + "extinct": false, + "id": 71130, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/12466261/square.jpg?1513752231", + "attribution": "(c) Arnold Wijker, some rights reserved (CC BY-NC)", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/12466261/medium.jpg?1513752231", + "id": 12466261, + "license_code": "cc-by-nc", + "original_dimensions": { + "width": 800, + "height": 714 + }, + "url": "https://static.inaturalist.org/photos/12466261/square.jpg?1513752231" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Water, Rove, Scarab, Long-horned, Leaf, and Snout Beetles" + }, + { + "observations_count": 222277, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Cucujiformia", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 35, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 71130, + "name": "Cucujiformia", + "rank": "infraorder", + "extinct": false, + "id": 372852, + "default_photo": { + "square_url": "https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg", + "attribution": "(c) Udo Schmidt, some rights reserved (CC BY-SA)", + "flags": [], + "medium_url": "https://farm5.staticflickr.com/4014/5142992820_7b86576c37.jpg", + "id": 1182110, + "license_code": "cc-by-sa", + "original_dimensions": null, + "url": "https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Cucujiform Beetles" + }, + { + "observations_count": 25765, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 1 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Weevil", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 33, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 372852, + "name": "Curculionoidea", + "rank": "superfamily", + "extinct": false, + "id": 60473, + "default_photo": { + "square_url": "https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg", + "attribution": "(c) Mick Talbot, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://farm3.staticflickr.com/2535/3729552832_4a436b62d9.jpg", + "id": 99067, + "license_code": "cc-by", + "original_dimensions": null, + "url": "https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Snout and Bark Beetles" + }, + { + "observations_count": 19478, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Curculionidae", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 30, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 60473, + "name": "Curculionidae", + "rank": "family", + "extinct": false, + "id": 48736, + "default_photo": { + "square_url": "https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg", + "attribution": "(c) Mick Talbot, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://farm4.staticflickr.com/3174/2891245226_93744353d6.jpg", + "id": 24438, + "license_code": "cc-by", + "original_dimensions": null, + "url": "https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "True Weevils" + }, + { + "observations_count": 1593, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Lixinae", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 27, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 48736, + "name": "Lixinae", + "rank": "subfamily", + "extinct": false, + "id": 272543, + "default_photo": { + "square_url": "https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg", + "attribution": "(c) Ferran Turmo Gort, some rights reserved (CC BY-NC-SA)", + "flags": [], + "medium_url": "https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82.jpg", + "id": 3094335, + "license_code": "cc-by-nc-sa", + "original_dimensions": null, + "url": "https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543 + ], + "iconic_taxon_name": "Insecta" + }, + { + "observations_count": 1093, + "taxon_schemes_count": 1, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": null, + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 25, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 272543, + "name": "Lixini", + "rank": "tribe", + "extinct": false, + "id": 507383, + "default_photo": { + "square_url": "https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg", + "attribution": "(c) Alexey Kljatov, some rights reserved (CC BY-NC)", + "flags": [], + "medium_url": "https://farm6.staticflickr.com/5092/5528807700_9da380c55d.jpg", + "id": 5744395, + "license_code": "cc-by-nc", + "original_dimensions": null, + "url": "https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383 + ], + "iconic_taxon_name": "Insecta" + } + ] + } + }, + { + "disagreement": false, + "flags": [], + "created_at": "2018-09-05T22:37:06+00:00", + "taxon_id": 493595, + "body": "", + "own_observation": false, + "uuid": "98d454e4-bead-473d-a372-f35b8ba5bf1c", + "taxon_change": null, + "vision": false, + "current": true, + "id": 34926789, + "created_at_details": { + "date": "2018-09-05", + "week": 36, + "month": 9, + "hour": 22, + "year": 2018, + "day": 5 + }, + "category": "improving", + "spam": false, + "user": { + "id": 180811, + "login": "borisb", + "spam": false, + "suspended": false, + "login_autocomplete": "borisb", + "login_exact": "borisb", + "name": "", + "name_autocomplete": "", + "icon": null, + "observations_count": 0, + "identifications_count": 105317, + "journal_posts_count": 0, + "activity_count": 105317, + "roles": [ + "curator" + ], + "site_id": 1 + }, + "previous_observation_taxon_id": 71157, + "taxon": { + "taxon_schemes_count": 1, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383/71157", + "min_species_ancestry": "48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157,493595", + "wikipedia_url": null, + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "created_at": "2016-04-25T22:35:20+00:00", + "taxon_changes_count": 0, + "complete_species_count": null, + "rank": "species", + "extinct": false, + "id": 493595, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383, + 71157 + ], + "observations_count": 3, + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "rank_level": 10, + "atlas_id": null, + "parent_id": 71157, + "name": "Lixus bardanae", + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/3480841/square.JPG?1461537518", + "attribution": "(c) Dimitǎr Boevski, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/3480841/medium.JPG?1461537518", + "id": 3480841, + "license_code": "cc-by", + "original_dimensions": { + "width": 1724, + "height": 1146 + }, + "url": "https://static.inaturalist.org/photos/3480841/square.JPG?1461537518" + }, + "iconic_taxon_name": "Insecta", + "ancestors": [ + { + "observations_count": 9030721, + "taxon_schemes_count": 2, + "ancestry": "48460", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 5 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Animal", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 1, + "rank_level": 70, + "taxon_changes_count": 3, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 48460, + "name": "Animalia", + "rank": "kingdom", + "extinct": false, + "id": 1, + "default_photo": { + "square_url": "https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg", + "attribution": "(c) David Midgley, some rights reserved (CC BY-NC-ND)", + "flags": [], + "medium_url": "https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b.jpg", + "id": 169, + "license_code": "cc-by-nc-nd", + "original_dimensions": null, + "url": "https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1 + ], + "iconic_taxon_name": "Animalia", + "preferred_common_name": "Animals" + }, + { + "observations_count": 4232787, + "taxon_schemes_count": 2, + "ancestry": "48460/1", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 1 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Arthropod", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 1, + "rank_level": 60, + "taxon_changes_count": 2, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 1, + "name": "Arthropoda", + "rank": "phylum", + "extinct": false, + "id": 47120, + "default_photo": { + "square_url": "https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg", + "attribution": "(c) Damien du Toit, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://farm1.staticflickr.com/1/380353_028542ead3.jpg", + "id": 4115, + "license_code": "cc-by", + "original_dimensions": null, + "url": "https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120 + ], + "iconic_taxon_name": "Animalia", + "preferred_common_name": "Arthropods" + }, + { + "observations_count": 3701511, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Hexapoda", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 1, + "rank_level": 57, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 47120, + "name": "Hexapoda", + "rank": "subphylum", + "extinct": false, + "id": 372739, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/1205428/square.?1413275622", + "attribution": "(c) heni, all rights reserved, uploaded by Jane Percival", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/1205428/medium.?1413275622", + "id": 1205428, + "license_code": null, + "original_dimensions": { + "width": 790, + "height": 557 + }, + "url": "https://static.inaturalist.org/photos/1205428/square.?1413275622" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739 + ], + "iconic_taxon_name": "Animalia", + "preferred_common_name": "Hexapods" + }, + { + "observations_count": 3696904, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 2 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Insect", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 50, + "taxon_changes_count": 2, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 372739, + "name": "Insecta", + "rank": "class", + "extinct": false, + "id": 47158, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739", + "attribution": "(c) Jason Michael Crockwell, some rights reserved (CC BY-NC-ND)", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/4744725/medium.jpeg?1472813739", + "id": 4744725, + "license_code": "cc-by-nc-nd", + "original_dimensions": { + "width": 2048, + "height": 1536 + }, + "url": "https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Insects" + }, + { + "observations_count": 3659996, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 2 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Pterygota", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 47, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 47158, + "name": "Pterygota", + "rank": "subclass", + "extinct": false, + "id": 184884, + "default_photo": { + "square_url": "https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg", + "attribution": "(c) gbohne, some rights reserved (CC BY-SA)", + "flags": [], + "medium_url": "https://farm5.staticflickr.com/4126/5064146527_95d67d2cae.jpg", + "id": 1670296, + "license_code": "cc-by-sa", + "original_dimensions": null, + "url": "https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Winged and Once-winged Insects" + }, + { + "observations_count": 410369, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Beetle", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 40, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 184884, + "name": "Coleoptera", + "rank": "order", + "extinct": false, + "id": 47208, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/1077943/square.jpg?1444320780", + "attribution": "(c) Jay Keller, all rights reserved, uploaded by Jay L. Keller", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/1077943/medium.jpg?1444320780", + "id": 1077943, + "license_code": null, + "original_dimensions": { + "width": 1711, + "height": 1679 + }, + "url": "https://static.inaturalist.org/photos/1077943/square.jpg?1444320780" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Beetles" + }, + { + "observations_count": 349335, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Polyphaga", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 37, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 47208, + "name": "Polyphaga", + "rank": "suborder", + "extinct": false, + "id": 71130, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/12466261/square.jpg?1513752231", + "attribution": "(c) Arnold Wijker, some rights reserved (CC BY-NC)", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/12466261/medium.jpg?1513752231", + "id": 12466261, + "license_code": "cc-by-nc", + "original_dimensions": { + "width": 800, + "height": 714 + }, + "url": "https://static.inaturalist.org/photos/12466261/square.jpg?1513752231" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Water, Rove, Scarab, Long-horned, Leaf, and Snout Beetles" + }, + { + "observations_count": 222277, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Cucujiformia", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 35, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 71130, + "name": "Cucujiformia", + "rank": "infraorder", + "extinct": false, + "id": 372852, + "default_photo": { + "square_url": "https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg", + "attribution": "(c) Udo Schmidt, some rights reserved (CC BY-SA)", + "flags": [], + "medium_url": "https://farm5.staticflickr.com/4014/5142992820_7b86576c37.jpg", + "id": 1182110, + "license_code": "cc-by-sa", + "original_dimensions": null, + "url": "https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Cucujiform Beetles" + }, + { + "observations_count": 25765, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 1 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Weevil", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 33, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 372852, + "name": "Curculionoidea", + "rank": "superfamily", + "extinct": false, + "id": 60473, + "default_photo": { + "square_url": "https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg", + "attribution": "(c) Mick Talbot, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://farm3.staticflickr.com/2535/3729552832_4a436b62d9.jpg", + "id": 99067, + "license_code": "cc-by", + "original_dimensions": null, + "url": "https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Snout and Bark Beetles" + }, + { + "observations_count": 19478, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Curculionidae", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 30, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 60473, + "name": "Curculionidae", + "rank": "family", + "extinct": false, + "id": 48736, + "default_photo": { + "square_url": "https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg", + "attribution": "(c) Mick Talbot, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://farm4.staticflickr.com/3174/2891245226_93744353d6.jpg", + "id": 24438, + "license_code": "cc-by", + "original_dimensions": null, + "url": "https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "True Weevils" + }, + { + "observations_count": 1593, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Lixinae", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 27, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 48736, + "name": "Lixinae", + "rank": "subfamily", + "extinct": false, + "id": 272543, + "default_photo": { + "square_url": "https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg", + "attribution": "(c) Ferran Turmo Gort, some rights reserved (CC BY-NC-SA)", + "flags": [], + "medium_url": "https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82.jpg", + "id": 3094335, + "license_code": "cc-by-nc-sa", + "original_dimensions": null, + "url": "https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543 + ], + "iconic_taxon_name": "Insecta" + }, + { + "observations_count": 1093, + "taxon_schemes_count": 1, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": null, + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 25, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 272543, + "name": "Lixini", + "rank": "tribe", + "extinct": false, + "id": 507383, + "default_photo": { + "square_url": "https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg", + "attribution": "(c) Alexey Kljatov, some rights reserved (CC BY-NC)", + "flags": [], + "medium_url": "https://farm6.staticflickr.com/5092/5528807700_9da380c55d.jpg", + "id": 5744395, + "license_code": "cc-by-nc", + "original_dimensions": null, + "url": "https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383 + ], + "iconic_taxon_name": "Insecta" + }, + { + "observations_count": 618, + "taxon_schemes_count": 1, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Lixus_(beetle)", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 20, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 507383, + "name": "Lixus", + "rank": "genus", + "extinct": false, + "id": 71157, + "default_photo": { + "square_url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg", + "attribution": "(c) Yvan, some rights reserved (CC BY-NC-SA)", + "flags": [], + "medium_url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03.jpg", + "id": 31486, + "license_code": "cc-by-nc-sa", + "original_dimensions": null, + "url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383, + 71157 + ], + "iconic_taxon_name": "Insecta" + } + ] + }, + "previous_observation_taxon": { + "taxon_schemes_count": 1, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383", + "min_species_ancestry": "48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157", + "wikipedia_url": "http://en.wikipedia.org/wiki/Lixus_(beetle)", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "created_at": "2011-05-11T05:36:06+00:00", + "taxon_changes_count": 0, + "complete_species_count": null, + "rank": "genus", + "extinct": false, + "id": 71157, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383, + 71157 + ], + "observations_count": 618, + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "rank_level": 20, + "atlas_id": null, + "parent_id": 507383, + "name": "Lixus", + "default_photo": { + "square_url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg", + "attribution": "(c) Yvan, some rights reserved (CC BY-NC-SA)", + "flags": [], + "medium_url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03.jpg", + "id": 31486, + "license_code": "cc-by-nc-sa", + "original_dimensions": null, + "url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg" + }, + "iconic_taxon_name": "Insecta" + } + }, + { + "disagreement": false, + "flags": [], + "created_at": "2018-09-22T17:19:26+00:00", + "taxon_id": 493595, + "body": null, + "own_observation": false, + "uuid": "2c245e63-c1fd-4295-9b95-1d06c339d9a5", + "taxon_change": null, + "vision": false, + "current": true, + "id": 36039221, + "created_at_details": { + "date": "2018-09-22", + "week": 38, + "month": 9, + "hour": 17, + "year": 2018, + "day": 22 + }, + "category": "supporting", + "spam": false, + "user": { + "id": 1226913, + "login": "jpreudhomme", + "spam": false, + "suspended": false, + "login_autocomplete": "jpreudhomme", + "login_exact": "jpreudhomme", + "name": "jupreudhomme", + "name_autocomplete": "jupreudhomme", + "icon": "https://static.inaturalist.org/attachments/users/icons/1226913/thumb.jpeg?1537623104", + "observations_count": 4, + "identifications_count": 201, + "journal_posts_count": 0, + "activity_count": 205, + "roles": [], + "site_id": 1, + "icon_url": "https://static.inaturalist.org/attachments/users/icons/1226913/medium.jpeg?1537623104" + }, + "previous_observation_taxon_id": 493595, + "taxon": { + "taxon_schemes_count": 1, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383/71157", + "min_species_ancestry": "48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157,493595", + "wikipedia_url": null, + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "created_at": "2016-04-25T22:35:20+00:00", + "taxon_changes_count": 0, + "complete_species_count": null, + "rank": "species", + "extinct": false, + "id": 493595, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383, + 71157 + ], + "observations_count": 3, + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "rank_level": 10, + "atlas_id": null, + "parent_id": 71157, + "name": "Lixus bardanae", + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/3480841/square.JPG?1461537518", + "attribution": "(c) Dimitǎr Boevski, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/3480841/medium.JPG?1461537518", + "id": 3480841, + "license_code": "cc-by", + "original_dimensions": { + "width": 1724, + "height": 1146 + }, + "url": "https://static.inaturalist.org/photos/3480841/square.JPG?1461537518" + }, + "iconic_taxon_name": "Insecta", + "ancestors": [ + { + "observations_count": 9030721, + "taxon_schemes_count": 2, + "ancestry": "48460", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 5 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Animal", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 1, + "rank_level": 70, + "taxon_changes_count": 3, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 48460, + "name": "Animalia", + "rank": "kingdom", + "extinct": false, + "id": 1, + "default_photo": { + "square_url": "https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg", + "attribution": "(c) David Midgley, some rights reserved (CC BY-NC-ND)", + "flags": [], + "medium_url": "https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b.jpg", + "id": 169, + "license_code": "cc-by-nc-nd", + "original_dimensions": null, + "url": "https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1 + ], + "iconic_taxon_name": "Animalia", + "preferred_common_name": "Animals" + }, + { + "observations_count": 4232787, + "taxon_schemes_count": 2, + "ancestry": "48460/1", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 1 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Arthropod", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 1, + "rank_level": 60, + "taxon_changes_count": 2, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 1, + "name": "Arthropoda", + "rank": "phylum", + "extinct": false, + "id": 47120, + "default_photo": { + "square_url": "https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg", + "attribution": "(c) Damien du Toit, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://farm1.staticflickr.com/1/380353_028542ead3.jpg", + "id": 4115, + "license_code": "cc-by", + "original_dimensions": null, + "url": "https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120 + ], + "iconic_taxon_name": "Animalia", + "preferred_common_name": "Arthropods" + }, + { + "observations_count": 3701511, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Hexapoda", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 1, + "rank_level": 57, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 47120, + "name": "Hexapoda", + "rank": "subphylum", + "extinct": false, + "id": 372739, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/1205428/square.?1413275622", + "attribution": "(c) heni, all rights reserved, uploaded by Jane Percival", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/1205428/medium.?1413275622", + "id": 1205428, + "license_code": null, + "original_dimensions": { + "width": 790, + "height": 557 + }, + "url": "https://static.inaturalist.org/photos/1205428/square.?1413275622" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739 + ], + "iconic_taxon_name": "Animalia", + "preferred_common_name": "Hexapods" + }, + { + "observations_count": 3696904, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 2 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Insect", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 50, + "taxon_changes_count": 2, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 372739, + "name": "Insecta", + "rank": "class", + "extinct": false, + "id": 47158, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739", + "attribution": "(c) Jason Michael Crockwell, some rights reserved (CC BY-NC-ND)", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/4744725/medium.jpeg?1472813739", + "id": 4744725, + "license_code": "cc-by-nc-nd", + "original_dimensions": { + "width": 2048, + "height": 1536 + }, + "url": "https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Insects" + }, + { + "observations_count": 3659996, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 2 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Pterygota", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 47, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 47158, + "name": "Pterygota", + "rank": "subclass", + "extinct": false, + "id": 184884, + "default_photo": { + "square_url": "https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg", + "attribution": "(c) gbohne, some rights reserved (CC BY-SA)", + "flags": [], + "medium_url": "https://farm5.staticflickr.com/4126/5064146527_95d67d2cae.jpg", + "id": 1670296, + "license_code": "cc-by-sa", + "original_dimensions": null, + "url": "https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Winged and Once-winged Insects" + }, + { + "observations_count": 410369, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Beetle", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 40, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 184884, + "name": "Coleoptera", + "rank": "order", + "extinct": false, + "id": 47208, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/1077943/square.jpg?1444320780", + "attribution": "(c) Jay Keller, all rights reserved, uploaded by Jay L. Keller", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/1077943/medium.jpg?1444320780", + "id": 1077943, + "license_code": null, + "original_dimensions": { + "width": 1711, + "height": 1679 + }, + "url": "https://static.inaturalist.org/photos/1077943/square.jpg?1444320780" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Beetles" + }, + { + "observations_count": 349335, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Polyphaga", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 37, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 47208, + "name": "Polyphaga", + "rank": "suborder", + "extinct": false, + "id": 71130, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/12466261/square.jpg?1513752231", + "attribution": "(c) Arnold Wijker, some rights reserved (CC BY-NC)", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/12466261/medium.jpg?1513752231", + "id": 12466261, + "license_code": "cc-by-nc", + "original_dimensions": { + "width": 800, + "height": 714 + }, + "url": "https://static.inaturalist.org/photos/12466261/square.jpg?1513752231" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Water, Rove, Scarab, Long-horned, Leaf, and Snout Beetles" + }, + { + "observations_count": 222277, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Cucujiformia", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 35, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 71130, + "name": "Cucujiformia", + "rank": "infraorder", + "extinct": false, + "id": 372852, + "default_photo": { + "square_url": "https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg", + "attribution": "(c) Udo Schmidt, some rights reserved (CC BY-SA)", + "flags": [], + "medium_url": "https://farm5.staticflickr.com/4014/5142992820_7b86576c37.jpg", + "id": 1182110, + "license_code": "cc-by-sa", + "original_dimensions": null, + "url": "https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Cucujiform Beetles" + }, + { + "observations_count": 25765, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 1 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Weevil", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 33, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 372852, + "name": "Curculionoidea", + "rank": "superfamily", + "extinct": false, + "id": 60473, + "default_photo": { + "square_url": "https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg", + "attribution": "(c) Mick Talbot, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://farm3.staticflickr.com/2535/3729552832_4a436b62d9.jpg", + "id": 99067, + "license_code": "cc-by", + "original_dimensions": null, + "url": "https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Snout and Bark Beetles" + }, + { + "observations_count": 19478, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Curculionidae", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 30, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 60473, + "name": "Curculionidae", + "rank": "family", + "extinct": false, + "id": 48736, + "default_photo": { + "square_url": "https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg", + "attribution": "(c) Mick Talbot, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://farm4.staticflickr.com/3174/2891245226_93744353d6.jpg", + "id": 24438, + "license_code": "cc-by", + "original_dimensions": null, + "url": "https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "True Weevils" + }, + { + "observations_count": 1593, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Lixinae", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 27, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 48736, + "name": "Lixinae", + "rank": "subfamily", + "extinct": false, + "id": 272543, + "default_photo": { + "square_url": "https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg", + "attribution": "(c) Ferran Turmo Gort, some rights reserved (CC BY-NC-SA)", + "flags": [], + "medium_url": "https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82.jpg", + "id": 3094335, + "license_code": "cc-by-nc-sa", + "original_dimensions": null, + "url": "https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543 + ], + "iconic_taxon_name": "Insecta" + }, + { + "observations_count": 1093, + "taxon_schemes_count": 1, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": null, + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 25, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 272543, + "name": "Lixini", + "rank": "tribe", + "extinct": false, + "id": 507383, + "default_photo": { + "square_url": "https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg", + "attribution": "(c) Alexey Kljatov, some rights reserved (CC BY-NC)", + "flags": [], + "medium_url": "https://farm6.staticflickr.com/5092/5528807700_9da380c55d.jpg", + "id": 5744395, + "license_code": "cc-by-nc", + "original_dimensions": null, + "url": "https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383 + ], + "iconic_taxon_name": "Insecta" + }, + { + "observations_count": 618, + "taxon_schemes_count": 1, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Lixus_(beetle)", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 20, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 507383, + "name": "Lixus", + "rank": "genus", + "extinct": false, + "id": 71157, + "default_photo": { + "square_url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg", + "attribution": "(c) Yvan, some rights reserved (CC BY-NC-SA)", + "flags": [], + "medium_url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03.jpg", + "id": 31486, + "license_code": "cc-by-nc-sa", + "original_dimensions": null, + "url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383, + 71157 + ], + "iconic_taxon_name": "Insecta" + } + ] + }, + "previous_observation_taxon": { + "taxon_schemes_count": 1, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383/71157", + "min_species_ancestry": "48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157,493595", + "wikipedia_url": null, + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "created_at": "2016-04-25T22:35:20+00:00", + "taxon_changes_count": 0, + "complete_species_count": null, + "rank": "species", + "extinct": false, + "id": 493595, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383, + 71157, + 493595 + ], + "observations_count": 3, + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "rank_level": 10, + "atlas_id": null, + "parent_id": 71157, + "name": "Lixus bardanae", + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/3480841/square.JPG?1461537518", + "attribution": "(c) Dimitǎr Boevski, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/3480841/medium.JPG?1461537518", + "id": 3480841, + "license_code": "cc-by", + "original_dimensions": { + "width": 1724, + "height": 1146 + }, + "url": "https://static.inaturalist.org/photos/3480841/square.JPG?1461537518" + }, + "iconic_taxon_name": "Insecta" + } + } + ], + "community_taxon_id": 493595, + "geojson": { + "coordinates": [ + "4.360086", + "50.646894" + ], + "type": "Point" + }, + "owners_identification_from_vision": true, + "identifications_count": 2, + "obscured": false, + "project_observations": [], + "num_identification_disagreements": 0, + "observation_photos": [ + { + "id": 22080796, + "position": 0, + "uuid": "76b54495-3497-4e96-b07c-7ad346939a02", + "photo": { + "attribution": "(c) Nicolas Noé, some rights reserved (CC BY)", + "flags": [], + "id": 24355315, + "license_code": "cc-by", + "original_dimensions": { + "width": 1445, + "height": 1057 + }, + "url": "https://static.inaturalist.org/photos/24355315/square.jpeg?1536150664" + } + }, + { + "id": 22080797, + "position": 1, + "uuid": "06b90a88-d98d-4447-b14c-5d354d2d68d1", + "photo": { + "attribution": "(c) Nicolas Noé, some rights reserved (CC BY)", + "flags": [], + "id": 24355313, + "license_code": "cc-by", + "original_dimensions": { + "width": 2048, + "height": 1364 + }, + "url": "https://static.inaturalist.org/photos/24355313/square.jpeg?1536150659" + } + } + ], + "geoprivacy": null, + "location": "50.646894,4.360086", + "votes": [], + "spam": false, + "user": { + "id": 886482, + "login": "niconoe", + "spam": false, + "suspended": false, + "login_autocomplete": "niconoe", + "login_exact": "niconoe", + "name": "Nicolas Noé", + "name_autocomplete": "Nicolas Noé", + "icon": "https://static.inaturalist.org/attachments/users/icons/886482/thumb.jpg?1529671435", + "observations_count": 223, + "identifications_count": 27, + "journal_posts_count": 0, + "activity_count": 250, + "roles": [], + "site_id": 1, + "icon_url": "https://static.inaturalist.org/attachments/users/icons/886482/medium.jpg?1529671435" + }, + "mappable": true, + "identifications_some_agree": true, + "project_ids_without_curator_id": [], + "place_guess": "54 rue des Badauds", + "faves": [], + "non_owner_ids": [ + { + "disagreement": false, + "flags": [], + "created_at": "2018-09-05T22:37:06+00:00", + "taxon_id": 493595, + "body": "", + "own_observation": false, + "uuid": "98d454e4-bead-473d-a372-f35b8ba5bf1c", + "taxon_change": null, + "vision": false, + "current": true, + "id": 34926789, + "created_at_details": { + "date": "2018-09-05", + "week": 36, + "month": 9, + "hour": 22, + "year": 2018, + "day": 5 + }, + "category": "improving", + "spam": false, + "user": { + "id": 180811, + "login": "borisb", + "spam": false, + "suspended": false, + "login_autocomplete": "borisb", + "login_exact": "borisb", + "name": "", + "name_autocomplete": "", + "icon": null, + "observations_count": 0, + "identifications_count": 105317, + "journal_posts_count": 0, + "activity_count": 105317, + "roles": [ + "curator" + ], + "site_id": 1 + }, + "previous_observation_taxon_id": 71157, + "taxon": { + "taxon_schemes_count": 1, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383/71157", + "min_species_ancestry": "48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157,493595", + "wikipedia_url": null, + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "created_at": "2016-04-25T22:35:20+00:00", + "taxon_changes_count": 0, + "complete_species_count": null, + "rank": "species", + "extinct": false, + "id": 493595, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383, + 71157 + ], + "observations_count": 3, + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "rank_level": 10, + "atlas_id": null, + "parent_id": 71157, + "name": "Lixus bardanae", + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/3480841/square.JPG?1461537518", + "attribution": "(c) Dimitǎr Boevski, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/3480841/medium.JPG?1461537518", + "id": 3480841, + "license_code": "cc-by", + "original_dimensions": { + "width": 1724, + "height": 1146 + }, + "url": "https://static.inaturalist.org/photos/3480841/square.JPG?1461537518" + }, + "iconic_taxon_name": "Insecta", + "ancestors": [ + { + "observations_count": 9030721, + "taxon_schemes_count": 2, + "ancestry": "48460", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 5 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Animal", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 1, + "rank_level": 70, + "taxon_changes_count": 3, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 48460, + "name": "Animalia", + "rank": "kingdom", + "extinct": false, + "id": 1, + "default_photo": { + "square_url": "https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg", + "attribution": "(c) David Midgley, some rights reserved (CC BY-NC-ND)", + "flags": [], + "medium_url": "https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b.jpg", + "id": 169, + "license_code": "cc-by-nc-nd", + "original_dimensions": null, + "url": "https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1 + ], + "iconic_taxon_name": "Animalia", + "preferred_common_name": "Animals" + }, + { + "observations_count": 4232787, + "taxon_schemes_count": 2, + "ancestry": "48460/1", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 1 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Arthropod", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 1, + "rank_level": 60, + "taxon_changes_count": 2, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 1, + "name": "Arthropoda", + "rank": "phylum", + "extinct": false, + "id": 47120, + "default_photo": { + "square_url": "https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg", + "attribution": "(c) Damien du Toit, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://farm1.staticflickr.com/1/380353_028542ead3.jpg", + "id": 4115, + "license_code": "cc-by", + "original_dimensions": null, + "url": "https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120 + ], + "iconic_taxon_name": "Animalia", + "preferred_common_name": "Arthropods" + }, + { + "observations_count": 3701511, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Hexapoda", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 1, + "rank_level": 57, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 47120, + "name": "Hexapoda", + "rank": "subphylum", + "extinct": false, + "id": 372739, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/1205428/square.?1413275622", + "attribution": "(c) heni, all rights reserved, uploaded by Jane Percival", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/1205428/medium.?1413275622", + "id": 1205428, + "license_code": null, + "original_dimensions": { + "width": 790, + "height": 557 + }, + "url": "https://static.inaturalist.org/photos/1205428/square.?1413275622" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739 + ], + "iconic_taxon_name": "Animalia", + "preferred_common_name": "Hexapods" + }, + { + "observations_count": 3696904, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 2 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Insect", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 50, + "taxon_changes_count": 2, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 372739, + "name": "Insecta", + "rank": "class", + "extinct": false, + "id": 47158, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739", + "attribution": "(c) Jason Michael Crockwell, some rights reserved (CC BY-NC-ND)", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/4744725/medium.jpeg?1472813739", + "id": 4744725, + "license_code": "cc-by-nc-nd", + "original_dimensions": { + "width": 2048, + "height": 1536 + }, + "url": "https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Insects" + }, + { + "observations_count": 3659996, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 2 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Pterygota", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 47, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 47158, + "name": "Pterygota", + "rank": "subclass", + "extinct": false, + "id": 184884, + "default_photo": { + "square_url": "https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg", + "attribution": "(c) gbohne, some rights reserved (CC BY-SA)", + "flags": [], + "medium_url": "https://farm5.staticflickr.com/4126/5064146527_95d67d2cae.jpg", + "id": 1670296, + "license_code": "cc-by-sa", + "original_dimensions": null, + "url": "https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Winged and Once-winged Insects" + }, + { + "observations_count": 410369, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Beetle", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 40, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 184884, + "name": "Coleoptera", + "rank": "order", + "extinct": false, + "id": 47208, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/1077943/square.jpg?1444320780", + "attribution": "(c) Jay Keller, all rights reserved, uploaded by Jay L. Keller", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/1077943/medium.jpg?1444320780", + "id": 1077943, + "license_code": null, + "original_dimensions": { + "width": 1711, + "height": 1679 + }, + "url": "https://static.inaturalist.org/photos/1077943/square.jpg?1444320780" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Beetles" + }, + { + "observations_count": 349335, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Polyphaga", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 37, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 47208, + "name": "Polyphaga", + "rank": "suborder", + "extinct": false, + "id": 71130, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/12466261/square.jpg?1513752231", + "attribution": "(c) Arnold Wijker, some rights reserved (CC BY-NC)", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/12466261/medium.jpg?1513752231", + "id": 12466261, + "license_code": "cc-by-nc", + "original_dimensions": { + "width": 800, + "height": 714 + }, + "url": "https://static.inaturalist.org/photos/12466261/square.jpg?1513752231" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Water, Rove, Scarab, Long-horned, Leaf, and Snout Beetles" + }, + { + "observations_count": 222277, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Cucujiformia", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 35, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 71130, + "name": "Cucujiformia", + "rank": "infraorder", + "extinct": false, + "id": 372852, + "default_photo": { + "square_url": "https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg", + "attribution": "(c) Udo Schmidt, some rights reserved (CC BY-SA)", + "flags": [], + "medium_url": "https://farm5.staticflickr.com/4014/5142992820_7b86576c37.jpg", + "id": 1182110, + "license_code": "cc-by-sa", + "original_dimensions": null, + "url": "https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Cucujiform Beetles" + }, + { + "observations_count": 25765, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 1 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Weevil", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 33, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 372852, + "name": "Curculionoidea", + "rank": "superfamily", + "extinct": false, + "id": 60473, + "default_photo": { + "square_url": "https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg", + "attribution": "(c) Mick Talbot, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://farm3.staticflickr.com/2535/3729552832_4a436b62d9.jpg", + "id": 99067, + "license_code": "cc-by", + "original_dimensions": null, + "url": "https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Snout and Bark Beetles" + }, + { + "observations_count": 19478, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Curculionidae", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 30, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 60473, + "name": "Curculionidae", + "rank": "family", + "extinct": false, + "id": 48736, + "default_photo": { + "square_url": "https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg", + "attribution": "(c) Mick Talbot, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://farm4.staticflickr.com/3174/2891245226_93744353d6.jpg", + "id": 24438, + "license_code": "cc-by", + "original_dimensions": null, + "url": "https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "True Weevils" + }, + { + "observations_count": 1593, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Lixinae", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 27, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 48736, + "name": "Lixinae", + "rank": "subfamily", + "extinct": false, + "id": 272543, + "default_photo": { + "square_url": "https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg", + "attribution": "(c) Ferran Turmo Gort, some rights reserved (CC BY-NC-SA)", + "flags": [], + "medium_url": "https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82.jpg", + "id": 3094335, + "license_code": "cc-by-nc-sa", + "original_dimensions": null, + "url": "https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543 + ], + "iconic_taxon_name": "Insecta" + }, + { + "observations_count": 1093, + "taxon_schemes_count": 1, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": null, + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 25, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 272543, + "name": "Lixini", + "rank": "tribe", + "extinct": false, + "id": 507383, + "default_photo": { + "square_url": "https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg", + "attribution": "(c) Alexey Kljatov, some rights reserved (CC BY-NC)", + "flags": [], + "medium_url": "https://farm6.staticflickr.com/5092/5528807700_9da380c55d.jpg", + "id": 5744395, + "license_code": "cc-by-nc", + "original_dimensions": null, + "url": "https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383 + ], + "iconic_taxon_name": "Insecta" + }, + { + "observations_count": 618, + "taxon_schemes_count": 1, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Lixus_(beetle)", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 20, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 507383, + "name": "Lixus", + "rank": "genus", + "extinct": false, + "id": 71157, + "default_photo": { + "square_url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg", + "attribution": "(c) Yvan, some rights reserved (CC BY-NC-SA)", + "flags": [], + "medium_url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03.jpg", + "id": 31486, + "license_code": "cc-by-nc-sa", + "original_dimensions": null, + "url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383, + 71157 + ], + "iconic_taxon_name": "Insecta" + } + ] + }, + "previous_observation_taxon": { + "taxon_schemes_count": 1, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383", + "min_species_ancestry": "48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157", + "wikipedia_url": "http://en.wikipedia.org/wiki/Lixus_(beetle)", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "created_at": "2011-05-11T05:36:06+00:00", + "taxon_changes_count": 0, + "complete_species_count": null, + "rank": "genus", + "extinct": false, + "id": 71157, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383, + 71157 + ], + "observations_count": 618, + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "rank_level": 20, + "atlas_id": null, + "parent_id": 507383, + "name": "Lixus", + "default_photo": { + "square_url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg", + "attribution": "(c) Yvan, some rights reserved (CC BY-NC-SA)", + "flags": [], + "medium_url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03.jpg", + "id": 31486, + "license_code": "cc-by-nc-sa", + "original_dimensions": null, + "url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg" + }, + "iconic_taxon_name": "Insecta" + } + }, + { + "disagreement": false, + "flags": [], + "created_at": "2018-09-22T17:19:26+00:00", + "taxon_id": 493595, + "body": null, + "own_observation": false, + "uuid": "2c245e63-c1fd-4295-9b95-1d06c339d9a5", + "taxon_change": null, + "vision": false, + "current": true, + "id": 36039221, + "created_at_details": { + "date": "2018-09-22", + "week": 38, + "month": 9, + "hour": 17, + "year": 2018, + "day": 22 + }, + "category": "supporting", + "spam": false, + "user": { + "id": 1226913, + "login": "jpreudhomme", + "spam": false, + "suspended": false, + "login_autocomplete": "jpreudhomme", + "login_exact": "jpreudhomme", + "name": "jupreudhomme", + "name_autocomplete": "jupreudhomme", + "icon": "https://static.inaturalist.org/attachments/users/icons/1226913/thumb.jpeg?1537623104", + "observations_count": 4, + "identifications_count": 201, + "journal_posts_count": 0, + "activity_count": 205, + "roles": [], + "site_id": 1, + "icon_url": "https://static.inaturalist.org/attachments/users/icons/1226913/medium.jpeg?1537623104" + }, + "previous_observation_taxon_id": 493595, + "taxon": { + "taxon_schemes_count": 1, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383/71157", + "min_species_ancestry": "48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157,493595", + "wikipedia_url": null, + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "created_at": "2016-04-25T22:35:20+00:00", + "taxon_changes_count": 0, + "complete_species_count": null, + "rank": "species", + "extinct": false, + "id": 493595, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383, + 71157 + ], + "observations_count": 3, + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "rank_level": 10, + "atlas_id": null, + "parent_id": 71157, + "name": "Lixus bardanae", + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/3480841/square.JPG?1461537518", + "attribution": "(c) Dimitǎr Boevski, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/3480841/medium.JPG?1461537518", + "id": 3480841, + "license_code": "cc-by", + "original_dimensions": { + "width": 1724, + "height": 1146 + }, + "url": "https://static.inaturalist.org/photos/3480841/square.JPG?1461537518" + }, + "iconic_taxon_name": "Insecta", + "ancestors": [ + { + "observations_count": 9030721, + "taxon_schemes_count": 2, + "ancestry": "48460", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 5 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Animal", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 1, + "rank_level": 70, + "taxon_changes_count": 3, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 48460, + "name": "Animalia", + "rank": "kingdom", + "extinct": false, + "id": 1, + "default_photo": { + "square_url": "https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg", + "attribution": "(c) David Midgley, some rights reserved (CC BY-NC-ND)", + "flags": [], + "medium_url": "https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b.jpg", + "id": 169, + "license_code": "cc-by-nc-nd", + "original_dimensions": null, + "url": "https://farm3.staticflickr.com/2188/2124709826_fd4ba36d1b_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1 + ], + "iconic_taxon_name": "Animalia", + "preferred_common_name": "Animals" + }, + { + "observations_count": 4232787, + "taxon_schemes_count": 2, + "ancestry": "48460/1", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 1 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Arthropod", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 1, + "rank_level": 60, + "taxon_changes_count": 2, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 1, + "name": "Arthropoda", + "rank": "phylum", + "extinct": false, + "id": 47120, + "default_photo": { + "square_url": "https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg", + "attribution": "(c) Damien du Toit, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://farm1.staticflickr.com/1/380353_028542ead3.jpg", + "id": 4115, + "license_code": "cc-by", + "original_dimensions": null, + "url": "https://farm1.staticflickr.com/1/380353_028542ead3_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120 + ], + "iconic_taxon_name": "Animalia", + "preferred_common_name": "Arthropods" + }, + { + "observations_count": 3701511, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Hexapoda", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 1, + "rank_level": 57, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 47120, + "name": "Hexapoda", + "rank": "subphylum", + "extinct": false, + "id": 372739, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/1205428/square.?1413275622", + "attribution": "(c) heni, all rights reserved, uploaded by Jane Percival", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/1205428/medium.?1413275622", + "id": 1205428, + "license_code": null, + "original_dimensions": { + "width": 790, + "height": 557 + }, + "url": "https://static.inaturalist.org/photos/1205428/square.?1413275622" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739 + ], + "iconic_taxon_name": "Animalia", + "preferred_common_name": "Hexapods" + }, + { + "observations_count": 3696904, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 2 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Insect", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 50, + "taxon_changes_count": 2, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 372739, + "name": "Insecta", + "rank": "class", + "extinct": false, + "id": 47158, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739", + "attribution": "(c) Jason Michael Crockwell, some rights reserved (CC BY-NC-ND)", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/4744725/medium.jpeg?1472813739", + "id": 4744725, + "license_code": "cc-by-nc-nd", + "original_dimensions": { + "width": 2048, + "height": 1536 + }, + "url": "https://static.inaturalist.org/photos/4744725/square.jpeg?1472813739" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Insects" + }, + { + "observations_count": 3659996, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 2 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Pterygota", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 47, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 47158, + "name": "Pterygota", + "rank": "subclass", + "extinct": false, + "id": 184884, + "default_photo": { + "square_url": "https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg", + "attribution": "(c) gbohne, some rights reserved (CC BY-SA)", + "flags": [], + "medium_url": "https://farm5.staticflickr.com/4126/5064146527_95d67d2cae.jpg", + "id": 1670296, + "license_code": "cc-by-sa", + "original_dimensions": null, + "url": "https://farm5.staticflickr.com/4126/5064146527_95d67d2cae_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Winged and Once-winged Insects" + }, + { + "observations_count": 410369, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Beetle", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 40, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 184884, + "name": "Coleoptera", + "rank": "order", + "extinct": false, + "id": 47208, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/1077943/square.jpg?1444320780", + "attribution": "(c) Jay Keller, all rights reserved, uploaded by Jay L. Keller", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/1077943/medium.jpg?1444320780", + "id": 1077943, + "license_code": null, + "original_dimensions": { + "width": 1711, + "height": 1679 + }, + "url": "https://static.inaturalist.org/photos/1077943/square.jpg?1444320780" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Beetles" + }, + { + "observations_count": 349335, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Polyphaga", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 37, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 47208, + "name": "Polyphaga", + "rank": "suborder", + "extinct": false, + "id": 71130, + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/12466261/square.jpg?1513752231", + "attribution": "(c) Arnold Wijker, some rights reserved (CC BY-NC)", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/12466261/medium.jpg?1513752231", + "id": 12466261, + "license_code": "cc-by-nc", + "original_dimensions": { + "width": 800, + "height": 714 + }, + "url": "https://static.inaturalist.org/photos/12466261/square.jpg?1513752231" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Water, Rove, Scarab, Long-horned, Leaf, and Snout Beetles" + }, + { + "observations_count": 222277, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Cucujiformia", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 35, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 71130, + "name": "Cucujiformia", + "rank": "infraorder", + "extinct": false, + "id": 372852, + "default_photo": { + "square_url": "https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg", + "attribution": "(c) Udo Schmidt, some rights reserved (CC BY-SA)", + "flags": [], + "medium_url": "https://farm5.staticflickr.com/4014/5142992820_7b86576c37.jpg", + "id": 1182110, + "license_code": "cc-by-sa", + "original_dimensions": null, + "url": "https://farm5.staticflickr.com/4014/5142992820_7b86576c37_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Cucujiform Beetles" + }, + { + "observations_count": 25765, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 1 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Weevil", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 33, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 372852, + "name": "Curculionoidea", + "rank": "superfamily", + "extinct": false, + "id": 60473, + "default_photo": { + "square_url": "https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg", + "attribution": "(c) Mick Talbot, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://farm3.staticflickr.com/2535/3729552832_4a436b62d9.jpg", + "id": 99067, + "license_code": "cc-by", + "original_dimensions": null, + "url": "https://farm3.staticflickr.com/2535/3729552832_4a436b62d9_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "Snout and Bark Beetles" + }, + { + "observations_count": 19478, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Curculionidae", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 30, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 60473, + "name": "Curculionidae", + "rank": "family", + "extinct": false, + "id": 48736, + "default_photo": { + "square_url": "https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg", + "attribution": "(c) Mick Talbot, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://farm4.staticflickr.com/3174/2891245226_93744353d6.jpg", + "id": 24438, + "license_code": "cc-by", + "original_dimensions": null, + "url": "https://farm4.staticflickr.com/3174/2891245226_93744353d6_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736 + ], + "iconic_taxon_name": "Insecta", + "preferred_common_name": "True Weevils" + }, + { + "observations_count": 1593, + "taxon_schemes_count": 2, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Lixinae", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 27, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 48736, + "name": "Lixinae", + "rank": "subfamily", + "extinct": false, + "id": 272543, + "default_photo": { + "square_url": "https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg", + "attribution": "(c) Ferran Turmo Gort, some rights reserved (CC BY-NC-SA)", + "flags": [], + "medium_url": "https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82.jpg", + "id": 3094335, + "license_code": "cc-by-nc-sa", + "original_dimensions": null, + "url": "https://farm4.staticflickr.com/3504/3697655122_b0ba7ebf82_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543 + ], + "iconic_taxon_name": "Insecta" + }, + { + "observations_count": 1093, + "taxon_schemes_count": 1, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": null, + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 25, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 272543, + "name": "Lixini", + "rank": "tribe", + "extinct": false, + "id": 507383, + "default_photo": { + "square_url": "https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg", + "attribution": "(c) Alexey Kljatov, some rights reserved (CC BY-NC)", + "flags": [], + "medium_url": "https://farm6.staticflickr.com/5092/5528807700_9da380c55d.jpg", + "id": 5744395, + "license_code": "cc-by-nc", + "original_dimensions": null, + "url": "https://farm6.staticflickr.com/5092/5528807700_9da380c55d_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383 + ], + "iconic_taxon_name": "Insecta" + }, + { + "observations_count": 618, + "taxon_schemes_count": 1, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383", + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "wikipedia_url": "http://en.wikipedia.org/wiki/Lixus_(beetle)", + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "rank_level": 20, + "taxon_changes_count": 0, + "atlas_id": null, + "complete_species_count": null, + "parent_id": 507383, + "name": "Lixus", + "rank": "genus", + "extinct": false, + "id": 71157, + "default_photo": { + "square_url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg", + "attribution": "(c) Yvan, some rights reserved (CC BY-NC-SA)", + "flags": [], + "medium_url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03.jpg", + "id": 31486, + "license_code": "cc-by-nc-sa", + "original_dimensions": null, + "url": "https://farm4.staticflickr.com/3614/3557113604_11b14dfd03_s.jpg" + }, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383, + 71157 + ], + "iconic_taxon_name": "Insecta" + } + ] + }, + "previous_observation_taxon": { + "taxon_schemes_count": 1, + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383/71157", + "min_species_ancestry": "48460,1,47120,372739,47158,184884,47208,71130,372852,60473,48736,272543,507383,71157,493595", + "wikipedia_url": null, + "current_synonymous_taxon_ids": null, + "iconic_taxon_id": 47158, + "created_at": "2016-04-25T22:35:20+00:00", + "taxon_changes_count": 0, + "complete_species_count": null, + "rank": "species", + "extinct": false, + "id": 493595, + "ancestor_ids": [ + 48460, + 1, + 47120, + 372739, + 47158, + 184884, + 47208, + 71130, + 372852, + 60473, + 48736, + 272543, + 507383, + 71157, + 493595 + ], + "observations_count": 3, + "is_active": true, + "flag_counts": { + "unresolved": 0, + "resolved": 0 + }, + "rank_level": 10, + "atlas_id": null, + "parent_id": 71157, + "name": "Lixus bardanae", + "default_photo": { + "square_url": "https://static.inaturalist.org/photos/3480841/square.JPG?1461537518", + "attribution": "(c) Dimitǎr Boevski, some rights reserved (CC BY)", + "flags": [], + "medium_url": "https://static.inaturalist.org/photos/3480841/medium.JPG?1461537518", + "id": 3480841, + "license_code": "cc-by", + "original_dimensions": { + "width": 1724, + "height": 1146 + }, + "url": "https://static.inaturalist.org/photos/3480841/square.JPG?1461537518" + }, + "iconic_taxon_name": "Insecta" + } + } + ] + } + ] +} diff --git a/test/sample_data/get_observations.atom b/test/sample_data/get_observations.atom new file mode 100644 index 00000000..3265a2f2 --- /dev/null +++ b/test/sample_data/get_observations.atom @@ -0,0 +1,21 @@ + + + tag:www.inaturalist.org,2005:/observations + + + iNaturalist: Observations by Everyone + 2020-06-10T23:05:37Z + /assets/favicon-cf3214988200dff386f744b79050b857.png + + tag:www.inaturalist.org,2005:Observation/16227955 + 2018-09-05T12:31:08Z + 2018-09-22T17:19:27Z + + Lixus bardanae + + niconoe + + <p><img src="https://static.inaturalist.org/photos/24355313/medium.jpeg?1536150659" alt="Medium" /> <img src="https://static.inaturalist.org/photos/24355315/medium.jpeg?1536150664" alt="Medium" /></p><p></p> + 50.646894 4.360086 + + diff --git a/test/sample_data/get_observations.csv b/test/sample_data/get_observations.csv new file mode 100644 index 00000000..e6280ce2 --- /dev/null +++ b/test/sample_data/get_observations.csv @@ -0,0 +1,2 @@ +scientific_name,datetime,description,place_guess,latitude,longitude,tag_list,common_name,url,image_url,user_login,id,species_guess,iconic_taxon_name,taxon_id,num_identification_agreements,num_identification_disagreements,observed_on_string,observed_on,time_observed_at,time_zone,positional_accuracy,private_place_guess,geoprivacy,taxon_geoprivacy,coordinates_obscured,positioning_method,positioning_device,user_id,created_at,updated_at,quality_grade,license,sound_url,oauth_application_id,captive_cultivated +Lixus bardanae,2018-09-05 14:06:00 +0200,"",54 rue des Badauds,50.646894,4.360086,"",,https://www.inaturalist.org/observations/16227955,https://static.inaturalist.org/photos/24355315/medium.jpeg?1536150664,niconoe,16227955,Lixus bardanae,Insecta,493595,2,0,2018/09/05 2:06 PM CEST,2018-09-05,2018-09-05 12:06:00 UTC,Paris,23,,,,false,,,886482,2018-09-05 12:31:08 UTC,2018-09-22 17:19:27 UTC,research,CC0,,,false diff --git a/test/sample_data/get_observations.dwc b/test/sample_data/get_observations.dwc new file mode 100644 index 00000000..d5058cfb --- /dev/null +++ b/test/sample_data/get_observations.dwc @@ -0,0 +1,73 @@ + + + + https://www.inaturalist.org/observations/16227955 + https://www.inaturalist.org/observations/16227955 + HumanObservation + 2018-09-22T17:19:27Z + iNaturalist + Observations + iNaturalist research-grade observations + 16227955 + https://www.inaturalist.org/observations/16227955 + https://www.inaturalist.org/observations/16227955 + Nicolas Noé + wild + 2018-09-05T14:06:00+02:00 + 12:06:00Z + 2018/09/05 2:06 PM CEST + 54 rue des Badauds + 50.646894 + 4.360086 + 23 + BE + Wallonie + 34896306 + 2018-09-05T12:34:22Z + 493595 + Lixus bardanae + species + Animalia + Arthropoda + Insecta + Coleoptera + Curculionidae + Lixus + http://creativecommons.org/publicdomain/zero/1.0/ + By Nicolas Noé no rights reserved + Nicolas Noé + niconoe + + https://www.inaturalist.org/photos/24355313 + http://purl.org/dc/dcmitype/StillImage + image/jpeg + https://static.inaturalist.org/photos/24355313/original.jpeg?1536150659 + https://static.inaturalist.org/photos/24355313/thumb.jpeg?1536150659 + https://www.inaturalist.org/photos/24355313 + https://www.inaturalist.org/photos/24355313 + 2018-09-05T12:31:01Z + 2018-09-05T12:31:01Z + http://creativecommons.org/licenses/by/4.0/ + Copyright Nicolas Noé, licensed under a Creative Commons Attribution License license: http://creativecommons.org/licenses/by/4.0/ + Nicolas Noé + iNaturalist + Nicolas Noé + + + https://www.inaturalist.org/photos/24355315 + http://purl.org/dc/dcmitype/StillImage + image/jpeg + https://static.inaturalist.org/photos/24355315/original.jpeg?1536150664 + https://static.inaturalist.org/photos/24355315/thumb.jpeg?1536150664 + https://www.inaturalist.org/photos/24355315 + https://www.inaturalist.org/photos/24355315 + 2018-09-05T12:31:05Z + 2018-09-05T12:31:05Z + http://creativecommons.org/licenses/by/4.0/ + Copyright Nicolas Noé, licensed under a Creative Commons Attribution License license: http://creativecommons.org/licenses/by/4.0/ + Nicolas Noé + iNaturalist + Nicolas Noé + + + diff --git a/test/sample_data/get_observations.js b/test/sample_data/get_observations.js new file mode 100644 index 00000000..a3e8dfd7 --- /dev/null +++ b/test/sample_data/get_observations.js @@ -0,0 +1,11 @@ +try { + var msg = document.getElementById('inatwidgetmsg'); + if (msg) { + msg.style.visibility = 'visible'; + } + + + document.write(' + + + +
\"Square\"<\/a> <\/td>Lixus bardanae<\/a>
Observer: <\/span>niconoe<\/a><\/span>
Date: <\/span> Sep 05 2018<\/span>
Place: <\/span>54 rue des Badauds<\/span><\/div><\/td><\/tr><\/table>') + + +} catch (e) {} diff --git a/test/sample_data/get_observations.json b/test/sample_data/get_observations.json new file mode 100644 index 00000000..f2539c34 --- /dev/null +++ b/test/sample_data/get_observations.json @@ -0,0 +1,131 @@ +[ + { + "id": 16227955, + "observed_on": "2018-09-05", + "description": "", + "latitude": "50.646894", + "longitude": "4.360086", + "map_scale": 17, + "timeframe": null, + "species_guess": "Lixus bardanae", + "user_id": 886482, + "taxon_id": 493595, + "created_at": "2018-09-05T12:31:08.048Z", + "updated_at": "2018-09-22T17:19:27.080Z", + "place_guess": "54 rue des Badauds", + "id_please": false, + "observed_on_string": "2018/09/05 2:06 PM CEST", + "iconic_taxon_id": 47158, + "num_identification_agreements": 2, + "num_identification_disagreements": 0, + "time_observed_at": "2018-09-05T12:06:00.000Z", + "time_zone": "Paris", + "location_is_exact": true, + "delta": false, + "positional_accuracy": 23, + "private_latitude": null, + "private_longitude": null, + "private_positional_accuracy": null, + "geoprivacy": null, + "quality_grade": "research", + "positioning_method": null, + "positioning_device": null, + "out_of_range": null, + "license": "CC0", + "uri": "https://www.inaturalist.org/observations/16227955", + "observation_photos_count": 2, + "comments_count": 2, + "zic_time_zone": "Europe/Paris", + "oauth_application_id": null, + "observation_sounds_count": 0, + "identifications_count": 3, + "captive": false, + "community_taxon_id": 493595, + "site_id": 1, + "old_uuid": null, + "public_positional_accuracy": 23, + "mappable": true, + "cached_votes_total": 0, + "last_indexed_at": "2019-10-10T20:19:39.304Z", + "private_place_guess": null, + "uuid": "6448d03a-7f9a-4099-86aa-ca09a7740b00", + "taxon_geoprivacy": null, + "short_description": "", + "user_login": "niconoe", + "iconic_taxon_name": "Insecta", + "tag_list": [], + "faves_count": 0, + "created_at_utc": "2018-09-05T12:31:08.048Z", + "updated_at_utc": "2018-09-22T17:19:27.080Z", + "time_observed_at_utc": "2018-09-05T12:06:00.000Z", + "owners_identification_from_vision": true, + "taxon": { + "id": 493595, + "name": "Lixus bardanae", + "rank": "species", + "ancestry": "48460/1/47120/372739/47158/184884/47208/71130/372852/60473/48736/272543/507383/71157", + "common_name": null + }, + "iconic_taxon": { + "id": 47158, + "name": "Insecta", + "rank": "class", + "rank_level": 50.0, + "ancestry": "48460/1/47120/372739" + }, + "user": { + "login": "niconoe", + "user_icon_url": "https://static.inaturalist.org/attachments/users/icons/886482/thumb.jpg?1529671435" + }, + "photos": [ + { + "id": 24355313, + "user_id": 886482, + "native_photo_id": "24355313", + "square_url": "https://static.inaturalist.org/photos/24355313/square.jpeg?1536150659", + "thumb_url": "https://static.inaturalist.org/photos/24355313/thumb.jpeg?1536150659", + "small_url": "https://static.inaturalist.org/photos/24355313/small.jpeg?1536150659", + "medium_url": "https://static.inaturalist.org/photos/24355313/medium.jpeg?1536150659", + "large_url": "https://static.inaturalist.org/photos/24355313/large.jpeg?1536150659", + "created_at": "2018-09-05T12:31:01.946Z", + "updated_at": "2018-09-05T12:31:01.946Z", + "native_page_url": "https://www.inaturalist.org/photos/24355313", + "native_username": "niconoe", + "native_realname": "Nicolas No\u00e9", + "license": 4, + "subtype": null, + "native_original_image_url": null, + "uuid": "be0dcd96-db21-4c19-814b-bcd33c051ea6", + "license_code": "CC-BY", + "attribution": "(c) Nicolas No\u00e9, some rights reserved (CC BY)", + "license_name": "Creative Commons Attribution License", + "license_url": "http://creativecommons.org/licenses/by/4.0/", + "type": "LocalPhoto" + }, + { + "id": 24355315, + "user_id": 886482, + "native_photo_id": "24355315", + "square_url": "https://static.inaturalist.org/photos/24355315/square.jpeg?1536150664", + "thumb_url": "https://static.inaturalist.org/photos/24355315/thumb.jpeg?1536150664", + "small_url": "https://static.inaturalist.org/photos/24355315/small.jpeg?1536150664", + "medium_url": "https://static.inaturalist.org/photos/24355315/medium.jpeg?1536150664", + "large_url": "https://static.inaturalist.org/photos/24355315/large.jpeg?1536150664", + "created_at": "2018-09-05T12:31:05.862Z", + "updated_at": "2018-09-05T12:31:05.862Z", + "native_page_url": "https://www.inaturalist.org/photos/24355315", + "native_username": "niconoe", + "native_realname": "Nicolas No\u00e9", + "license": 4, + "subtype": null, + "native_original_image_url": null, + "uuid": "154b105a-3e04-448d-b8a3-8ced07af2adf", + "license_code": "CC-BY", + "attribution": "(c) Nicolas No\u00e9, some rights reserved (CC BY)", + "license_name": "Creative Commons Attribution License", + "license_url": "http://creativecommons.org/licenses/by/4.0/", + "type": "LocalPhoto" + } + ] + } +] \ No newline at end of file diff --git a/test/sample_data/get_observations.kml b/test/sample_data/get_observations.kml new file mode 100644 index 00000000..371cfe0d --- /dev/null +++ b/test/sample_data/get_observations.kml @@ -0,0 +1,45 @@ + + + + + Lixus bardanae + 1 + https://www.inaturalist.org/observations/16227955 + + 2018-09-05T14:06:00+02:00 + + + +
+ + Thumb + + +
+ Observed by niconoe + + 2018-09-05 + 54 rue des Badauds +
+
+
+ + + + +
+]]> + + /assets/observations/google_earth-824f44474a896e5afd52c3274499151b.kml?prevent=155#Insecta + + 4.360086,50.646894 + + + + diff --git a/test/test_helpers.py b/test/test_helpers.py index e81e5a39..6a785034 100644 --- a/test/test_helpers.py +++ b/test/test_helpers.py @@ -3,7 +3,7 @@ from dateutil.tz import gettz from unittest.mock import patch -from pyinaturalist.helpers import ( +from pyinaturalist.request_params import ( preprocess_request_params, convert_bool_params, convert_datetime_params, @@ -60,17 +60,17 @@ def test_strip_empty_params(): ("q", "not a datetime", "not a datetime"), ], ) -@patch("pyinaturalist.helpers.tzlocal", return_value=gettz("US/Pacific")) +@patch("pyinaturalist.request_params.tzlocal", return_value=gettz("US/Pacific")) def test_convert_datetime_params(tzlocal, param, value, expected): converted = convert_datetime_params({param: value}) assert converted[param] == expected # This is just here so that tests will fail if one of the conversion steps is removed -@patch("pyinaturalist.helpers.convert_bool_params") -@patch("pyinaturalist.helpers.convert_datetime_params") -@patch("pyinaturalist.helpers.convert_list_params") -@patch("pyinaturalist.helpers.strip_empty_params") +@patch("pyinaturalist.request_params.convert_bool_params") +@patch("pyinaturalist.request_params.convert_datetime_params") +@patch("pyinaturalist.request_params.convert_list_params") +@patch("pyinaturalist.request_params.strip_empty_params") def test_preprocess_request_params(mock_bool, mock_datetime, mock_list, mock_strip): preprocess_request_params({"id": 1}) assert all([mock_bool.called, mock_datetime.called, mock_list.called, mock_strip.called]) From 17c932b59b9fdb123d8fe825ea652e9b1febbc54 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Wed, 10 Jun 2020 20:28:47 -0500 Subject: [PATCH 16/25] Add GeoJSON response format for observations --- HISTORY.rst | 1 + pyinaturalist/constants.py | 14 +++- pyinaturalist/node_api.py | 64 ++++++++------- pyinaturalist/response_format.py | 82 +++++++++++++++++++ pyinaturalist/rest_api.py | 3 +- test/sample_data/get_observations.geojson | 23 ++++++ test/test_pyinaturalist.py | 74 +++++++++++++---- ...test_helpers.py => test_request_params.py} | 5 +- 8 files changed, 217 insertions(+), 49 deletions(-) create mode 100644 pyinaturalist/response_format.py create mode 100644 test/sample_data/get_observations.geojson rename test/{test_helpers.py => test_request_params.py} (96%) diff --git a/HISTORY.rst b/HISTORY.rst index b8adba08..bdb2da62 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -9,6 +9,7 @@ History * Added `minify` option to `node_api.get_taxa_autocomplete()` * Convert all date and datetime parameters to timezone-aware ISO 8601 timestamps * Added a dry-run mode to mock out API requests for testing +* Added 6 additional observation response formats, including GeoJSON, Darwin Core, and others 0.9.1 (2020-05-26) ++++++++++++++++++ diff --git a/pyinaturalist/constants.py b/pyinaturalist/constants.py index 9e0f636f..781d3d5a 100644 --- a/pyinaturalist/constants.py +++ b/pyinaturalist/constants.py @@ -9,6 +9,19 @@ DRY_RUN_WRITE_ONLY = False # Only mock 'write' requests WRITE_HTTP_METHODS = ["PATCH", "POST", "PUT", "DELETE"] +# Basic observation attributes to include by default in geojson responses +DEFAULT_OBSERVATION_ATTRS = [ + "id", + "photo_url", + "positional_accuracy", + "preferred_common_name", + "quality_grade", + "taxon_id", + "taxon_name", + "time_observed_at", + "uri", +] + # All request parameters from both Node API and REST (Rails) API that accept date or datetime strings DATETIME_PARAMS = [ "created_after", @@ -30,7 +43,6 @@ ] # Reponse formats supported by GET /observations endpoint -# TODO: custom geojson FeatureCollection format OBSERVATION_FORMATS = ["atom", "csv", "dwc", "json", "kml", "widget"] # Taxonomic ranks from Node API Swagger spec diff --git a/pyinaturalist/node_api.py b/pyinaturalist/node_api.py index 8af9ed46..481ea711 100644 --- a/pyinaturalist/node_api.py +++ b/pyinaturalist/node_api.py @@ -1,5 +1,7 @@ -# Code to access the (read-only, but fast) Node based public iNaturalist API -# See: http://api.inaturalist.org/v1/docs/ +""" +Code to access the (read-only, but fast) Node based public iNaturalist API +See: http://api.inaturalist.org/v1/docs/ +""" from logging import getLogger from time import sleep from typing import Dict, Any, List @@ -7,13 +9,22 @@ import requests from urllib.parse import urljoin -from pyinaturalist.constants import THROTTLING_DELAY, INAT_NODE_API_BASE_URL, RANKS +from pyinaturalist.constants import ( + DEFAULT_OBSERVATION_ATTRS, + INAT_NODE_API_BASE_URL, + PER_PAGE_RESULTS, + THROTTLING_DELAY, +) from pyinaturalist.exceptions import ObservationNotFound -from pyinaturalist.helpers import is_int, merge_two_dicts +from pyinaturalist.request_params import is_int, merge_two_dicts +from pyinaturalist.response_format import ( + format_taxon, + as_geojson_feature_collection, + _get_rank_range, + flatten_nested_params, +) from pyinaturalist.api_requests import get -PER_PAGE_RESULTS = 30 # Paginated queries: how many records do we ask per page? - logger = getLogger(__name__) @@ -91,6 +102,23 @@ def get_all_observations(params: Dict, user_agent: str = None) -> List[Dict[str, id_above = results[-1]["id"] +def get_geojson_observations(properties: List[str] = None, **kwargs) -> Dict[str, Any]: + """ Get all observation results combined into a GeoJSON ``FeatureCollection``. + + Args: + properties: Properties from observation results to include as GeoJSON properties + kwargs: Arguments for :py:func:`.get_observations` + + Returns: + A list of dicts containing taxa results + """ + observations = get_all_observations(kwargs) + return as_geojson_feature_collection( + (flatten_nested_params(obs) for obs in observations), + properties=properties or DEFAULT_OBSERVATION_ATTRS, + ) + + def get_taxa_by_id(taxon_id: int, user_agent: str = None) -> Dict[str, Any]: """ Get one or more taxa by ID. @@ -168,27 +196,3 @@ def get_taxa_autocomplete(user_agent: str = None, minify: bool = False, **params if minify: json_response["results"] = [format_taxon(t) for t in json_response["results"]] return json_response - - -def format_taxon(taxon: Dict) -> str: - """Format a taxon result into a single string containing taxon ID, rank, and name - (including common name, if available). - """ - # Visually align taxon IDs (< 7 chars) and ranks (< 11 chars) - common = taxon.get("preferred_common_name") - return "{:>8}: {:>12} {}{}".format( - taxon["id"], taxon["rank"].title(), taxon["name"], " ({})".format(common) if common else "", - ) - - -def _get_rank_range(min_rank: str = None, max_rank: str = None) -> List[str]: - """ Translate min and/or max rank into a list of ranks """ - min_rank_index = _get_rank_index(min_rank) if min_rank else 0 - max_rank_index = _get_rank_index(max_rank) + 1 if max_rank else len(RANKS) - return RANKS[min_rank_index:max_rank_index] - - -def _get_rank_index(rank: str) -> int: - if rank not in RANKS: - raise ValueError("Invalid rank") - return RANKS.index(rank) diff --git a/pyinaturalist/response_format.py b/pyinaturalist/response_format.py new file mode 100644 index 00000000..3ea94769 --- /dev/null +++ b/pyinaturalist/response_format.py @@ -0,0 +1,82 @@ +""" Helper functions for formatting API responses """ +from typing import Any, Dict, List, Iterable + +from pyinaturalist.constants import RANKS + + +def as_geojson_feature_collection( + results: Iterable[Dict[str, Any]], properties: List[str] = None +) -> Dict[str, Any]: + """" + Convert results from an API response into a + `geojson FeatureCollection`_ object. + This is currently only used for observations, but could be used for any other responses with + geospatial info. + + Args: + results: List of results from API response + properties: Whitelist of specific properties to include + """ + return { + "type": "FeatureCollection", + "features": [as_geojson_feature(record, properties) for record in results], + } + + +def as_geojson_feature(result: Dict[str, Any], properties: List[str] = None) -> Dict[str, Any]: + """" + Convert an individual response item to a geojson Feature object, optionally with specific + response properties included. + + Args: + result: A single response item + properties: Whitelist of specific properties to include + """ + result["geojson"]["coordinates"] = [float(i) for i in result["geojson"]["coordinates"]] + return { + "type": "Feature", + "geometry": result["geojson"], + "properties": {k: result.get(k) for k in properties or []}, + } + + +def flatten_nested_params(observation: Dict[str, Any]) -> Dict[str, Any]: + """ Extract some nested observation properties to include at the top level; + this makes it easier to specify these as properties for + :py:func:`.as_as_geojson_feature_collection`. + + Args: + observation: A single observation result + """ + taxon = observation.get("taxon", {}) + photos = observation.get("photos", [{}]) + observation["taxon_id"] = taxon.get("id") + observation["taxon_name"] = taxon.get("name") + observation["taxon_rank"] = taxon.get("rank") + observation["preferred_common_name"] = taxon.get("preferred_common_name") + observation["photo_url"] = photos[0].get("url") + return observation + + +def format_taxon(taxon: Dict) -> str: + """Format a taxon result into a single string containing taxon ID, rank, and name + (including common name, if available). + """ + # Visually align taxon IDs (< 7 chars) and ranks (< 11 chars) + common = taxon.get("preferred_common_name") + return "{:>8}: {:>12} {}{}".format( + taxon["id"], taxon["rank"].title(), taxon["name"], " ({})".format(common) if common else "", + ) + + +def _get_rank_range(min_rank: str = None, max_rank: str = None) -> List[str]: + """ Translate min and/or max rank into a list of ranks """ + min_rank_index = _get_rank_index(min_rank) if min_rank else 0 + max_rank_index = _get_rank_index(max_rank) + 1 if max_rank else len(RANKS) + return RANKS[min_rank_index:max_rank_index] + + +def _get_rank_index(rank: str) -> int: + if rank not in RANKS: + raise ValueError("Invalid rank") + return RANKS.index(rank) diff --git a/pyinaturalist/rest_api.py b/pyinaturalist/rest_api.py index 8ed986d8..6bedb44a 100644 --- a/pyinaturalist/rest_api.py +++ b/pyinaturalist/rest_api.py @@ -12,7 +12,6 @@ from pyinaturalist.api_requests import delete, get, post, put -# TODO: Docs, tests def get_observations(response_format="json", user_agent: str = None, **params) -> Union[Dict, str]: """Get observation data, optionally in an alternative format. Return type will be ``dict`` for the ``json`` response format, and ``str`` for all others. @@ -23,6 +22,8 @@ def get_observations(response_format="json", user_agent: str = None, **params) - get_observations(id=45414404, format="dwc") """ + if response_format == "geojson": + raise ValueError("For geojson format, use pyinaturalist.node_api.get_geojson_observations") if response_format not in OBSERVATION_FORMATS: raise ValueError("Invalid response format") diff --git a/test/sample_data/get_observations.geojson b/test/sample_data/get_observations.geojson new file mode 100644 index 00000000..d9123c1f --- /dev/null +++ b/test/sample_data/get_observations.geojson @@ -0,0 +1,23 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [4.360086, 50.646894] + }, + "properties": { + "id": 16227955, + "photo_url": "https://static.inaturalist.org/photos/24355315/square.jpeg?1536150659", + "positional_accuracy": 23, + "preferred_common_name": null, + "quality_grade": "research", + "taxon_id": 493595, + "taxon_name": "Lixus bardanae", + "time_observed_at": "2018-09-05T14:06:00+02:00", + "uri": "https://www.inaturalist.org/observations/16227955" + } + } + ] +} diff --git a/test/test_pyinaturalist.py b/test/test_pyinaturalist.py index b1651491..b64eb7ac 100755 --- a/test/test_pyinaturalist.py +++ b/test/test_pyinaturalist.py @@ -2,6 +2,7 @@ Tests for `pyinaturalist` module. """ from datetime import datetime, timedelta +from sys import version_info from unittest.mock import patch import pytest @@ -9,16 +10,19 @@ from urllib.parse import urlencode, urljoin import pyinaturalist -from pyinaturalist.constants import INAT_NODE_API_BASE_URL +from pyinaturalist.constants import INAT_NODE_API_BASE_URL, INAT_BASE_URL from pyinaturalist.node_api import ( get_observation, + get_geojson_observations, get_taxa, get_taxa_by_id, get_taxa_autocomplete, ) from pyinaturalist.rest_api import ( + OBSERVATION_FORMATS, get_access_token, get_all_observation_fields, + get_observations, get_observation_fields, update_observation, create_observations, @@ -26,17 +30,22 @@ delete_observation, ) from pyinaturalist.exceptions import AuthenticationError, ObservationNotFound -from test.conftest import load_sample_json +from test.conftest import load_sample_data -PAGE_1_JSON_RESPONSE = load_sample_json("get_observation_fields_page1.json") -PAGE_2_JSON_RESPONSE = load_sample_json("get_observation_fields_page2.json") +PAGE_1_JSON_RESPONSE = load_sample_data("get_observation_fields_page1.json") +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 str(load_sample_data("get_observations.{}".format(response_format))) class TestNodeApi(object): def test_get_observation(self, requests_mock): requests_mock.get( urljoin(INAT_NODE_API_BASE_URL, "observations?id=16227955"), - json=load_sample_json("get_observation.json"), + json=load_sample_data("get_observation.json"), status_code=200, ) @@ -46,10 +55,26 @@ def test_get_observation(self, requests_mock): assert obs_data["user"]["login"] == "niconoe" assert len(obs_data["photos"]) == 2 + def test_get_geojson_observations(self, requests_mock): + requests_mock.get( + urljoin( + INAT_NODE_API_BASE_URL, + "observations?observation_id=16227955&order_by=id&order=asc&per_page=30", + ), + json=load_sample_data("get_observation.json"), + status_code=200, + ) + + geojson = get_geojson_observations(observation_id=16227955) + feature = geojson["features"][0] + assert feature["geometry"]["coordinates"] == [4.360086, 50.646894] + assert feature["properties"]["id"] == 16227955 + assert feature["properties"]["taxon_id"] == 493595 + def test_get_non_existent_observation(self, requests_mock): requests_mock.get( urljoin(INAT_NODE_API_BASE_URL, "observations?id=99999999"), - json=load_sample_json("get_nonexistent_observation.json"), + json=load_sample_data("get_nonexistent_observation.json"), status_code=200, ) with pytest.raises(ObservationNotFound): @@ -59,7 +84,7 @@ def test_get_taxa(self, requests_mock): params = urlencode({"q": "vespi", "rank": "genus,subgenus,species"}) requests_mock.get( urljoin(INAT_NODE_API_BASE_URL, "taxa?" + params), - json=load_sample_json("get_taxa.json"), + json=load_sample_data("get_taxa.json"), status_code=200, ) @@ -109,7 +134,7 @@ def test_get_taxa_by_id(self, requests_mock): taxon_id = 70118 requests_mock.get( urljoin(INAT_NODE_API_BASE_URL, "taxa/" + str(taxon_id)), - json=load_sample_json("get_taxa_by_id.json"), + json=load_sample_data("get_taxa_by_id.json"), status_code=200, ) @@ -128,7 +153,7 @@ def test_get_taxa_by_id(self, requests_mock): def test_get_taxa_autocomplete(self, requests_mock): requests_mock.get( urljoin(INAT_NODE_API_BASE_URL, "taxa/autocomplete?q=vespi"), - json=load_sample_json("get_taxa_autocomplete.json"), + json=load_sample_data("get_taxa_autocomplete.json"), status_code=200, ) @@ -148,7 +173,7 @@ def test_get_taxa_autocomplete(self, requests_mock): def test_get_taxa_autocomplete_minified(self, requests_mock): requests_mock.get( urljoin(INAT_NODE_API_BASE_URL, "taxa/autocomplete?q=vespi"), - json=load_sample_json("get_taxa_autocomplete.json"), + json=load_sample_data("get_taxa_autocomplete.json"), status_code=200, ) @@ -170,6 +195,25 @@ def test_get_taxa_autocomplete_minified(self, requests_mock): class TestRestApi(object): + @pytest.mark.skipif( + version_info < (3, 5), reason="Python 3.4 doesn't allow dict expansion in function calls" + ) + @pytest.mark.parametrize("response_format", OBSERVATION_FORMATS) + def test_get_observations(self, response_format, requests_mock): + """ Test all supported observation data formats """ + # A minor workaround to avoid pytest blowing up on 3.4 even when skipped + response = get_observations_response(response_format) + key = "json" if response_format == "json" else "text" + response_kwargs = {key: str(response), "status_code": 200} + + requests_mock.get( + urljoin(INAT_BASE_URL, "observations.{}?id=16227955".format(response_format)), + **response_kwargs, + ) + + observations = get_observations(id=16227955, response_format=response_format) + assert observations == response + def test_get_observation_fields(self, requests_mock): """ get_observation_fields() work as expected (basic use)""" @@ -254,7 +298,7 @@ def test_get_access_token(self, requests_mock): def test_update_observation(self, requests_mock): requests_mock.put( "https://www.inaturalist.org/observations/17932425.json", - json=load_sample_json("update_observation_result.json"), + json=load_sample_data("update_observation_result.json"), status_code=200, ) @@ -310,7 +354,7 @@ def test_update_observation_not_mine(self, requests_mock): def test_create_observation(self, requests_mock): requests_mock.post( "https://www.inaturalist.org/observations.json", - json=load_sample_json("create_observation_result.json"), + json=load_sample_data("create_observation_result.json"), status_code=200, ) @@ -337,7 +381,7 @@ def test_create_observation_fail(self, requests_mock): requests_mock.post( "https://www.inaturalist.org/observations.json", - json=load_sample_json("create_observation_fail.json"), + json=load_sample_data("create_observation_fail.json"), status_code=422, ) @@ -349,7 +393,7 @@ def test_create_observation_fail(self, requests_mock): def test_put_observation_field_values(self, requests_mock): requests_mock.put( "https://www.inaturalist.org/observation_field_values/31", - json=load_sample_json("put_observation_field_value_result.json"), + json=load_sample_data("put_observation_field_value_result.json"), status_code=200, ) @@ -383,7 +427,7 @@ def test_user_agent(self, requests_mock): # TODO: test for all functions that access the inaturalist API? requests_mock.get( urljoin(INAT_NODE_API_BASE_URL, "observations?id=16227955"), - json=load_sample_json("get_observation.json"), + json=load_sample_data("get_observation.json"), status_code=200, ) accepted_json = { diff --git a/test/test_helpers.py b/test/test_request_params.py similarity index 96% rename from test/test_helpers.py rename to test/test_request_params.py index 6a785034..36097688 100644 --- a/test/test_helpers.py +++ b/test/test_request_params.py @@ -82,12 +82,13 @@ def test_preprocess_request_params(mock_bool, mock_datetime, mock_list, mock_str @pytest.mark.parametrize( "http_function", get_module_http_functions(pyinaturalist.node_api).values() ) -@patch("pyinaturalist.node_api._get_rank_range") +@patch("pyinaturalist.response_format.as_geojson_feature") +@patch("pyinaturalist.response_format._get_rank_range") @patch("pyinaturalist.node_api.merge_two_dicts") @patch("pyinaturalist.api_requests.preprocess_request_params") @patch("pyinaturalist.api_requests.requests.request") def test_all_node_requests_use_param_conversion( - request, preprocess_request_params, merge_two_dicts, get_rank_range, http_function, + request, preprocess_request_params, merge_two_dicts, get_rank_range, as_geojson, http_function, ): request().json.return_value = {"total_results": 1, "results": [{}]} mock_args = get_mock_args_for_signature(http_function) From c44059f358634c6c79170e001718bf0e53f019be Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Wed, 10 Jun 2020 21:24:53 -0500 Subject: [PATCH 17/25] Split test_pyinaturalist into one test module per source module --- test/test_node_api.py | 255 +++++++++++++++++++ test/test_pyinaturalist.py | 493 ------------------------------------- test/test_rest_api.py | 271 ++++++++++++++++++++ 3 files changed, 526 insertions(+), 493 deletions(-) create mode 100644 test/test_node_api.py delete mode 100755 test/test_pyinaturalist.py create mode 100644 test/test_rest_api.py diff --git a/test/test_node_api.py b/test/test_node_api.py new file mode 100644 index 00000000..eec45e75 --- /dev/null +++ b/test/test_node_api.py @@ -0,0 +1,255 @@ +import pytest +from urllib.parse import urlencode, urljoin +from unittest.mock import patch + +import pyinaturalist +from pyinaturalist.constants import INAT_NODE_API_BASE_URL +from pyinaturalist.node_api import ( + get_observation, + get_geojson_observations, + get_taxa, + get_taxa_by_id, + get_taxa_autocomplete, +) +from pyinaturalist.exceptions import ObservationNotFound +from pyinaturalist.rest_api import get_access_token +from test.conftest import load_sample_data + +PAGE_1_JSON_RESPONSE = load_sample_data("get_observation_fields_page1.json") +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 str(load_sample_data("get_observations.{}".format(response_format))) + + +def test_get_observation(requests_mock): + requests_mock.get( + urljoin(INAT_NODE_API_BASE_URL, "observations?id=16227955"), + json=load_sample_data("get_observation.json"), + status_code=200, + ) + + obs_data = get_observation(observation_id=16227955) + assert obs_data["quality_grade"] == "research" + assert obs_data["id"] == 16227955 + assert obs_data["user"]["login"] == "niconoe" + assert len(obs_data["photos"]) == 2 + + +def test_get_geojson_observations(requests_mock): + requests_mock.get( + urljoin( + INAT_NODE_API_BASE_URL, + "observations?observation_id=16227955&order_by=id&order=asc&per_page=30", + ), + json=load_sample_data("get_observation.json"), + status_code=200, + ) + + geojson = get_geojson_observations(observation_id=16227955) + feature = geojson["features"][0] + assert feature["geometry"]["coordinates"] == [4.360086, 50.646894] + assert feature["properties"]["id"] == 16227955 + assert feature["properties"]["taxon_id"] == 493595 + + +def test_get_non_existent_observation(requests_mock): + requests_mock.get( + urljoin(INAT_NODE_API_BASE_URL, "observations?id=99999999"), + json=load_sample_data("get_nonexistent_observation.json"), + status_code=200, + ) + with pytest.raises(ObservationNotFound): + get_observation(observation_id=99999999) + + +def test_get_taxa(requests_mock): + params = urlencode({"q": "vespi", "rank": "genus,subgenus,species"}) + requests_mock.get( + urljoin(INAT_NODE_API_BASE_URL, "taxa?" + params), + json=load_sample_data("get_taxa.json"), + status_code=200, + ) + + response = get_taxa(q="vespi", rank=["genus", "subgenus", "species"]) + first_result = response["results"][0] + + assert len(response["results"]) == 30 + assert response["total_results"] == 35 + assert first_result["id"] == 70118 + assert first_result["name"] == "Nicrophorus vespilloides" + assert first_result["rank"] == "species" + assert first_result["is_active"] is True + assert len(first_result["ancestor_ids"]) == 14 + + +CLASS_AND_HIGHER = ["class", "superclass", "subphylum", "phylum", "kingdom"] +SPECIES_AND_LOWER = ["form", "variety", "subspecies", "hybrid", "species"] +CLASS_THOUGH_PHYLUM = ["class", "superclass", "subphylum", "phylum"] + + +@pytest.mark.parametrize( + "params, expected_ranks", + [ + ({"rank": "genus"}, "genus"), + ({"min_rank": "class"}, CLASS_AND_HIGHER), + ({"max_rank": "species"}, SPECIES_AND_LOWER), + ({"min_rank": "class", "max_rank": "phylum"}, CLASS_THOUGH_PHYLUM), + ({"max_rank": "species", "rank": "override_me"}, SPECIES_AND_LOWER), + ], +) +@patch("pyinaturalist.node_api.make_inaturalist_api_get_call") +def test_get_taxa_by_rank_range( + mock_inaturalist_api_get_call, params, expected_ranks, +): + # Make sure custom rank params result in the correct 'rank' param value + get_taxa(**params) + mock_inaturalist_api_get_call.assert_called_with( + "taxa", {"rank": expected_ranks}, user_agent=None + ) + + +# This is just a spot test of a case in which boolean params should be converted +@patch("pyinaturalist.api_requests.requests.request") +def test_get_taxa_by_name_and_is_active(request): + get_taxa(q="Lixus bardanae", is_active=False) + request_kwargs = request.call_args[1] + assert request_kwargs["params"] == {"q": "Lixus bardanae", "is_active": "false"} + + +def test_get_taxa_by_id(requests_mock): + taxon_id = 70118 + requests_mock.get( + urljoin(INAT_NODE_API_BASE_URL, "taxa/" + str(taxon_id)), + json=load_sample_data("get_taxa_by_id.json"), + status_code=200, + ) + + response = get_taxa_by_id(taxon_id) + result = response["results"][0] + assert len(response["results"]) == 1 + assert result["id"] == taxon_id + assert result["name"] == "Nicrophorus vespilloides" + assert result["rank"] == "species" + assert result["is_active"] is True + assert len(result["ancestors"]) == 12 + + with pytest.raises(ValueError): + get_taxa_by_id([1, 2]) + + +def test_get_taxa_autocomplete(requests_mock): + requests_mock.get( + urljoin(INAT_NODE_API_BASE_URL, "taxa/autocomplete?q=vespi"), + json=load_sample_data("get_taxa_autocomplete.json"), + status_code=200, + ) + + response = get_taxa_autocomplete(q="vespi") + first_result = response["results"][0] + + assert len(response["results"]) == 10 + assert response["total_results"] == 44 + assert first_result["matched_term"] == "Vespidae" + assert first_result["id"] == 52747 + assert first_result["name"] == "Vespidae" + assert first_result["rank"] == "family" + assert first_result["is_active"] is True + assert len(first_result["ancestor_ids"]) == 11 + + +# Test usage of format_taxon() with get_taxa_autocomplete() +def test_get_taxa_autocomplete_minified(requests_mock): + requests_mock.get( + urljoin(INAT_NODE_API_BASE_URL, "taxa/autocomplete?q=vespi"), + json=load_sample_data("get_taxa_autocomplete.json"), + status_code=200, + ) + + expected_results = [ + " 52747: Family Vespidae (Hornets, Paper Wasps, Potter Wasps, and Allies)", + " 84738: Subfamily Vespinae (Hornets and Yellowjackets)", + " 131878: Species Nicrophorus vespillo (Vespillo Burying Beetle)", + " 495392: Species Vespidae st1", + " 70118: Species Nicrophorus vespilloides (Lesser Vespillo Burying Beetle)", + " 84737: Genus Vespina", + " 621584: Species Vespicula cypho", + " 621585: Species Vespicula trachinoides", + " 621586: Species Vespicula zollingeri", + " 299443: Species Vespita woolleyi", + ] + + response = get_taxa_autocomplete(q="vespi", minify=True) + assert response["results"] == expected_results + + +def test_user_agent(requests_mock): + # TODO: test for all functions that access the inaturalist API? + requests_mock.get( + urljoin(INAT_NODE_API_BASE_URL, "observations?id=16227955"), + json=load_sample_data("get_observation.json"), + status_code=200, + ) + accepted_json = { + "access_token": "604e5df329b98eecd22bb0a84f88b68a075a023ac437f2317b02f3a9ba414a08", + "token_type": "Bearer", + "scope": "write", + "created_at": 1539352135, + } + requests_mock.post( + "https://www.inaturalist.org/oauth/token", json=accepted_json, status_code=200, + ) + + default_ua = "Pyinaturalist/{v}".format(v=pyinaturalist.__version__) + + # By default, we have a 'Pyinaturalist' user agent: + get_observation(observation_id=16227955) + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == default_ua + get_access_token("valid_username", "valid_password", "valid_app_id", "valid_app_secret") + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == default_ua + + # But if the user sets a custom one, it is indeed used: + get_observation(observation_id=16227955, user_agent="CustomUA") + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "CustomUA" + get_access_token( + "valid_username", + "valid_password", + "valid_app_id", + "valid_app_secret", + user_agent="CustomUA", + ) + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "CustomUA" + + # We can also set it globally: + pyinaturalist.user_agent = "GlobalUA" + get_observation(observation_id=16227955) + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" + get_access_token("valid_username", "valid_password", "valid_app_id", "valid_app_secret") + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" + + # And it persists across requests: + get_observation(observation_id=16227955) + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" + get_access_token("valid_username", "valid_password", "valid_app_id", "valid_app_secret") + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" + + # But if we have a global and local one, the local has priority + get_observation(observation_id=16227955, user_agent="CustomUA 2") + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "CustomUA 2" + get_access_token( + "valid_username", + "valid_password", + "valid_app_id", + "valid_app_secret", + user_agent="CustomUA 2", + ) + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "CustomUA 2" + + # We can reset the global settings to the default: + pyinaturalist.user_agent = pyinaturalist.DEFAULT_USER_AGENT + get_observation(observation_id=16227955) + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == default_ua + get_access_token("valid_username", "valid_password", "valid_app_id", "valid_app_secret") + assert requests_mock._adapter.last_request._request.headers["User-Agent"] == default_ua diff --git a/test/test_pyinaturalist.py b/test/test_pyinaturalist.py deleted file mode 100755 index b64eb7ac..00000000 --- a/test/test_pyinaturalist.py +++ /dev/null @@ -1,493 +0,0 @@ -""" -Tests for `pyinaturalist` module. -""" -from datetime import datetime, timedelta -from sys import version_info -from unittest.mock import patch - -import pytest -from requests import HTTPError -from urllib.parse import urlencode, urljoin - -import pyinaturalist -from pyinaturalist.constants import INAT_NODE_API_BASE_URL, INAT_BASE_URL -from pyinaturalist.node_api import ( - get_observation, - get_geojson_observations, - get_taxa, - get_taxa_by_id, - get_taxa_autocomplete, -) -from pyinaturalist.rest_api import ( - OBSERVATION_FORMATS, - get_access_token, - get_all_observation_fields, - get_observations, - get_observation_fields, - update_observation, - create_observations, - put_observation_field_values, - delete_observation, -) -from pyinaturalist.exceptions import AuthenticationError, ObservationNotFound -from test.conftest import load_sample_data - -PAGE_1_JSON_RESPONSE = load_sample_data("get_observation_fields_page1.json") -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 str(load_sample_data("get_observations.{}".format(response_format))) - - -class TestNodeApi(object): - def test_get_observation(self, requests_mock): - requests_mock.get( - urljoin(INAT_NODE_API_BASE_URL, "observations?id=16227955"), - json=load_sample_data("get_observation.json"), - status_code=200, - ) - - obs_data = get_observation(observation_id=16227955) - assert obs_data["quality_grade"] == "research" - assert obs_data["id"] == 16227955 - assert obs_data["user"]["login"] == "niconoe" - assert len(obs_data["photos"]) == 2 - - def test_get_geojson_observations(self, requests_mock): - requests_mock.get( - urljoin( - INAT_NODE_API_BASE_URL, - "observations?observation_id=16227955&order_by=id&order=asc&per_page=30", - ), - json=load_sample_data("get_observation.json"), - status_code=200, - ) - - geojson = get_geojson_observations(observation_id=16227955) - feature = geojson["features"][0] - assert feature["geometry"]["coordinates"] == [4.360086, 50.646894] - assert feature["properties"]["id"] == 16227955 - assert feature["properties"]["taxon_id"] == 493595 - - def test_get_non_existent_observation(self, requests_mock): - requests_mock.get( - urljoin(INAT_NODE_API_BASE_URL, "observations?id=99999999"), - json=load_sample_data("get_nonexistent_observation.json"), - status_code=200, - ) - with pytest.raises(ObservationNotFound): - get_observation(observation_id=99999999) - - def test_get_taxa(self, requests_mock): - params = urlencode({"q": "vespi", "rank": "genus,subgenus,species"}) - requests_mock.get( - urljoin(INAT_NODE_API_BASE_URL, "taxa?" + params), - json=load_sample_data("get_taxa.json"), - status_code=200, - ) - - response = get_taxa(q="vespi", rank=["genus", "subgenus", "species"]) - first_result = response["results"][0] - - assert len(response["results"]) == 30 - assert response["total_results"] == 35 - assert first_result["id"] == 70118 - assert first_result["name"] == "Nicrophorus vespilloides" - assert first_result["rank"] == "species" - assert first_result["is_active"] is True - assert len(first_result["ancestor_ids"]) == 14 - - CLASS_AND_HIGHER = ["class", "superclass", "subphylum", "phylum", "kingdom"] - SPECIES_AND_LOWER = ["form", "variety", "subspecies", "hybrid", "species"] - CLASS_THOUGH_PHYLUM = ["class", "superclass", "subphylum", "phylum"] - - @pytest.mark.parametrize( - "params, expected_ranks", - [ - ({"rank": "genus"}, "genus"), - ({"min_rank": "class"}, CLASS_AND_HIGHER), - ({"max_rank": "species"}, SPECIES_AND_LOWER), - ({"min_rank": "class", "max_rank": "phylum"}, CLASS_THOUGH_PHYLUM), - ({"max_rank": "species", "rank": "override_me"}, SPECIES_AND_LOWER), - ], - ) - @patch("pyinaturalist.node_api.make_inaturalist_api_get_call") - def test_get_taxa_by_rank_range( - self, mock_inaturalist_api_get_call, params, expected_ranks, - ): - # Make sure custom rank params result in the correct 'rank' param value - get_taxa(**params) - mock_inaturalist_api_get_call.assert_called_with( - "taxa", {"rank": expected_ranks}, user_agent=None - ) - - # This is just a spot test of a case in which boolean params should be converted - @patch("pyinaturalist.api_requests.requests.request") - def test_get_taxa_by_name_and_is_active(self, request): - get_taxa(q="Lixus bardanae", is_active=False) - request_kwargs = request.call_args[1] - assert request_kwargs["params"] == {"q": "Lixus bardanae", "is_active": "false"} - - def test_get_taxa_by_id(self, requests_mock): - taxon_id = 70118 - requests_mock.get( - urljoin(INAT_NODE_API_BASE_URL, "taxa/" + str(taxon_id)), - json=load_sample_data("get_taxa_by_id.json"), - status_code=200, - ) - - response = get_taxa_by_id(taxon_id) - result = response["results"][0] - assert len(response["results"]) == 1 - assert result["id"] == taxon_id - assert result["name"] == "Nicrophorus vespilloides" - assert result["rank"] == "species" - assert result["is_active"] is True - assert len(result["ancestors"]) == 12 - - with pytest.raises(ValueError): - get_taxa_by_id([1, 2]) - - def test_get_taxa_autocomplete(self, requests_mock): - requests_mock.get( - urljoin(INAT_NODE_API_BASE_URL, "taxa/autocomplete?q=vespi"), - json=load_sample_data("get_taxa_autocomplete.json"), - status_code=200, - ) - - response = get_taxa_autocomplete(q="vespi") - first_result = response["results"][0] - - assert len(response["results"]) == 10 - assert response["total_results"] == 44 - assert first_result["matched_term"] == "Vespidae" - assert first_result["id"] == 52747 - assert first_result["name"] == "Vespidae" - assert first_result["rank"] == "family" - assert first_result["is_active"] is True - assert len(first_result["ancestor_ids"]) == 11 - - # Test usage of format_taxon() with get_taxa_autocomplete() - def test_get_taxa_autocomplete_minified(self, requests_mock): - requests_mock.get( - urljoin(INAT_NODE_API_BASE_URL, "taxa/autocomplete?q=vespi"), - json=load_sample_data("get_taxa_autocomplete.json"), - status_code=200, - ) - - expected_results = [ - " 52747: Family Vespidae (Hornets, Paper Wasps, Potter Wasps, and Allies)", - " 84738: Subfamily Vespinae (Hornets and Yellowjackets)", - " 131878: Species Nicrophorus vespillo (Vespillo Burying Beetle)", - " 495392: Species Vespidae st1", - " 70118: Species Nicrophorus vespilloides (Lesser Vespillo Burying Beetle)", - " 84737: Genus Vespina", - " 621584: Species Vespicula cypho", - " 621585: Species Vespicula trachinoides", - " 621586: Species Vespicula zollingeri", - " 299443: Species Vespita woolleyi", - ] - - response = get_taxa_autocomplete(q="vespi", minify=True) - assert response["results"] == expected_results - - -class TestRestApi(object): - @pytest.mark.skipif( - version_info < (3, 5), reason="Python 3.4 doesn't allow dict expansion in function calls" - ) - @pytest.mark.parametrize("response_format", OBSERVATION_FORMATS) - def test_get_observations(self, response_format, requests_mock): - """ Test all supported observation data formats """ - # A minor workaround to avoid pytest blowing up on 3.4 even when skipped - response = get_observations_response(response_format) - key = "json" if response_format == "json" else "text" - response_kwargs = {key: str(response), "status_code": 200} - - requests_mock.get( - urljoin(INAT_BASE_URL, "observations.{}?id=16227955".format(response_format)), - **response_kwargs, - ) - - observations = get_observations(id=16227955, response_format=response_format) - assert observations == response - - def test_get_observation_fields(self, requests_mock): - """ get_observation_fields() work as expected (basic use)""" - - requests_mock.get( - "https://www.inaturalist.org/observation_fields.json?q=sex&page=2", - json=PAGE_2_JSON_RESPONSE, - status_code=200, - ) - - obs_fields = get_observation_fields(search_query="sex", page=2) - assert obs_fields == PAGE_2_JSON_RESPONSE - - def test_get_all_observation_fields(self, requests_mock): - """get_all_observation_fields() is able to paginate, accepts a search query and return correct results""" - - requests_mock.get( - "https://www.inaturalist.org/observation_fields.json?q=sex&page=1", - json=PAGE_1_JSON_RESPONSE, - status_code=200, - ) - - requests_mock.get( - "https://www.inaturalist.org/observation_fields.json?q=sex&page=2", - json=PAGE_2_JSON_RESPONSE, - status_code=200, - ) - - page_3_json_response = [] - requests_mock.get( - "https://www.inaturalist.org/observation_fields.json?q=sex&page=3", - json=page_3_json_response, - status_code=200, - ) - - all_fields = get_all_observation_fields(search_query="sex") - assert all_fields == PAGE_1_JSON_RESPONSE + PAGE_2_JSON_RESPONSE - - def test_get_all_observation_fields_noparam(self, requests_mock): - """get_all_observation_fields() can also be called without a search query without errors""" - requests_mock.get( - "https://www.inaturalist.org/observation_fields.json?page=1", json=[], status_code=200, - ) - - get_all_observation_fields() - - def test_get_access_token_fail(self, requests_mock): - """ If we provide incorrect credentials to get_access_token(), an AuthenticationError is raised""" - - rejection_json = { - "error": "invalid_client", - "error_description": "Client authentication failed due to " - "unknown client, no client authentication " - "included, or unsupported authentication " - "method.", - } - requests_mock.post( - "https://www.inaturalist.org/oauth/token", json=rejection_json, status_code=401, - ) - - with pytest.raises(AuthenticationError): - get_access_token("username", "password", "app_id", "app_secret") - - def test_get_access_token(self, requests_mock): - """ Test a successful call to get_access_token() """ - - accepted_json = { - "access_token": "604e5df329b98eecd22bb0a84f88b68a075a023ac437f2317b02f3a9ba414a08", - "token_type": "Bearer", - "scope": "write", - "created_at": 1539352135, - } - requests_mock.post( - "https://www.inaturalist.org/oauth/token", json=accepted_json, status_code=200, - ) - - token = get_access_token( - "valid_username", "valid_password", "valid_app_id", "valid_app_secret" - ) - - assert token == "604e5df329b98eecd22bb0a84f88b68a075a023ac437f2317b02f3a9ba414a08" - - def test_update_observation(self, requests_mock): - requests_mock.put( - "https://www.inaturalist.org/observations/17932425.json", - json=load_sample_data("update_observation_result.json"), - status_code=200, - ) - - p = { - "ignore_photos": 1, - "observation": {"description": "updated description v2 !"}, - } - r = update_observation(observation_id=17932425, params=p, access_token="valid token") - - # If all goes well we got a single element representing the updated observation, enclosed in a list. - assert len(r) == 1 - assert r[0]["id"] == 17932425 - assert r[0]["description"] == "updated description v2 !" - - def test_update_nonexistent_observation(self, requests_mock): - """When we try to update a non-existent observation, iNat returns an error 410 with "obs does not longer exists". """ - requests_mock.put( - "https://www.inaturalist.org/observations/999999999.json", - json={"error": "Cette observation n’existe plus."}, - status_code=410, - ) - - p = { - "ignore_photos": 1, - "observation": {"description": "updated description v2 !"}, - } - - with pytest.raises(HTTPError) as excinfo: - update_observation(observation_id=999999999, params=p, access_token="valid token") - assert excinfo.value.response.status_code == 410 - assert excinfo.value.response.json() == {"error": "Cette observation n’existe plus."} - - def test_update_observation_not_mine(self, 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( - "https://www.inaturalist.org/observations/16227955.json", - json={"error": "Cette observation n’existe plus."}, - status_code=410, - ) - - p = { - "ignore_photos": 1, - "observation": {"description": "updated description v2 !"}, - } - - with pytest.raises(HTTPError) as excinfo: - update_observation( - observation_id=16227955, params=p, access_token="valid token for another user", - ) - assert excinfo.value.response.status_code == 410 - assert excinfo.value.response.json() == {"error": "Cette observation n’existe plus."} - - def test_create_observation(self, requests_mock): - requests_mock.post( - "https://www.inaturalist.org/observations.json", - json=load_sample_data("create_observation_result.json"), - status_code=200, - ) - - params = { - "observation": {"species_guess": "Pieris rapae"}, - } - - r = create_observations(params=params, access_token="valid token") - assert len(r) == 1 # We added a single one - assert ( - r[0]["latitude"] is None - ) # We have the field, but it's none since we didn't submitted anything - assert r[0]["taxon_id"] == 55626 # Pieris Rapae @ iNaturalist - - def test_create_observation_fail(self, requests_mock): - params = { - "observation": { - "species_guess": "Pieris rapae", - # Some invalid data so the observation is rejected... - "observed_on_string": (datetime.now() + timedelta(days=1)).isoformat(), - "latitude": 200, - } - } - - requests_mock.post( - "https://www.inaturalist.org/observations.json", - json=load_sample_data("create_observation_fail.json"), - status_code=422, - ) - - with pytest.raises(HTTPError) as excinfo: - create_observations(params=params, access_token="valid token") - assert excinfo.value.response.status_code == 422 - assert "errors" in excinfo.value.response.json() # iNat also give details about the errors - - def test_put_observation_field_values(self, requests_mock): - requests_mock.put( - "https://www.inaturalist.org/observation_field_values/31", - json=load_sample_data("put_observation_field_value_result.json"), - status_code=200, - ) - - r = put_observation_field_values( - observation_id=18166477, - observation_field_id=31, # Animal behavior - value="fouraging", - access_token="valid token", - ) - - assert r["id"] == 31 - assert r["observation_field_id"] == 31 - assert r["observation_id"] == 18166477 - assert r["value"] == "fouraging" - - def test_delete_observation(self): - # Blocked because the expected behaviour is still unclear because of - # https://github.com/inaturalist/inaturalist/issues/2252 - pass - - def test_delete_unexisting_observation(self, requests_mock): - """ObservationNotFound is raised if the observation doesn't exists""" - requests_mock.delete( - "https://www.inaturalist.org/observations/24774619.json", status_code=404 - ) - - with pytest.raises(ObservationNotFound): - delete_observation(observation_id=24774619, access_token="valid token") - - def test_user_agent(self, requests_mock): - # TODO: test for all functions that access the inaturalist API? - requests_mock.get( - urljoin(INAT_NODE_API_BASE_URL, "observations?id=16227955"), - json=load_sample_data("get_observation.json"), - status_code=200, - ) - accepted_json = { - "access_token": "604e5df329b98eecd22bb0a84f88b68a075a023ac437f2317b02f3a9ba414a08", - "token_type": "Bearer", - "scope": "write", - "created_at": 1539352135, - } - requests_mock.post( - "https://www.inaturalist.org/oauth/token", json=accepted_json, status_code=200, - ) - - default_ua = "Pyinaturalist/{v}".format(v=pyinaturalist.__version__) - - # By default, we have a 'Pyinaturalist' user agent: - get_observation(observation_id=16227955) - assert requests_mock._adapter.last_request._request.headers["User-Agent"] == default_ua - get_access_token("valid_username", "valid_password", "valid_app_id", "valid_app_secret") - assert requests_mock._adapter.last_request._request.headers["User-Agent"] == default_ua - - # But if the user sets a custom one, it is indeed used: - get_observation(observation_id=16227955, user_agent="CustomUA") - assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "CustomUA" - get_access_token( - "valid_username", - "valid_password", - "valid_app_id", - "valid_app_secret", - user_agent="CustomUA", - ) - assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "CustomUA" - - # We can also set it globally: - pyinaturalist.user_agent = "GlobalUA" - get_observation(observation_id=16227955) - assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" - get_access_token("valid_username", "valid_password", "valid_app_id", "valid_app_secret") - assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" - - # And it persists across requests: - get_observation(observation_id=16227955) - assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" - get_access_token("valid_username", "valid_password", "valid_app_id", "valid_app_secret") - assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "GlobalUA" - - # But if we have a global and local one, the local has priority - get_observation(observation_id=16227955, user_agent="CustomUA 2") - assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "CustomUA 2" - get_access_token( - "valid_username", - "valid_password", - "valid_app_id", - "valid_app_secret", - user_agent="CustomUA 2", - ) - assert requests_mock._adapter.last_request._request.headers["User-Agent"] == "CustomUA 2" - - # We can reset the global settings to the default: - pyinaturalist.user_agent = pyinaturalist.DEFAULT_USER_AGENT - get_observation(observation_id=16227955) - assert requests_mock._adapter.last_request._request.headers["User-Agent"] == default_ua - get_access_token("valid_username", "valid_password", "valid_app_id", "valid_app_secret") - assert requests_mock._adapter.last_request._request.headers["User-Agent"] == default_ua diff --git a/test/test_rest_api.py b/test/test_rest_api.py new file mode 100644 index 00000000..3fd760e8 --- /dev/null +++ b/test/test_rest_api.py @@ -0,0 +1,271 @@ +from datetime import datetime, timedelta + +import pytest +from requests import HTTPError +from urllib.parse import urljoin + +from pyinaturalist.constants import INAT_BASE_URL +from pyinaturalist.rest_api import ( + OBSERVATION_FORMATS, + get_access_token, + get_all_observation_fields, + get_observations, + get_observation_fields, + update_observation, + create_observations, + put_observation_field_values, + delete_observation, +) +from pyinaturalist.exceptions import AuthenticationError, ObservationNotFound +from test.conftest import load_sample_data + +PAGE_1_JSON_RESPONSE = load_sample_data("get_observation_fields_page1.json") +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 str(load_sample_data("get_observations.{}".format(response_format))) + + +@pytest.mark.parametrize("response_format", OBSERVATION_FORMATS) +def test_get_observations(response_format, requests_mock): + """ Test all supported observation data formats """ + response = get_observations_response(response_format) + + # Workaround to avoid pytest blowing up on 3.4 even when skipped. Otherwise this could be: + # key = "json" if response_format == "json" else "text" + # requests_mock.get(..., **{key: str(response)}) + if response_format == "json": + requests_mock.get( + urljoin(INAT_BASE_URL, "observations.{}?id=16227955".format(response_format)), + status_code=200, + json=response, + ) + else: + requests_mock.get( + urljoin(INAT_BASE_URL, "observations.{}?id=16227955".format(response_format)), + status_code=200, + text=response, + ) + + observations = get_observations(id=16227955, response_format=response_format) + assert observations == response + + +def test_get_observation_fields(requests_mock): + """ get_observation_fields() work as expected (basic use)""" + + requests_mock.get( + "https://www.inaturalist.org/observation_fields.json?q=sex&page=2", + json=PAGE_2_JSON_RESPONSE, + status_code=200, + ) + + obs_fields = get_observation_fields(search_query="sex", page=2) + assert obs_fields == PAGE_2_JSON_RESPONSE + + +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( + "https://www.inaturalist.org/observation_fields.json?q=sex&page=1", + json=PAGE_1_JSON_RESPONSE, + status_code=200, + ) + + requests_mock.get( + "https://www.inaturalist.org/observation_fields.json?q=sex&page=2", + json=PAGE_2_JSON_RESPONSE, + status_code=200, + ) + + page_3_json_response = [] + requests_mock.get( + "https://www.inaturalist.org/observation_fields.json?q=sex&page=3", + json=page_3_json_response, + status_code=200, + ) + + all_fields = get_all_observation_fields(search_query="sex") + assert all_fields == PAGE_1_JSON_RESPONSE + PAGE_2_JSON_RESPONSE + + +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( + "https://www.inaturalist.org/observation_fields.json?page=1", json=[], status_code=200, + ) + + get_all_observation_fields() + + +def test_get_access_token_fail(requests_mock): + """ If we provide incorrect credentials to get_access_token(), an AuthenticationError is raised""" + + rejection_json = { + "error": "invalid_client", + "error_description": "Client authentication failed due to " + "unknown client, no client authentication " + "included, or unsupported authentication " + "method.", + } + requests_mock.post( + "https://www.inaturalist.org/oauth/token", json=rejection_json, status_code=401, + ) + + with pytest.raises(AuthenticationError): + get_access_token("username", "password", "app_id", "app_secret") + + +def test_get_access_token(requests_mock): + """ Test a successful call to get_access_token() """ + + accepted_json = { + "access_token": "604e5df329b98eecd22bb0a84f88b68a075a023ac437f2317b02f3a9ba414a08", + "token_type": "Bearer", + "scope": "write", + "created_at": 1539352135, + } + requests_mock.post( + "https://www.inaturalist.org/oauth/token", json=accepted_json, status_code=200, + ) + + token = get_access_token("valid_username", "valid_password", "valid_app_id", "valid_app_secret") + + assert token == "604e5df329b98eecd22bb0a84f88b68a075a023ac437f2317b02f3a9ba414a08" + + +def test_update_observation(requests_mock): + requests_mock.put( + "https://www.inaturalist.org/observations/17932425.json", + json=load_sample_data("update_observation_result.json"), + status_code=200, + ) + + p = { + "ignore_photos": 1, + "observation": {"description": "updated description v2 !"}, + } + r = update_observation(observation_id=17932425, params=p, access_token="valid token") + + # If all goes well we got a single element representing the updated observation, enclosed in a list. + assert len(r) == 1 + assert r[0]["id"] == 17932425 + assert r[0]["description"] == "updated description v2 !" + + +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". """ + requests_mock.put( + "https://www.inaturalist.org/observations/999999999.json", + json={"error": "Cette observation n’existe plus."}, + status_code=410, + ) + + p = { + "ignore_photos": 1, + "observation": {"description": "updated description v2 !"}, + } + + with pytest.raises(HTTPError) as excinfo: + update_observation(observation_id=999999999, params=p, access_token="valid token") + assert excinfo.value.response.status_code == 410 + assert excinfo.value.response.json() == {"error": "Cette observation n’existe plus."} + + +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( + "https://www.inaturalist.org/observations/16227955.json", + json={"error": "Cette observation n’existe plus."}, + status_code=410, + ) + + p = { + "ignore_photos": 1, + "observation": {"description": "updated description v2 !"}, + } + + with pytest.raises(HTTPError) as excinfo: + update_observation( + observation_id=16227955, params=p, access_token="valid token for another user", + ) + assert excinfo.value.response.status_code == 410 + assert excinfo.value.response.json() == {"error": "Cette observation n’existe plus."} + + +def test_create_observation(requests_mock): + requests_mock.post( + "https://www.inaturalist.org/observations.json", + json=load_sample_data("create_observation_result.json"), + status_code=200, + ) + + params = { + "observation": {"species_guess": "Pieris rapae"}, + } + + r = create_observations(params=params, access_token="valid token") + assert len(r) == 1 # We added a single one + assert ( + r[0]["latitude"] is None + ) # We have the field, but it's none since we didn't submitted anything + assert r[0]["taxon_id"] == 55626 # Pieris Rapae @ iNaturalist + + +def test_create_observation_fail(requests_mock): + params = { + "observation": { + "species_guess": "Pieris rapae", + # Some invalid data so the observation is rejected... + "observed_on_string": (datetime.now() + timedelta(days=1)).isoformat(), + "latitude": 200, + } + } + + requests_mock.post( + "https://www.inaturalist.org/observations.json", + json=load_sample_data("create_observation_fail.json"), + status_code=422, + ) + + with pytest.raises(HTTPError) as excinfo: + create_observations(params=params, access_token="valid token") + assert excinfo.value.response.status_code == 422 + assert "errors" in excinfo.value.response.json() # iNat also give details about the errors + + +def test_put_observation_field_values(requests_mock): + requests_mock.put( + "https://www.inaturalist.org/observation_field_values/31", + json=load_sample_data("put_observation_field_value_result.json"), + status_code=200, + ) + + r = put_observation_field_values( + observation_id=18166477, + observation_field_id=31, # Animal behavior + value="fouraging", + access_token="valid token", + ) + + assert r["id"] == 31 + assert r["observation_field_id"] == 31 + assert r["observation_id"] == 18166477 + assert r["value"] == "fouraging" + + +def test_delete_observation(): + # Blocked because the expected behaviour is still unclear because of + # https://github.com/inaturalist/inaturalist/issues/2252 + pass + + +def test_delete_unexisting_observation(requests_mock): + """ObservationNotFound is raised if the observation doesn't exists""" + requests_mock.delete("https://www.inaturalist.org/observations/24774619.json", status_code=404) + + with pytest.raises(ObservationNotFound): + delete_observation(observation_id=24774619, access_token="valid token") From b57d4755800e2f4df210e6a80b2bc8fe503bc96a Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Wed, 10 Jun 2020 22:19:56 -0500 Subject: [PATCH 18/25] Add some more docs and tests; remove unused Sphinx formats; use minimal version of Sphinx Makefile --- docs/Makefile | 180 ++----------------------------- docs/conf.py | 216 ++++---------------------------------- docs/make.bat | 189 --------------------------------- pyinaturalist/node_api.py | 122 ++++++++++++--------- test/test_node_api.py | 31 ++++-- test/test_rest_api.py | 10 +- 6 files changed, 137 insertions(+), 611 deletions(-) diff --git a/docs/Makefile b/docs/Makefile index 0e35bee9..5bb0bf9f 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,177 +1,19 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = +# Minimal makefile for Sphinx documentation +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . BUILDDIR = _build -# User-friendly check for sphinx-build -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) -endif - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext +# Put it first so that "make" without argument is like "make help". help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/complexity.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/complexity.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/complexity" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/complexity" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." +.PHONY: help all Makefile -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." +all: clean html -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." +# Catch-all target: route all unknown targets to Sphinx +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py index 2176c556..4f5e8180 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,22 +1,6 @@ -# -*- coding: utf-8 -*- -# -# complexity documentation build configuration file, created by -# sphinx-quickstart on Tue Jul 9 22:26:36 2013. -# -# This file is execfile()d with the current directory set to its containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -import sys, os - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# sys.path.insert(0, os.path.abspath('.')) +# Documentation build configuration file, created by sphinx-quickstart +import os +import sys cwd = os.getcwd() parent = os.path.dirname(cwd) @@ -26,12 +10,24 @@ # -- General configuration ----------------------------------------------------- -# If your documentation needs a minimal Sphinx version, state it here. -# needs_sphinx = '1.0' +# Sphinx extension modules +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "sphinx_autodoc_typehints", +] + +# Enable automatic links to other projects' Sphinx docs +intersphinx_mapping = { + "requests": ("https://requests.readthedocs.io/en/master/", None), +} -# Add any Sphinx extension module names here, as strings. They can be extensions -# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ["sphinx.ext.autodoc", "sphinx_autodoc_typehints", "sphinx.ext.viewcode"] +# Enable Google-style docstrings +napoleon_google_docstring = True +napoleon_include_private_with_doc = False +napoleon_include_special_with_doc = False # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -39,63 +35,25 @@ # The suffix of source filenames. source_suffix = ".rst" -# The encoding of source files. -# source_encoding = 'utf-8-sig' - # The master toctree document. master_doc = "index" # General information about the project. project = u"pyinaturalist" -copyright = u"2018, Nicolas Noé" +copyright = u"2020, Nicolas Noé" -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# # The short X.Y version. version = pyinaturalist.__version__ # The full version, including alpha/beta/rc tags. release = pyinaturalist.__version__ -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -# today = '' -# Else, today_fmt is used as the format for a strftime call. -# today_fmt = '%B %d, %Y' - # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ["_build"] -# The reST default role (used for this markup: `text`) to use for all documents. -# default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -# add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -# add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -# show_authors = False - # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" -# A list of ignored prefixes for module index sorting. -# modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -# keep_warnings = False - - # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for @@ -107,16 +65,6 @@ # documentation. # html_theme_options = {} -# Add any paths that contain custom themes here, relative to this directory. -# html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -# html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -# html_short_title = None - # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None @@ -130,125 +78,3 @@ # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -# html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -# html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -# html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -# html_additional_pages = {} - -# If false, no module index is generated. -# html_domain_indices = True - -# If false, no index is generated. -# html_use_index = True - -# If true, the index is split into individual pages for each letter. -# html_split_index = False - -# If true, links to the reST sources are added to the pages. -# html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -# html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -# html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -# html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -# html_file_suffix = None - -# Output file base name for HTML help builder. -htmlhelp_basename = "pyinaturalistdoc" - - -# -- Options for LaTeX output -------------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - #'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - #'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - #'preamble': '', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). -latex_documents = [ - ("index", "pyinaturalist.tex", u"pyinaturalist Documentation", u"Nicolas Noé", "manual",), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -# latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -# latex_use_parts = False - -# If true, show page references after internal links. -# latex_show_pagerefs = False - -# If true, show URL addresses after external links. -# latex_show_urls = False - -# Documents to append as an appendix to all manuals. -# latex_appendices = [] - -# If false, no module index is generated. -# latex_domain_indices = True - - -# -- Options for manual page output -------------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [("index", "pyinaturalist", u"pyinaturalist Documentation", [u"Nicolas Noé"], 1)] - -# If true, show URL addresses after external links. -# man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------------ - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ( - "index", - "pyinaturalist", - u"pyinaturalist Documentation", - u"Nicolas Noé", - "pyinaturalist", - "One line description of project.", - "Miscellaneous", - ), -] - -# Documents to append as an appendix to all manuals. -# texinfo_appendices = [] - -# If false, no module index is generated. -# texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -# texinfo_no_detailmenu = False diff --git a/docs/make.bat b/docs/make.bat index 2df9a8cb..6cddfa2e 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -19,24 +19,6 @@ if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. singlehtml to make a single large HTML file - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. devhelp to make HTML files and a Devhelp project - echo. epub to make an epub - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. text to make text files - echo. man to make manual pages - echo. texinfo to make Texinfo files - echo. gettext to make PO message catalogs - echo. changes to make an overview over all changed/added/deprecated items - echo. xml to make Docutils-native XML files - echo. pseudoxml to make pseudoxml-XML files for display purposes - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled goto end ) @@ -68,175 +50,4 @@ if "%1" == "html" ( goto end ) -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "singlehtml" ( - %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) - -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\complexity.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\complexity.ghc - goto end -) - -if "%1" == "devhelp" ( - %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. - goto end -) - -if "%1" == "epub" ( - %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub file is in %BUILDDIR%/epub. - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdf" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf - cd %BUILDDIR%/.. - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdfja" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf-ja - cd %BUILDDIR%/.. - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "text" ( - %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The text files are in %BUILDDIR%/text. - goto end -) - -if "%1" == "man" ( - %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The manual pages are in %BUILDDIR%/man. - goto end -) - -if "%1" == "texinfo" ( - %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. - goto end -) - -if "%1" == "gettext" ( - %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The message catalogs are in %BUILDDIR%/locale. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - if errorlevel 1 exit /b 1 - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - if errorlevel 1 exit /b 1 - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -if "%1" == "xml" ( - %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The XML files are in %BUILDDIR%/xml. - goto end -) - -if "%1" == "pseudoxml" ( - %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. - goto end -) - :end diff --git a/pyinaturalist/node_api.py b/pyinaturalist/node_api.py index 481ea711..4a9f6b88 100644 --- a/pyinaturalist/node_api.py +++ b/pyinaturalist/node_api.py @@ -33,10 +33,9 @@ def make_inaturalist_api_get_call( ) -> requests.Response: """Make an API call to iNaturalist. - endpoint is a string such as 'observations' - method: 'GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE' - kwargs are passed to requests.request - Returns a requests.Response object + Args: + endpoint: The name of an endpoint not including the base URL e.g. 'observations' + kwargs: Arguments for :py:func:`requests.request` """ response = get( urljoin(INAT_NODE_API_BASE_URL, endpoint), params=params, user_agent=user_agent, **kwargs @@ -47,11 +46,15 @@ def make_inaturalist_api_get_call( def get_observation(observation_id: int, user_agent: str = None) -> Dict[str, Any]: """Get details about an observation. - :param observation_id: - :param user_agent: a user-agent string that will be passed to iNaturalist. + Args: + observation_id: Observation ID + user_agent: a user-agent string that will be passed to iNaturalist. - :returns: a dict with details on the observation - :raises: ObservationNotFound + Returns: + A dict with details on the observation + + Raises: + ObservationNotFound """ r = get_observations(params={"id": observation_id}, user_agent=user_agent) @@ -62,9 +65,11 @@ def get_observation(observation_id: int, user_agent: str = None) -> Dict[str, An def get_observations(params: Dict, user_agent: str = None) -> Dict[str, Any]: - """Search observations, see: http://api.inaturalist.org/v1/docs/#!/Observations/get_observations. + """Search observations. + See: http://api.inaturalist.org/v1/docs/#!/Observations/get_observations. - Returns the parsed JSON returned by iNaturalist (observations in r['results'], a list of dicts) + Returns: + The parsed JSON returned by iNaturalist (observations in r['results'], a list of dicts) """ r = make_inaturalist_api_get_call("observations", params=params, user_agent=user_agent) @@ -76,7 +81,8 @@ def get_all_observations(params: Dict, user_agent: str = None) -> List[Dict[str, Some params will be overwritten: order_by, order, per_page, id_above (do NOT specify page when using this). - Returns a list of dicts (one entry per observation) + Returns: + A list of dicts (one entry per observation) """ # According to the doc: "The large size of the observations index prevents us from supporting the page parameter @@ -104,18 +110,34 @@ def get_all_observations(params: Dict, user_agent: str = None) -> List[Dict[str, def get_geojson_observations(properties: List[str] = None, **kwargs) -> Dict[str, Any]: """ Get all observation results combined into a GeoJSON ``FeatureCollection``. + By default this includes some basic observation properties as GeoJSON ``Feature`` properties. + The ``properties`` argument can be used to override these defaults. + + Example: + >>> get_geojson_observations(observation_id=16227955, properties=["photo_url"]) + {"type": "FeatureCollection", + "features": [{ + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [4.360086, 50.646894]}, + "properties": { + "photo_url": "https://static.inaturalist.org/photos/24355315/square.jpeg?1536150659" + } + } + ] + } Args: properties: Properties from observation results to include as GeoJSON properties kwargs: Arguments for :py:func:`.get_observations` Returns: - A list of dicts containing taxa results + A ``FeatureCollection`` containing observation results as ``Feature`` dicts. """ + kwargs["mappable"] = True observations = get_all_observations(kwargs) return as_geojson_feature_collection( (flatten_nested_params(obs) for obs in observations), - properties=properties or DEFAULT_OBSERVATION_ATTRS, + properties=properties if properties is not None else DEFAULT_OBSERVATION_ATTRS, ) @@ -124,9 +146,11 @@ def get_taxa_by_id(taxon_id: int, user_agent: str = None) -> Dict[str, Any]: Get one or more taxa by ID. See: https://api.inaturalist.org/v1/docs/#!/Taxa/get_taxa_id - :param: taxon_id: Get taxa with this ID. Multiple values are allowed. + Args: + taxon_id: Get taxa with this ID. Multiple values are allowed. - :returns: A list of dicts containing taxa results + Returns: + A list of dicts containing taxa results """ if not is_int(taxon_id): raise ValueError("Please specify a single integer for the taxon ID") @@ -141,25 +165,27 @@ def get_taxa( """Given zero to many of following parameters, returns taxa matching the search criteria. See https://api.inaturalist.org/v1/docs/#!/Taxa/get_taxa - :param q: Name must begin with this value - :param is_active: Taxon is active - :param taxon_id: Only show taxa with this ID, or its descendants - :param parent_id: Taxon's parent must have this ID - :param rank: Taxon must have this exact rank - :param min_rank: Taxon must have this rank or higher; overrides ``rank`` - :param max_rank: Taxon must have this rank or lower; overrides ``rank`` - :param rank_level: Taxon must have this rank level. Some example values are 70 (kingdom), - 60 (phylum), 50 (class), 40 (order), 30 (family), 20 (genus), 10 (species), 5 (subspecies) - :param id_above: Must have an ID above this value - :param id_below: Must have an ID below this value - :param per_page: Number of results to return in a page. The maximum value is generally 200 - unless otherwise noted - :param locale: Locale preference for taxon common names - :param preferred_place_id: Place preference for regional taxon common names - :param only_id: Return only the record IDs - :param all_names: Include all taxon names in the response - - :returns: A list of dicts containing taxa results + Args: + q: Name must begin with this value + is_active: Taxon is active + taxon_id: Only show taxa with this ID, or its descendants + parent_id: Taxon's parent must have this ID + rank: Taxon must have this exact rank + min_rank: Taxon must have this rank or higher; overrides ``rank`` + max_rank: Taxon must have this rank or lower; overrides ``rank`` + rank_level: Taxon must have this rank level. Some example values are 70 (kingdom), 60 (phylum), + 50 (class), 40 (order), 30 (family), 20 (genus), 10 (species), 5 (subspecies) + id_above: Must have an ID above this value + id_below: Must have an ID below this value + per_page: Number of results to return in a page. The maximum value is generally 200 unless + otherwise noted + locale: Locale preference for taxon common names + preferred_place_id: Place preference for regional taxon common names + only_id: Return only the record IDs + all_names: Include all taxon names in the response + + Returns: + A list of dicts containing taxa results """ if min_rank or max_rank: params["rank"] = _get_rank_range(min_rank, max_rank) @@ -175,19 +201,21 @@ def get_taxa_autocomplete(user_agent: str = None, minify: bool = False, **params **Note:** There appears to currently be a bug in the API that causes ``per_page`` to not have any effect. - :param q: Name must begin with this value - :param is_active: Taxon is active - :param taxon_id: Only show taxa with this ID, or its descendants - :param rank: Taxon must have this rank - :param rank_level: Taxon must have this rank level. Some example values are 70 (kingdom), - 60 (phylum), 50 (class), 40 (order), 30 (family), 20 (genus), 10 (species), 5 (subspecies) - :param per_page: Number of results to return in a page. The maximum value is generally 200 unless otherwise noted - :param locale: Locale preference for taxon common names - :param preferred_place_id: Place preference for regional taxon common names - :param all_names: Include all taxon names in the response - :param minify: Condense each match into a single string containg taxon ID, rank, and name - - :returns: A list of dicts containing taxa results + Args: + q: Name must begin with this value + is_active: Taxon is active + taxon_id: Only show taxa with this ID, or its descendants + rank: Taxon must have this rank + rank_level: Taxon must have this rank level. Some example values are 70 (kingdom), + 60 (phylum), 50 (class), 40 (order), 30 (family), 20 (genus), 10 (species), 5 (subspecies) + per_page: Number of results to return in a page. The maximum value is generally 200 unless otherwise noted + locale: Locale preference for taxon common names + preferred_place_id: Place preference for regional taxon common names + all_names: Include all taxon names in the response + minify: Condense each match into a single string containg taxon ID, rank, and name + + Returns: + A list of dicts containing taxa results """ r = make_inaturalist_api_get_call("taxa/autocomplete", params, user_agent=user_agent) r.raise_for_status() diff --git a/test/test_node_api.py b/test/test_node_api.py index eec45e75..d64c1f04 100644 --- a/test/test_node_api.py +++ b/test/test_node_api.py @@ -26,7 +26,7 @@ def get_observations_response(response_format): def test_get_observation(requests_mock): requests_mock.get( - urljoin(INAT_NODE_API_BASE_URL, "observations?id=16227955"), + urljoin(INAT_NODE_API_BASE_URL, "observations"), json=load_sample_data("get_observation.json"), status_code=200, ) @@ -40,10 +40,7 @@ def test_get_observation(requests_mock): def test_get_geojson_observations(requests_mock): requests_mock.get( - urljoin( - INAT_NODE_API_BASE_URL, - "observations?observation_id=16227955&order_by=id&order=asc&per_page=30", - ), + urljoin(INAT_NODE_API_BASE_URL, "observations"), json=load_sample_data("get_observation.json"), status_code=200, ) @@ -55,9 +52,25 @@ def test_get_geojson_observations(requests_mock): assert feature["properties"]["taxon_id"] == 493595 +def test_get_geojson_observations__custom_properties(requests_mock): + requests_mock.get( + urljoin(INAT_NODE_API_BASE_URL, "observations"), + json=load_sample_data("get_observation.json"), + status_code=200, + ) + + properties = ["taxon_name", "taxon_rank"] + geojson = get_geojson_observations(observation_id=16227955, properties=properties) + print(geojson) + feature = geojson["features"][0] + assert feature["properties"]["taxon_name"] == "Lixus bardanae" + assert feature["properties"]["taxon_rank"] == "species" + assert "id" not in feature["properties"] and "taxon_id" not in feature["properties"] + + def test_get_non_existent_observation(requests_mock): requests_mock.get( - urljoin(INAT_NODE_API_BASE_URL, "observations?id=99999999"), + urljoin(INAT_NODE_API_BASE_URL, "observations"), json=load_sample_data("get_nonexistent_observation.json"), status_code=200, ) @@ -142,7 +155,7 @@ def test_get_taxa_by_id(requests_mock): def test_get_taxa_autocomplete(requests_mock): requests_mock.get( - urljoin(INAT_NODE_API_BASE_URL, "taxa/autocomplete?q=vespi"), + urljoin(INAT_NODE_API_BASE_URL, "taxa/autocomplete"), json=load_sample_data("get_taxa_autocomplete.json"), status_code=200, ) @@ -163,7 +176,7 @@ def test_get_taxa_autocomplete(requests_mock): # Test usage of format_taxon() with get_taxa_autocomplete() def test_get_taxa_autocomplete_minified(requests_mock): requests_mock.get( - urljoin(INAT_NODE_API_BASE_URL, "taxa/autocomplete?q=vespi"), + urljoin(INAT_NODE_API_BASE_URL, "taxa/autocomplete"), json=load_sample_data("get_taxa_autocomplete.json"), status_code=200, ) @@ -188,7 +201,7 @@ def test_get_taxa_autocomplete_minified(requests_mock): def test_user_agent(requests_mock): # TODO: test for all functions that access the inaturalist API? requests_mock.get( - urljoin(INAT_NODE_API_BASE_URL, "observations?id=16227955"), + urljoin(INAT_NODE_API_BASE_URL, "observations"), json=load_sample_data("get_observation.json"), status_code=200, ) diff --git a/test/test_rest_api.py b/test/test_rest_api.py index 3fd760e8..15d679b2 100644 --- a/test/test_rest_api.py +++ b/test/test_rest_api.py @@ -38,13 +38,13 @@ def test_get_observations(response_format, requests_mock): # requests_mock.get(..., **{key: str(response)}) if response_format == "json": requests_mock.get( - urljoin(INAT_BASE_URL, "observations.{}?id=16227955".format(response_format)), + urljoin(INAT_BASE_URL, "observations.{}".format(response_format)), status_code=200, json=response, ) else: requests_mock.get( - urljoin(INAT_BASE_URL, "observations.{}?id=16227955".format(response_format)), + urljoin(INAT_BASE_URL, "observations.{}".format(response_format)), status_code=200, text=response, ) @@ -53,6 +53,12 @@ def test_get_observations(response_format, requests_mock): assert observations == response +@pytest.mark.parametrize("response_format", ["geojson", "yaml"]) +def test_get_observations__invalid_format(response_format): + with pytest.raises(ValueError): + get_observations(id=16227955, response_format=response_format) + + def test_get_observation_fields(requests_mock): """ get_observation_fields() work as expected (basic use)""" From 3a7d44936ef1efafaea5259d9a7827dee5c521a5 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Thu, 11 Jun 2020 10:49:10 -0500 Subject: [PATCH 19/25] Include branching and HTML coverage report with tox -e coverage; remove cookiecutter Makefile since it's now redundant with tox --- .gitignore | 1 + Makefile | 56 -------------------------------------------------- pyproject.toml | 7 +++++++ setup.cfg | 2 +- tox.ini | 8 ++++---- 5 files changed, 13 insertions(+), 61 deletions(-) delete mode 100644 Makefile diff --git a/.gitignore b/.gitignore index 7913c1a0..54f88b3b 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ pip-log.txt .coverage .tox nosetests.xml +test-reports/ # Translations *.mo diff --git a/Makefile b/Makefile deleted file mode 100644 index 31808525..00000000 --- a/Makefile +++ /dev/null @@ -1,56 +0,0 @@ -.PHONY: help clean clean-pyc clean-build list test test-all coverage docs release sdist - -help: - @echo "clean-build - remove build artifacts" - @echo "clean-pyc - remove Python file artifacts" - @echo "lint - check style with flake8" - @echo "test - run tests quickly with the default Python" - @echo "test-all - run tests on every Python version with tox" - @echo "coverage - check code coverage quickly with the default Python" - @echo "docs - generate Sphinx HTML documentation, including API docs" - @echo "release - package and upload a release" - @echo "sdist - package" - -clean: clean-build clean-pyc - -clean-build: - rm -fr build/ - rm -fr dist/ - rm -fr *.egg-info - -clean-pyc: - find . -name '*.pyc' -exec rm -f {} + - find . -name '*.pyo' -exec rm -f {} + - find . -name '*~' -exec rm -f {} + - -lint: - flake8 pyinaturalist test - -test: - py.test - -test-all: - tox - -coverage: - coverage run --source pyinaturalist setup.py test - coverage report -m - coverage html - open htmlcov/index.html - -docs: - rm -f docs/pyinaturalist.rst - rm -f docs/modules.rst - sphinx-apidoc -o docs/ pyinaturalist - $(MAKE) -C docs clean - $(MAKE) -C docs html - open docs/_build/html/index.html - -release: clean - python setup.py sdist upload - python setup.py bdist_wheel upload - -sdist: clean - python setup.py sdist - python setup.py bdist_wheel upload - ls -l dist diff --git a/pyproject.toml b/pyproject.toml index aa4949aa..f0b73593 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,9 @@ [tool.black] line-length = 100 + +[tool.coverage.html] +directory = 'test-reports' + +[tool.coverage.run] +branch = true +source = ['pyinaturalist'] diff --git a/setup.cfg b/setup.cfg index b6009b2e..e05d8030 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] description = Python client for the iNaturalist APIs -long_description = file: README.md, HISTORY.rst +long_description = file: README.rst, HISTORY.rst keywords = pyinaturalist, iNaturalist license = MIT license license_files = LICENSE diff --git a/tox.ini b/tox.ini index 4e9d9a8a..0c423576 100644 --- a/tox.ini +++ b/tox.ini @@ -16,18 +16,18 @@ whitelist_externals = printf # Run all "code quality" checks: coverage, annotations, and style [testenv:coverage] commands = - pytest --basetemp={envtmpdir} --cov={toxinidir}/pyinaturalist - printf '====================\n\n' + pytest --basetemp={envtmpdir} --cov --cov-report=term --cov-report=html + printf '\n\n' [testenv:mypy] commands = mypy --config-file={toxinidir}/setup.cfg . - printf '====================\n\n' + printf '\n\n' [testenv:style] commands = black --check . - printf '====================\n\n' + printf '\n\n' # Install only minimal dependencies for older interpreters # pytest==4.6.9 is the latest release that supports python 3.4 From 9849c4c6c25545cb1000f26a401e03b88cb4e528 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Thu, 11 Jun 2020 13:12:10 -0500 Subject: [PATCH 20/25] On second thought, our versioning is simple enough that it's probably safe enough to do without the `semantic-version` package --- pyinaturalist/__init__.py | 15 +++------------ setup.py | 3 +-- test/test_version.py | 3 --- 3 files changed, 4 insertions(+), 17 deletions(-) diff --git a/pyinaturalist/__init__.py b/pyinaturalist/__init__.py index 95068645..781b8e18 100644 --- a/pyinaturalist/__init__.py +++ b/pyinaturalist/__init__.py @@ -16,22 +16,13 @@ def get_prerelease_version(version: str) -> str: """ If we're running in a Travis CI job on the dev branch, get a prerelease version using the current build number. For example: ``1.0.0 -> 1.0.0-dev.123`` - This could also be done in ``.travis.yml``, but it's a bit cleaner to do in python, and - ``semantic_version`` provides some extra sanity checks. + This could also be done in ``.travis.yml``, but it's a bit cleaner to do in python. """ if not (getenv("TRAVIS") == "true" and getenv("TRAVIS_BRANCH") == "dev"): return version - # If we happen to be in a dev build, this will prevent the initial 'pip install' from failing - try: - from semantic_version import Version - except ImportError: - return version - - new_version = Version(version) - new_version.prerelease = ("dev", getenv("TRAVIS_BUILD_NUMBER", "0")) + new_version = '{}-dev.{}'.format(version, getenv("TRAVIS_BUILD_NUMBER", "0")) getLogger(__name__).info("Using pre-release version: {}".format(new_version)) - return str(new_version) - + return new_version # This won't modify the version outside of Travis __version__ = get_prerelease_version(__version__) diff --git a/setup.py b/setup.py index f973a1b0..4f96cb79 100644 --- a/setup.py +++ b/setup.py @@ -24,14 +24,13 @@ "pytest", "pytest-cov", "requests-mock>=1.7", - "semantic-version", "Sphinx", "sphinx-autodoc-typehints", "sphinx-rtd-theme", "tox", ], # Additional packages used only within CI jobs - "build": ["coveralls", "semantic-version", "tox-travis"], + "build": ["coveralls", "tox-travis"], }, zip_safe=False, ) diff --git a/test/test_version.py b/test/test_version.py index 73f13bec..1ed5fdc9 100644 --- a/test/test_version.py +++ b/test/test_version.py @@ -1,8 +1,6 @@ # A couple tests to make sure that versioning works as expected within Travis # So, for example, the build would fail before accidentally publishing a bad version -from sys import version_info from unittest.mock import patch -import pytest # Mocking out getenv() instead of actually setting envars so this doesn't affect other tests @@ -13,7 +11,6 @@ def test_version__stable_release(mock_getenv): assert "dev" not in pyinaturalist.__version__ -@pytest.mark.skipif(version_info < (3, 6), reason="semantic-version requires python >= 3.6") @patch("pyinaturalist.getenv", side_effect=["true", "dev", "123"]) def test_version__pre_release(mock_getenv): import pyinaturalist From f9a7632d4f36d1d6b7e9c8750e9c6a0913b97ca0 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Thu, 11 Jun 2020 11:19:02 -0500 Subject: [PATCH 21/25] Symlink documentation image dir(s) at build time --- .gitignore | 3 ++- docs/Makefile | 10 ++++++++-- docs/docs/README.rst | 2 -- docs/docs/images | 1 - tox.ini | 12 ++++-------- 5 files changed, 14 insertions(+), 14 deletions(-) delete mode 100644 docs/docs/README.rst delete mode 120000 docs/docs/images diff --git a/.gitignore b/.gitignore index 54f88b3b..d7da7bfd 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,8 @@ output/*.html output/*/index.html # Sphinx -docs/_build +docs/_build/ +docs/docs/ # Mac OS X .DS_Store diff --git a/docs/Makefile b/docs/Makefile index 5bb0bf9f..c58e95ab 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -11,9 +11,15 @@ help: .PHONY: help all Makefile -all: clean html +all: clean symlink-images linkcheck html +# Create symlinks so that relative links to static content will resolve correctly in: +# 1. README.rst (as displayed on GitHub) and +# 2. Sphinx html docs (as displayed on readthedocs.io) +symlink-images: + mkdir -p docs && rm docs/images + ln -s $(pwd)/images $(pwd)/docs/images -# Catch-all target: route all unknown targets to Sphinx +# Catch-all target: route all unknown targets to Sphinx builder %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/docs/README.rst b/docs/docs/README.rst deleted file mode 100644 index 087aecb0..00000000 --- a/docs/docs/README.rst +++ /dev/null @@ -1,2 +0,0 @@ -The symlink(s) in this directory exist so that relative links to static content will resolve in -both README.rst (as displayed on GitHub) and Sphinx html docs (as displayed on readthedocs.io). diff --git a/docs/docs/images b/docs/docs/images deleted file mode 120000 index 5e675731..00000000 --- a/docs/docs/images +++ /dev/null @@ -1 +0,0 @@ -../images \ No newline at end of file diff --git a/tox.ini b/tox.ini index 0c423576..5b76065f 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,9 @@ setenv = extras = dev commands = pytest --basetemp={envtmpdir} -whitelist_externals = printf +whitelist_externals = + printf + make # Run all "code quality" checks: coverage, annotations, and style [testenv:coverage] @@ -44,14 +46,8 @@ deps = extras = [testenv:docs] -changedir=docs/ -deps = - -r{toxinidir}/requirements.txt -whitelist_externals = make commands = - make clean - sphinx-build -b linkcheck ./ _build/ - sphinx-build -b html ./ _build/ + make -C docs all [testenv:lint] commands = From aa01c21074db3db6d737536f67c1620b0a390a81 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Thu, 11 Jun 2020 13:06:13 -0500 Subject: [PATCH 22/25] Customize readthedocs builder to use apidoc and auto-generate Sphinx sources --- .gitignore | 1 + .travis.yml | 4 +- HISTORY.rst | 22 ++++----- docs/Makefile | 14 +++--- docs/conf.py | 109 ++++++++++++++++++++++++--------------------- docs/index.rst | 6 +-- docs/make.bat | 53 ---------------------- docs/reference.rst | 21 --------- setup.cfg | 4 +- setup.py | 4 +- tox.ini | 10 ++++- 11 files changed, 91 insertions(+), 157 deletions(-) delete mode 100644 docs/make.bat diff --git a/.gitignore b/.gitignore index d7da7bfd..c99603a0 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ output/*/index.html # Sphinx docs/_build/ docs/docs/ +docs/modules/ # Mac OS X .DS_Store diff --git a/.travis.yml b/.travis.yml index 85b02539..e9e55da2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,9 +10,9 @@ matrix: - python: "3.5" - python: "3.6" - python: "3.7" - # Run a separate job for combined code quality checks + # Run a separate job for combined code quality checks & build tests - python: "3.8" - env: TOXENV=coverage,mypy,style + env: TOXENV=coverage,mypy,style,docs,dist-test # Only this job will contain an encrypted PYPI_TOKEN for deployment - python: "3.8" env: diff --git a/HISTORY.rst b/HISTORY.rst index bdb2da62..1974e847 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,7 +3,7 @@ History ------- 0.10.0 (2020-06-TBD) -++++++++++++++++++++ +^^^^^^^^^^^^^^^^^^^^ * Added more info & examples to README for taxa endpoints * Added `minify` option to `node_api.get_taxa_autocomplete()` @@ -12,48 +12,48 @@ History * Added 6 additional observation response formats, including GeoJSON, Darwin Core, and others 0.9.1 (2020-05-26) -++++++++++++++++++ +^^^^^^^^^^^^^^^^^^ * Bugfix: proper support for boolean and integer list parameters (see https://github.com/niconoe/pyinaturalist/issues/17). Thanks Jordan Cook! 0.9.0 (2020-05-06) -++++++++++++++++++ +^^^^^^^^^^^^^^^^^^ * new taxa-related functions: node_api.get_taxa(), node_api.get_taxa_autocomplete(), node_api.get_taxa_by_id(). Many thanks to Jordan Cook! 0.8.0 (2019-07-11) -++++++++++++++++++ +^^^^^^^^^^^^^^^^^^ * all functions now take an optional `user-agent `_ parameter in order to identify yourself to iNaturalist. If not set, `Pyinaturalist/` will be used. 0.7.0 (2019-05-08) -++++++++++++++++++ +^^^^^^^^^^^^^^^^^^ * rest_api.delete_observation() now raises ObservationNotFound if the observation doesn't exists * minor dependencies update for security reasons 0.6.0 (2018-11-15) -++++++++++++++++++ +^^^^^^^^^^^^^^^^^^ * New function: rest_api.delete_observation() 0.5.0 (2018-11-05) -++++++++++++++++++ +^^^^^^^^^^^^^^^^^^ * New function: node_api.get_observation() 0.4.0 (2018-11-05) -++++++++++++++++++ +^^^^^^^^^^^^^^^^^^ * create_observation() now raises exceptions in case of errors. 0.3.0 (2018-11-05) -++++++++++++++++++ +^^^^^^^^^^^^^^^^^^ * update_observation() now raises exceptions in case of errors. 0.2.0 (2018-10-31) -++++++++++++++++++ +^^^^^^^^^^^^^^^^^^ * Better infrastructure (type annotations, documentation, ...) * Dropped support for Python 2. @@ -62,6 +62,6 @@ History 0.1.0 (2018-10-10) -++++++++++++++++++ +^^^^^^^^^^^^^^^^^^ * First release on PyPI. diff --git a/docs/Makefile b/docs/Makefile index c58e95ab..73ec5ccd 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -2,23 +2,19 @@ SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . +AUTOSOURCEDIR = $(SOURCEDIR)/modules BUILDDIR = _build - # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -.PHONY: help all Makefile +.PHONY: help all clean-apidoc Makefile -all: clean symlink-images linkcheck html +all: clean clean-apidoc html -# Create symlinks so that relative links to static content will resolve correctly in: -# 1. README.rst (as displayed on GitHub) and -# 2. Sphinx html docs (as displayed on readthedocs.io) -symlink-images: - mkdir -p docs && rm docs/images - ln -s $(pwd)/images $(pwd)/docs/images +clean-apidoc: + rm -rf "$(AUTOSOURCEDIR)" # Catch-all target: route all unknown targets to Sphinx builder %: Makefile diff --git a/docs/conf.py b/docs/conf.py index 4f5e8180..8383883d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,14 +1,27 @@ # Documentation build configuration file, created by sphinx-quickstart -import os import sys +from os import makedirs, symlink +from os.path import abspath, dirname, exists, join -cwd = os.getcwd() -parent = os.path.dirname(cwd) -sys.path.insert(0, parent) +DOCS_DIR = abspath(dirname(__file__)) +DOC_IMAGES_DIR = join(DOCS_DIR, "images") +PROJECT_DIR = dirname(DOCS_DIR) +PACKAGE_DIR = join(PROJECT_DIR, "pyinaturalist") +sys.path.insert(0, PROJECT_DIR) +from pyinaturalist import __version__ -import pyinaturalist - -# -- General configuration ----------------------------------------------------- +# General information about the project. +project = "pyinaturalist" +copyright = "2020, Nicolas Noé" +needs_sphinx = "3.0" # Minimum Sphinx version; needed for latest version of autodoc type hints +master_doc = "index" +source_suffix = ".rst" +version = release = __version__ +# Exclude the generated pyinaturalist.rst, which will just contain top-level __init__ info +# and add an extra level to the toctree +exclude_patterns = ["_build", "modules/pyinaturalist.rst"] +html_static_path = ["_static"] +templates_path = ["_templates"] # Sphinx extension modules extensions = [ @@ -16,7 +29,7 @@ "sphinx.ext.intersphinx", "sphinx.ext.napoleon", "sphinx.ext.viewcode", - "sphinx_autodoc_typehints", + "sphinxcontrib.apidoc", ] # Enable automatic links to other projects' Sphinx docs @@ -29,52 +42,44 @@ napoleon_include_private_with_doc = False napoleon_include_special_with_doc = False -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] - -# The suffix of source filenames. -source_suffix = ".rst" - -# The master toctree document. -master_doc = "index" +# Use apidoc to auto-generate rst sources +apidoc_module_dir = PACKAGE_DIR +apidoc_output_dir = "modules" +apidoc_excluded_paths= ["__init__"] +apidoc_module_first = True +apidoc_separate_modules = True +apidoc_toc_file = False -# General information about the project. -project = u"pyinaturalist" -copyright = u"2020, Nicolas Noé" - -# The short X.Y version. -version = pyinaturalist.__version__ -# The full version, including alpha/beta/rc tags. -release = pyinaturalist.__version__ - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = ["_build"] - -# The name of the Pygments (syntax highlighting) style to use. +# HTML theme settings pygments_style = "sphinx" - -# -- Options for HTML output --------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. html_theme = "sphinx_rtd_theme" - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. # html_theme_options = {} -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -# html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -# html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] +# Favicon & sidebar logo +# html_logo = 'logo.jpg' +# html_favicon = 'favicon.ico' + + +def setup(app): + """ Run some additional steps after the Sphinx builder is intialized. This allows us to + run any custom behavior that would otherwise go in the Makefile, so the readthedocs builder + will behave the same as building the docs manually. + + Reference: + * https://www.sphinx-doc.org/en/master/extdev/appapi.html#sphinx-core-events + * https://docs.readthedocs.io/en/stable/builds.html + * https://github.com/sphinx-contrib/apidoc + """ + app.connect("builder-inited", make_symlinks) + + +def make_symlinks(app): + """ Create symlinks so that relative links to static content will resolve correctly in both: + * README.rst (as displayed on GitHub and PyPi) and + * Sphinx html docs (as displayed on readthedocs.io) + """ + doc_symlinks_dir = join(DOCS_DIR, "docs") + symlinked_images_dir = join(doc_symlinks_dir, "images") + makedirs(doc_symlinks_dir, exist_ok=True) + if not exists(symlinked_images_dir): + symlink(DOC_IMAGES_DIR, symlinked_images_dir) diff --git a/docs/index.rst b/docs/index.rst index 0e1b4f5c..5ac8b12c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,11 +7,11 @@ .. include:: ../README.rst -Contents: -========= +Contents +======== .. toctree:: - :maxdepth: 2 + :maxdepth: 3 reference contributing diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 6cddfa2e..00000000 --- a/docs/make.bat +++ /dev/null @@ -1,53 +0,0 @@ -@ECHO OFF - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set BUILDDIR=_build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . -set I18NSPHINXOPTS=%SPHINXOPTS% . -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% - set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% -) - -if "%1" == "" goto help - -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - - -%SPHINXBUILD% 2> nul -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -:end diff --git a/docs/reference.rst b/docs/reference.rst index 0de6234f..2fe9f552 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -45,24 +45,3 @@ Pyinaturalist provides functions to use both of those APIs. If you don't configure the user agent, `Pyinaturalist/` will be used. -REST API --------- - -.. automodule:: pyinaturalist.rest_api - :members: - :show-inheritance: - -Node-based API --------------- - -.. automodule:: pyinaturalist.node_api - :members: - :show-inheritance: - -Exceptions ----------- - -.. automodule:: pyinaturalist.exceptions - :members: - :show-inheritance: - diff --git a/setup.cfg b/setup.cfg index e05d8030..636beddc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,7 @@ [metadata] description = Python client for the iNaturalist APIs long_description = file: README.rst, HISTORY.rst +long_description_content_type = text/x-rst keywords = pyinaturalist, iNaturalist license = MIT license license_files = LICENSE @@ -22,9 +23,6 @@ ignore_missing_imports = True [mypy-requests_mock] ignore_missing_imports = True -[mypy-semantic_version] -ignore_missing_imports = True - [mypy-setuptools] ignore_missing_imports = True diff --git a/setup.py b/setup.py index 4f96cb79..af2eef41 100644 --- a/setup.py +++ b/setup.py @@ -24,9 +24,9 @@ "pytest", "pytest-cov", "requests-mock>=1.7", - "Sphinx", - "sphinx-autodoc-typehints", + "Sphinx>=3.0", "sphinx-rtd-theme", + "sphinxcontrib-apidoc", "tox", ], # Additional packages used only within CI jobs diff --git a/tox.ini b/tox.ini index 5b76065f..254be52a 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ minversion = 3.14 envlist = py34, py35, py36, py37, py38, - coverage, mypy, style, docs, lint + coverage, mypy, style, dist-test, docs, lint [testenv] setenv = @@ -53,5 +53,13 @@ commands = commands = python setup.py flake8 +# Build and check distributions without deploying, just to make sure they can build correctly +[testenv:dist-test] +deps = + twine +commands = + python setup.py sdist bdist_wheel + twine check dist/* + [flake8] max-line-length = 119 From 515bdf0c233d4264e6212b10690494038a7fe0bf Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Thu, 11 Jun 2020 16:01:13 -0500 Subject: [PATCH 23/25] Update Development Status section in docs and fill in some missing function docstrings --- .travis.yml | 2 +- README.rst | 46 +++++----- docs/conf.py | 2 +- docs/index.rst | 2 +- docs/reference.rst | 23 +++-- pyinaturalist/__init__.py | 3 +- pyinaturalist/response_format.py | 2 +- pyinaturalist/rest_api.py | 142 ++++++++++++++++++------------- 8 files changed, 130 insertions(+), 92 deletions(-) diff --git a/.travis.yml b/.travis.yml index e9e55da2..ce08ff59 100644 --- a/.travis.yml +++ b/.travis.yml @@ -40,7 +40,7 @@ deploy: - provider: pypi user: __token__ password: $PYPI_TOKEN - distributions: sdist + distributions: sdist bdist_wheel skip_cleanup: true skip_existing: true on: diff --git a/README.rst b/README.rst index c1585a31..682e331e 100644 --- a/README.rst +++ b/README.rst @@ -21,28 +21,18 @@ pyinaturalist :alt: PyPI - Format Python client for the `iNaturalist APIs `_. - -Status ------- - -Work in progress: features are implemented one by one, as time allows and as the authors needs them. - -That being said, many things are already possible (searching observations, creating a new observation, ...) and -contributions are welcome! - -Python 3 only. See full documentation at ``_. Installation ------------ -Simply use pip:: +Install with pip:: $ pip install pyinaturalist -Or if you prefer using the development version:: +Or, if you would like to use the latest development (non-stable) version:: - $ pip install git+https://github.com/niconoe/pyinaturalist.git + $ pip install --pre pyinaturalist Or, to set up for local development (preferably in a new virtualenv):: @@ -50,6 +40,26 @@ Or, to set up for local development (preferably in a new virtualenv):: $ cd pyinaturalist $ pip install -Ue ".[dev]" +Development Status +------------------ +Pyinaturalist is under active development. Currently, a handful of the most relevant API endpoints +are implemented, including: + +* Searching, creating, and updating observations and observation fields +* Searching for species + +See below for some examples, +see `Reference `_ for a complete list, and +see `Issues `_ for planned & proposed features. +More endpoints will continue to be added as they are needed. PRs are welcome! + +.. note:: + The two iNaturalist APIs expose a combined total of 103 endpoints. Many of these are primarily for + internal use by the iNaturalist web application and mobile apps, and are unlikely to be added unless + there are specific use cases for them. + +.. 37 in REST API, 65 in Node API, 1 undocumented as of 2020-06-11 + Examples -------- @@ -58,7 +68,6 @@ Observations Search all observations matching a criteria: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - .. code-block:: python from pyinaturalist.node_api import get_all_observations @@ -68,11 +77,9 @@ See `available parameters ', password='', app_id='', app_secret=) @@ -83,11 +90,9 @@ Note: you'll need to `create an iNaturalist app `_ that they also use internally: it is very complete - and provides read/write access, but is rather slow and sometimes inconsistent. -- the `Node-based API `_ allows searching and returning core data, is faster and - provides more consistent returned results than the REST API, but has less features. +- The original, `Rails-based REST API `_ + that they also use internally: it is very complete and provides read/write access, but is rather + slow and sometimes inconsistent. +- The `Node-based API `_ allows read-only access to most + iNaturalist resources, is faster and provides more consistent behavior than the REST API, but with + fewer features. + +Pyinaturalist provides functions to use both of those APIs. Main API usage information can be found +in :py:mod:`.node_api` and :py:mod:`.rest_api`. Docs for additional supporting modules can also be found below. + +.. Note: module docs are generated by sphinx-apidoc +.. toctree:: + :caption: Module Documentation + :glob: + + modules/* -Pyinaturalist provides functions to use both of those APIs. .. note:: diff --git a/pyinaturalist/__init__.py b/pyinaturalist/__init__.py index 781b8e18..e773868e 100644 --- a/pyinaturalist/__init__.py +++ b/pyinaturalist/__init__.py @@ -20,9 +20,10 @@ 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")) + new_version = "{}-dev.{}".format(version, getenv("TRAVIS_BUILD_NUMBER", "0")) getLogger(__name__).info("Using pre-release version: {}".format(new_version)) return new_version + # This won't modify the version outside of Travis __version__ = get_prerelease_version(__version__) diff --git a/pyinaturalist/response_format.py b/pyinaturalist/response_format.py index 3ea94769..efa39594 100644 --- a/pyinaturalist/response_format.py +++ b/pyinaturalist/response_format.py @@ -9,7 +9,7 @@ def as_geojson_feature_collection( ) -> Dict[str, Any]: """" Convert results from an API response into a - `geojson FeatureCollection`_ object. + `geojson FeatureCollection `_ object. This is currently only used for observations, but could be used for any other responses with geospatial info. diff --git a/pyinaturalist/rest_api.py b/pyinaturalist/rest_api.py index 6bedb44a..9825b242 100644 --- a/pyinaturalist/rest_api.py +++ b/pyinaturalist/rest_api.py @@ -11,11 +11,20 @@ from pyinaturalist.exceptions import AuthenticationError, ObservationNotFound from pyinaturalist.api_requests import delete, get, post, put +# Workaround for python 3.4 +try: + from json import JSONDecodeError +except ImportError: + from builtins import ValueError as JSONDecodeError # type: ignore + def get_observations(response_format="json", user_agent: str = None, **params) -> Union[Dict, str]: """Get observation data, optionally in an alternative format. Return type will be - ``dict`` for the ``json`` response format, and ``str`` for all others. - See: https://www.inaturalist.org/pages/api+reference#get-observations + ``dict`` for the ``json`` response format, and ``str`` for all others. Also see + :py:func:`.get_geojson_observations` for GeoJSON format (not included here because it wraps + a separate API endpoint). + + For API parameters, see: https://www.inaturalist.org/pages/api+reference#get-observations Example:: @@ -96,14 +105,11 @@ def put_observation_field_values( # TODO: It appears pushing the same value/pair twice in a row (but deleting it meanwhile via the UI)... # TODO: ...triggers an error 404 the second time (report to iNaturalist?) """Sets an observation field (value) on an observation. + Will fail if this observation_field is already set for this observation. - :param observation_id: - :param observation_field_id: - :param value: - :param access_token: access_token: the access token, as returned by :func:`get_access_token()` - :param user_agent: a user-agent string that will be passed to iNaturalist. - - :returns: iNaturalist's response as a dict, for example: + Example: + >>> put_observation_field_values( + >>> observation_id=7345179, observation_field_id=9613, value=250, access_token=token) {'id': 31, 'observation_id': 18166477, 'observation_field_id': 31, @@ -116,7 +122,15 @@ def put_observation_field_values( 'created_at_utc': '2012-09-29T09:05:44.935Z', 'updated_at_utc': '2018-11-13T09:49:47.985Z'} - Will fail if this observation_field is already set for this observation. + Args: + observation_id: + observation_field_id: + value: + access_token: access_token: the access token, as returned by :func:`get_access_token()` + user_agent: a user-agent string that will be passed to iNaturalist. + + Returns: + The nwely updated field value record """ payload = { @@ -143,18 +157,19 @@ def put_observation_field_values( def get_access_token( username: str, password: str, app_id: str, app_secret: str, user_agent: str = None ) -> str: - """ - Get an access token using the user's iNaturalist username and password. + """ Get an access token using the user's iNaturalist username and password. + You still need an iNaturalist app to do this. - (you still need an iNaturalist app to do this) - - :param username: - :param password: - :param app_id: - :param app_secret: - :param user_agent: a user-agent string that will be passed to iNaturalist. - - :return: the access token, example use: headers = {"Authorization": "Bearer %s" % access_token} + Example: + >>> access_token = get_access_token('...') + >>> headers = {"Authorization": f"Bearer {access_token}"} + + Args: + username: iNaturalist username + password: iNaturalist password + app_id: iNaturalist application ID + app_secret: iNaturalist application secret + user_agent: a user-agent string that will be passed to iNaturalist. """ payload = { "client_id": app_id, @@ -180,10 +195,11 @@ def add_photo_to_observation( ): """Upload a picture and assign it to an existing observation. - :param observation_id: the ID of the observation - :param file_object: a file-like object for the picture. Example: open('/Users/nicolasnoe/vespa.jpg', 'rb') - :param access_token: the access token, as returned by :func:`get_access_token()` - :param user_agent: a user-agent string that will be passed to iNaturalist. + Args: + observation_id: the ID of the observation + file_object: a file-like object for the picture. Example: open('/Users/nicolasnoe/vespa.jpg', 'rb') + access_token: the access token, as returned by :func:`get_access_token()` + user_agent: a user-agent string that will be passed to iNaturalist. """ data = {"observation_photo[observation_id]": observation_id} file_data = {"file": file_object} @@ -203,23 +219,25 @@ def create_observations( params: Dict[str, Dict[str, Any]], access_token: str, user_agent: str = None ) -> List[Dict[str, Any]]: """Create a single or several (if passed an array) observations). + For API reference, see: https://www.inaturalist.org/pages/api+reference#post-observations - :param params: - :param access_token: the access token, as returned by :func:`get_access_token()` - :param user_agent: a user-agent string that will be passed to iNaturalist. + Example: + >>> params = {'observation': {'species_guess': 'Pieris rapae'}} + >>> token = get_access_token('...') + >>> create_observations(params=params, access_token=token) - :return: iNaturalist's JSON response, as a Python object - :raise: requests.HTTPError, if the call is not successful. iNaturalist returns an error 422 (unprocessable entity) - if it rejects the observation data (for example an observation date in the future or a latitude > 90. In - that case the exception's `response` attribute give details about the errors. + Args: + params: + access_token: the access token, as returned by :func:`get_access_token()` + user_agent: a user-agent string that will be passed to iNaturalist. - allowed params: see https://www.inaturalist.org/pages/api+reference#post-observations + Returns: + The newly created observation(s) in JSON format - Example: - - params = {'observation': - {'species_guess': 'Pieris rapae'}, - } + Raises: + :py:exc:`requests.HTTPError`, if the call is not successful. iNaturalist returns an error 422 (unprocessable entity) + if it rejects the observation data (for example an observation date in the future or a latitude > 90. In + that case the exception's `response` attribute give details about the errors. TODO investigate: according to the doc, we should be able to pass multiple observations (in an array, and in renaming observation to observations, but as far as I saw they are not created (while a status of 200 is returned) @@ -240,16 +258,19 @@ def update_observation( """ Update a single observation. See https://www.inaturalist.org/pages/api+reference#put-observations-id - :param observation_id: the ID of the observation to update - :param params: to be passed to iNaturalist API - :param access_token: the access token, as returned by :func:`get_access_token()` - :param user_agent: a user-agent string that will be passed to iNaturalist. + Args: + observation_id: the ID of the observation to update + params: to be passed to iNaturalist API + access_token: the access token, as returned by :func:`get_access_token()` + user_agent: a user-agent string that will be passed to iNaturalist. - :return: iNaturalist's JSON response, as a Python object - :raise: requests.HTTPError, if the call is not successful. iNaturalist returns an error 410 if the observation - doesn't exists or belongs to another user (as of November 2018). - """ + Returns: + iNaturalist's JSON response, as a Python object + Raises: + :py:exc:`requests.HTTPError`, if the call is not successful. iNaturalist returns an + error 410 if the observation doesn't exists or belongs to another user. + """ response = put( url="{base_url}/observations/{id}.json".format(base_url=INAT_BASE_URL, id=observation_id), json=params, @@ -268,14 +289,17 @@ def delete_observation( """ Delete an observation. - :param observation_id: - :param access_token: - :param user_agent: a user-agent string that will be passed to iNaturalist. + Args: + observation_id: + access_token: + user_agent: a user-agent string that will be passed to iNaturalist. - :return: iNaturalist's JSON response, as a Python object (currently raise a JSONDecodeError because of an - iNaturalist bug - :raise: ObservationNotFound if the requested observation doesn't exists, requests.HTTPError (403) if the - observation belongs to another user + Returns: + iNaturalist's JSON response, as a Python object + + Raises: + :py:exc:`.ObservationNotFound` if the requested observation doesn't exist + :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), @@ -285,10 +309,10 @@ def delete_observation( ) if response.status_code == 404: raise ObservationNotFound - response.raise_for_status() - # According to iNaturalist documentation, proper JSON should be returned. It seems however that the response is - # currently empty (while the requests succeed), so you may receive a JSONDecode exception. - # It has been reported to the iNaturalist team because the issue persists month after: - # https://github.com/inaturalist/inaturalist/issues/2252 - return response.json() + + # Handle an empty response; see https://github.com/inaturalist/inaturalist/issues/2252 + try: + return response.json() + except JSONDecodeError: + return [] From ca95fd54a2e018be34fb705173e0243d0a824b0d Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Tue, 16 Jun 2020 10:44:27 -0500 Subject: [PATCH 24/25] Update mock response in dry-run mode to be compatible with get_geojson_observations() --- pyinaturalist/api_requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyinaturalist/api_requests.py b/pyinaturalist/api_requests.py index 6a5231ee..9e729f0c 100644 --- a/pyinaturalist/api_requests.py +++ b/pyinaturalist/api_requests.py @@ -12,7 +12,7 @@ # Mock response content to return in dry-run mode MOCK_RESPONSE = Mock(spec=requests.Response) -MOCK_RESPONSE.json.return_value = {"results": ["nodata"]} +MOCK_RESPONSE.json.return_value = {"results": [], "total_results": 0} logger = getLogger(__name__) From 8623120bafa9b4a71800cb5a16bd8e5d6d01217a Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Tue, 16 Jun 2020 10:44:59 -0500 Subject: [PATCH 25/25] Minor updates to README and release notes --- HISTORY.rst | 9 +++++---- README.rst | 8 +++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 1974e847..c6ec18ed 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,14 +2,15 @@ History ------- -0.10.0 (2020-06-TBD) -^^^^^^^^^^^^^^^^^^^^ +0.10.0 (2020-06-16) +^^^^^^^^^^^^^^^^^^^ -* Added more info & examples to README for taxa endpoints +* Added more info & examples to README for taxa endpoints, and other documentation improvements * Added `minify` option to `node_api.get_taxa_autocomplete()` -* Convert all date and datetime parameters to timezone-aware ISO 8601 timestamps +* Added conversion for all date and datetime parameters to timezone-aware ISO 8601 timestamps * Added a dry-run mode to mock out API requests for testing * Added 6 additional observation response formats, including GeoJSON, Darwin Core, and others +* Set up pre-release builds for latest development version 0.9.1 (2020-05-26) ^^^^^^^^^^^^^^^^^^ diff --git a/README.rst b/README.rst index 682e331e..82f224b4 100644 --- a/README.rst +++ b/README.rst @@ -268,10 +268,15 @@ but will be logged instead: .. code-block:: python + >>> import logging >>> import pyinaturalist + + # Enable at least INFO-level logging + >>> logging.basicConfig(level='INFO') + >>> pyinaturalist.DRY_RUN_ENABLED = True >>> get_taxa(q='warbler', locale=1) - {'results': ['nodata']} + {'results': [], 'total_results': 0} INFO:pyinaturalist.api_requests:Request: GET, https://api.inaturalist.org/v1/taxa, params={'q': 'warbler', 'locale': 1}, headers={'Accept': 'application/json', 'User-Agent': 'Pyinaturalist/0.9.1'} @@ -293,6 +298,7 @@ instead: .. code-block:: python >>> pyinaturalist.DRY_RUN_WRITE_ONLY = True + # Also works as an environment variable >>> import os >>> os.environ["DRY_RUN_WRITE_ONLY"] = 'True'