diff --git a/AUTHORS.md b/AUTHORS.md index 156fabd..a890125 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -1,4 +1,5 @@ Nils Nolde Timothy Ellersiek Christian Beiwinkel -Matthieu Viry \ No newline at end of file +Matthieu Viry +Laurent Basara \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a630b3..fe5426c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Added `geotiff` support for Valhalla's isochrones ([#150](https://github.com/mthh/routingpy/pull/150)). - Added `alternates` support for Valhalla's directions ([#152](https://github.com/mthh/routingpy/pull/152)). - Added `/optimized_route` endpoint to Valhalla ([#160](https://github.com/mthh/routingpy/pull/160)). +- Added `IGN` router with support for `directions` and `isochrones` for French territories ([#157](https://github.com/mthh/routingpy/pull/157)). ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5769fc8..831598d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -131,5 +131,5 @@ It's really easy: To run the new tests and ensure consistency, refer to the [Tests](#tests) section above. **Don't store secrets** in the tests. 4. **Document** Please use docstring documentation for all user-exposed functionality, similar to other router implementations. - Also, please register the new module in `docs/indes.rst`'s `Routers` section. To build the docs, refer to the + Also, please register the new module in `docs/index.rst`'s `Routers` section. To build the docs, refer to the [documentation section](#documentation) for details. Don't forget to add your name to the list of `AUTHORS.md`. diff --git a/README.rst b/README.rst index f860c93..31e4d50 100644 --- a/README.rst +++ b/README.rst @@ -42,6 +42,7 @@ or **time-distance matrices**. - `OpenTripPlannerV2`_ - `Local Valhalla`_ - `Local OSRM`_ +- `IGN`_ This list is hopefully growing with time and contributions by other developers. An up-to-date list is always available in our documentation_. @@ -309,6 +310,7 @@ All these parameters, and more, can optionally be **globally set** for all route .. _OpenTripPlannerV2: https://docs.opentripplanner.org/en/latest/ .. _Local Valhalla: https://valhalla.github.io/valhalla/ .. _Local OSRM: https://github.com/Project-OSRM/osrm-backend/wiki +.. _IGN: https://geoservices.ign.fr/documentation/services/services-geoplateforme/itineraire .. _documentation: https://routingpy.readthedocs.io/en/latest .. _routing-py.routers: https://routingpy.readthedocs.io/en/latest/#module-routingpy.routers .. _Apache 2.0 License: https://github.com/mthh/routingpy/blob/master/LICENSE diff --git a/docs/index.rst b/docs/index.rst index 2e20969..5072730 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -55,6 +55,14 @@ Graphhopper .. automethod:: __init__ +IGN +--- + +.. autoclass:: routingpy.routers.IGN + :members: + + .. automethod:: __init__ + MapboxOSRM ---------- diff --git a/poetry.lock b/poetry.lock index 147174d..708ce68 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "affine" diff --git a/routingpy/routers/__init__.py b/routingpy/routers/__init__.py index 46fcc37..dc04c58 100644 --- a/routingpy/routers/__init__.py +++ b/routingpy/routers/__init__.py @@ -18,6 +18,7 @@ from ..exceptions import RouterNotFound from .google import Google from .graphhopper import Graphhopper +from .ign import IGN from .mapbox_osrm import MapboxOSRM from .openrouteservice import ORS from .opentripplanner_v2 import OpenTripPlannerV2 @@ -28,6 +29,7 @@ _SERVICE_TO_ROUTER = { "google": Google, "graphhopper": Graphhopper, + "ign": IGN, "mapbox_osrm": MapboxOSRM, "mapbox-osrm": MapboxOSRM, "mapbox": MapboxOSRM, @@ -59,7 +61,7 @@ def get_router_by_name(router_name): :param router_name: Name of the router as string. :type router_name: str - :rtype: Union[:class:`routingpy.routers.google.Google`, :class:`routingpy.routers.graphhopper.Graphhopper`, :class:`routingpy.routers.mapbox_osrm.MapBoxOSRM`, :class:`routingpy.routers.openrouteservice.ORS`, :class:`routingpy.routers.osrm.OSRM`, :class:`routingpy.routers.otp_v2.OpenTripPlannerV2`, :class:`routingpy.routers.valhalla.Valhalla`] + :rtype: Union[:class:`routingpy.routers.google.Google`, :class:`routingpy.routers.graphhopper.Graphhopper`, :class:`routingpy.routers.ign.IGN`, :class:`routingpy.routers.mapbox_osrm.MapBoxOSRM`, :class:`routingpy.routers.openrouteservice.ORS`, :class:`routingpy.routers.osrm.OSRM`, :class:`routingpy.routers.otp_v2.OpenTripPlannerV2`, :class:`routingpy.routers.valhalla.Valhalla`] """ try: diff --git a/routingpy/routers/ign.py b/routingpy/routers/ign.py new file mode 100644 index 0000000..036c249 --- /dev/null +++ b/routingpy/routers/ign.py @@ -0,0 +1,419 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2021 GIS OPS UG +# +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +# + +import json +import warnings +from typing import List, Optional, Tuple, Union # noqa: F401 + +from .. import convert, utils +from ..client_base import DEFAULT +from ..client_default import Client +from ..direction import Direction +from ..isochrone import Isochrone, Isochrones + + +class IGN: + """Performs requests to the IGN Geoportail "itineraire" geoservices""" + + _DEFAULT_BASE_URL = "https://data.geopf.fr/navigation" + + def __init__( + self, + base_url: Optional[str] = _DEFAULT_BASE_URL, + user_agent: Optional[str] = None, + timeout: Optional[int] = DEFAULT, + retry_timeout: Optional[int] = None, + retry_over_query_limit: Optional[bool] = False, + skip_api_error: Optional[bool] = None, + client=Client, + **client_kwargs, + ): + """ + Initializes an IGN client. + + :param base_url: The base URL for the request. Defaults to the official address + instance for "car". Should not have a trailing slash. + + :param user_agent: User Agent to be used when requesting. + Default :attr:`routingpy.routers.options.default_user_agent`. + + :param timeout: Combined connect and read timeout for HTTP requests, in + seconds. Specify ``None`` for no timeout. Default :attr:`routingpy.routers.options.default_timeout`. + + :param retry_timeout: Timeout across multiple retriable requests, in + seconds. Default :attr:`routingpy.routers.options.default_retry_timeout`. + + :param retry_over_query_limit: If True, client will not raise an exception + on HTTP 429, but instead jitter a sleeping timer to pause between + requests until HTTP 200 or retry_timeout is reached. + Default :attr:`routingpy.routers.options.default_retry_over_query_limit`. + + :param skip_api_error: Continue with batch processing if a :class:`routingpy.exceptions.RouterApiError` is + encountered (e.g. no route found). If False, processing will discontinue and raise an error. + Default :attr:`routingpy.routers.options.default_skip_api_error`. + + :param client: A client class for request handling. Needs to be derived from :class:`routingpy.client_base.BaseClient` + + :param client_kwargs: Additional arguments passed to the client, such as headers or proxies. + """ + + self.client = client( + base_url, + user_agent, + timeout, + retry_timeout, + retry_over_query_limit, + skip_api_error, + **client_kwargs, + ) + + def directions( + self, + locations: List[List[float]] = [], + profile: Optional[str] = "car", + resource: str = "bdtopo-osrm", + optimization: Optional[str] = None, + geometry_format: Optional[str] = None, + constraints: Optional[Union[str, dict, List[str]]] = None, + getSteps: Optional[bool] = None, + getBbox: Optional[bool] = None, + crs: Optional[str] = None, + waysAttributes: Optional[List[str]] = None, + dry_run: Optional[bool] = None, + **direction_kwargs, + ): + """ + Get directions between an origin point and a destination point. + + For more information, visit https://www.geoportail.gouv.fr/depot/swagger/itineraire.html + + Use ``direction_kwargs`` for any missing ``directions`` request options. + + :param locations: The coordinates tuple the route should be calculated + from in order of visit. + + :param profile: Optionally specifies the mode of transport to use when calculating + directions. Should be "car" (default) or "pedestrian"; + resource "bdtopo-osrm" also supports "exceptionnal". + + :param resource: The routing resource to use. Should be one of + "bdtopo-osrm", "bdtopo-pgr", "bdtopo-valhalla" + + :param optimization: Optimization mode used to compute the route + "fastest" (default) or "shortest". + + :param geometry_format: Format of returned geometries. + One of "geojson" (default) or "polyline" + + :param constraints: Route constraints. May be a JSON-serializable object or a pre-serialized string. + + :param getSteps: Include step-by-step instructions. Defaults to "true". + + :param getBbox: Include route bounding box in the response. Defaults to "true". + + :param crs: Coordinate reference system for returned geometries. + Default is "EPSG:4326" (WGS84 : latitude/longitude), + other options depend on resource used. + + :param dry_run: Print URL and parameters without sending the request. + + :param direction_kwargs: any additional keyword arguments which will override parameters + + :returns: A route from provided coordinates and restrictions. + :rtype: :class:`routingpy.direction.Direction` + """ + + params = self.get_direction_params( + locations, + profile, + resource, + optimization, + geometry_format, + constraints, + getSteps, + getBbox, + crs, + waysAttributes, + **direction_kwargs, + ) + + return self.parse_direction_json( + self.client._request("/itineraire", get_params=params, dry_run=dry_run), + geometry_format=geometry_format, + ) + + @staticmethod + def get_direction_params( + locations: List[List[float]], + profile: Optional[str] = "car", + resource: str = "bdtopo-osrm", + optimization: Optional[str] = None, + geometry_format: Optional[str] = None, + constraints: Optional[Union[str, dict, List[str]]] = None, + getSteps: Optional[bool] = None, + getBbox: Optional[bool] = None, + crs: Optional[str] = None, + waysAttributes: Optional[List[str]] = None, + **direction_kwargs, + ): + """ + Builds and returns the router's route parameters. It's a separate function so that + bindings can use routingpy's functionality. See documentation of .directions(). + """ + + params = dict() + + if isinstance(locations, list) and len(locations) >= 2: + start = locations[0] + end = locations[-1] + intermediates = locations[1:-1] if len(locations) > 2 else None + else: + raise ValueError("locations parameter must be a list of at least two coordinate pairs") + + def format_coord(arg): + """Formats a coordinate pair as "lon,lat" string.""" + return convert.delimit_list([convert.format_float(f) for f in arg]) + + params["start"] = format_coord(start) + params["end"] = format_coord(end) + + if intermediates: + formatted = convert.delimit_list([i for i in intermediates], "|") + params["intermediates"] = formatted + + if not resource: + raise ValueError("resource parameter is required for IGN directions") + params["resource"] = resource + + if profile is not None: + params["profile"] = profile + + if optimization is not None: + params["optimization"] = optimization + + if geometry_format is not None: + params["geometryFormat"] = geometry_format + + if constraints is not None: + # constraints can be provided as JSON/dict or as pre-serialized string + if isinstance(constraints, (dict, list)): + params["constraints"] = json.dumps(constraints) + else: + params["constraints"] = str(constraints) + + if getSteps is not None: + params["getSteps"] = convert.convert_bool(getSteps) + + if getBbox is not None: + params["getBbox"] = convert.convert_bool(getBbox) + + params["distanceUnit"] = "meter" + params["timeUnit"] = "second" + + if crs is not None: + params["crs"] = crs + + if waysAttributes: + params["waysAttributes"] = convert.delimit_list(waysAttributes, ",") + + params = utils.deep_merge_dicts(params, direction_kwargs) + + return params + + @classmethod + def parse_geometry(cls, geometry, geometry_format): + if geometry is None: + return None + coo = None + if geometry_format == "geojson" or geometry_format is None: + coo = geometry.get("coordinates") + elif geometry_format == "polyline": + coo = utils.decode_polyline5(geometry, is3d=False) + return coo + + @staticmethod + def parse_direction_json(response, geometry_format): + if response is None or not isinstance(response, dict): # pragma: no cover + return Direction() + + def key_to_int(key): + val = response.get(key) + if val is not None: + return int(val) + return None + + geometry = IGN.parse_geometry(response.get("geometry"), geometry_format) + + return Direction( + geometry=geometry, + duration=key_to_int("distance"), + distance=key_to_int("duration"), + raw=response, + ) + + def isochrones( + self, + locations: List[float], + intervals: Union[List[int], Tuple[int], int], + interval_type: Optional[str] = "time", + profile: Optional[str] = "car", + resource: Optional[str] = "bdtopo-valhalla", + direction: Optional[str] = None, + constraints: Optional[Union[str, dict, List[str]]] = None, + geometry_format: Optional[str] = None, + crs: Optional[str] = None, + dry_run: Optional[bool] = None, + ): + """ + Get isochrone (or equidistant) around a location using the IGN Geoportail isochrone service, + see https://www.geoportail.gouv.fr/depot/swagger/itineraire.html#/Utilisation/isochrone + + This method calls the IGN "isochrone" operation of the itineraire API and returns polygonal + contour for the requested interval. Consult the service's GetCapabilities for valid values + for `resource`, `profile`, and other provider-specific options. + + :param locations: One pair of lng/lat values, expressed in the resource CRS. + :type locations: [float, float] + + :param intervals: Integer range for which to compute isochrone/equidistant. Value represents + seconds when ``interval_type`` is "time", or meters when ``interval_type`` is "distance". + Note that only one contour can be calculated by the API. For compatibility reasons, it + is possible to pass this value in a list or tuple, or as a single integer. + If multiple values are passed, only the first one will be used. + :type intervals: int + + :param interval_type: Type of the provided ranges: "time" for isochrones (default) or "distance" + for equidistants. + :type interval_type: str + + :param profile: see distance method for details. Default "car". + :type profile: str + + :param resource: one of "bdtopo-valhalla" (default), "bdtopo-pgr". + Note: "bdtopo-osrm" does not support isochrones. + + :param direction: Directionality of the calculation. Should be "departure" (defaults, + gives potential arrival points) or "arrival" (gives potential starting points). + :type direction: str + + :param constraints: see `distance` method documentation. + + :param geometry_format: see `distance` method documentation. + + :param crs: see `distance` method documentation. + + :param dry_run: Print URL and parameters without sending the request. + :param dry_run: bool + + :returns: An isochrone with the specified range. + :rtype: :class:`routingpy.isochrone.Isochrones` + + :raises ValueError: If required parameters are missing or malformed (for example, if ``locations`` is not a + valid coordinate pair or if ``intervals`` is empty). + """ + + params = self.get_isochrone_params( + locations=locations, + intervals=intervals, + interval_type=interval_type, + profile=profile, + resource=resource, + direction=direction, + constraints=constraints, + geometry_format=geometry_format, + crs=crs, + ) + + return self.parse_isochrone_json( + self.client._request("/isochrone", get_params=params, dry_run=dry_run), + geometry_format=geometry_format, + ) + + @staticmethod + def get_isochrone_params( + locations: List[float], + intervals: Union[List[int], Tuple[int], int], + interval_type: Optional[str] = "time", + profile: Optional[str] = "car", + resource: Optional[str] = "bdtopo-valhalla", + direction: Optional[str] = None, + constraints: Optional[Union[str, dict, List[str]]] = None, + geometry_format: Optional[str] = None, + crs: Optional[str] = None, + ): + # Validate and format inputs + if convert.is_list(intervals) and len(intervals) != 0: + if len(intervals) > 1: + warnings.warn( + "Only the first value of the intervals list/tuple " + "is used by the IGN isochrone service.", + UserWarning, + ) + intervals = intervals[0] + + if not isinstance(intervals, int): + raise ValueError("Intervals parameter must be an integer or list-like of integers") + + if len(locations) != 2: + raise ValueError("locations must be a coordinate pair [lng, lat]") + + location_formatted = convert.delimit_list( + [convert.format_float(locations[0]), convert.format_float(locations[1])] + ) + params = { + "point": location_formatted, + "profile": profile, + "costValue": intervals, + "costType": interval_type, + "resource": resource, + } + + # map direction argument to the service's location_type parameter + if direction: + params["location_type"] = direction + + if constraints is not None: + if isinstance(constraints, (dict, list)): + params["constraints"] = json.dumps(constraints) + else: + params["constraints"] = str(constraints) + + if geometry_format is not None: + params["geometryFormat"] = geometry_format + + if crs is not None: + params["crs"] = crs + + return params + + @staticmethod + def parse_isochrone_json(response, geometry_format): + if response is None: # pragma: no cover + return Isochrones() + + geometry = IGN.parse_geometry(response.get("geometry"), geometry_format) + center = [float(coo) for coo in response.get("point").split(",")] + isochrone = Isochrone( + geometry=geometry, + interval=response.get("costValue"), + center=center, + interval_type=response.get("costType"), + ) + return Isochrones([isochrone], raw=response) + + def matrix(self): # pragma: no cover + raise NotImplementedError diff --git a/routingpy/routers/valhalla.py b/routingpy/routers/valhalla.py index f581fe4..50417fb 100644 --- a/routingpy/routers/valhalla.py +++ b/routingpy/routers/valhalla.py @@ -228,7 +228,7 @@ def get_direction_params( ): """ Builds and returns the router's route parameters. It's a separate function so that - bindings can use routingpy's functionality. See documentation of .matrix(). + bindings can use routingpy's functionality. See documentation of .directions(). """ params = dict(costing=profile, narrative=instructions) @@ -593,7 +593,7 @@ def matrix( :param locations: Multiple pairs of lng/lat values. :param profile: Specifies the mode of transport to use when calculating - matrices. One of ["auto", "bicycle", "multimodal", "pedestrian". + matrices. One of ["auto", "bicycle", "multimodal", "pedestrian"]. :param sources: A list of indices that refer to the list of locations (starting with 0). If not passed, all indices are considered. diff --git a/tests/data/mock.py b/tests/data/mock.py index 6c0adfc..cc120b0 100644 --- a/tests/data/mock.py +++ b/tests/data/mock.py @@ -983,6 +983,23 @@ "weights": [[0.0, 272.99, 2331.526], [258.115, 0.0, 2305.121], [2356.307, 2225.083, 0.0]], }, }, + "ign": { + "directions_geojson": { + "geometry": {"coordinates": PARAM_LINE}, + "duration": 100, + "distance": 100, + }, + "directions_polyline": { + "geometry": "qmbjHspkr@kCmCpE{T~M|@|QcRvC}OjCgD~F~@~I~SjLxqAjT||@lDde@aBhh@uUbuAmJpPod@|c@iWhQoSt_@}Hx]oTfNqNWqQeKilAa]hByx@DiCvDd@dNxJ[zMl[eCfKlBn[rUb]z]pGoGbHtY|j@xw@jKnFfd@~IdhEpyFm@rGpInKyAhDuDuE", + "duration": 100, + "distance": 100, + }, + "isochrones": { + "point": ",".join([str(x) for x in PARAM_POINT]), + "costValue": PARAM_INT_BIG, + "geometry": {"coordinates": PARAM_POLY}, + }, + }, } ENDPOINTS_QUERIES = { @@ -1285,6 +1302,19 @@ "resolve_locations": "true", }, }, + "ign": { + "directions": { + "resource": "bdtopo-osrm", + "locations": PARAM_LINE, + "profile": "car", + "geometry_format": "geojson", + "getSteps": True, + }, + "isochrones": { + "locations": PARAM_POINT, + "intervals": PARAM_INT_BIG, + }, + }, } ENDPOINTS_EXPECTED = { diff --git a/tests/test_ign.py b/tests/test_ign.py new file mode 100644 index 0000000..f3f5853 --- /dev/null +++ b/tests/test_ign.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2021 GIS OPS UG +# +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +# +"""Tests for the IGN Geoportail module.""" + +from copy import deepcopy + +import responses + +import tests as _test +from routingpy import IGN +from routingpy.direction import Direction +from routingpy.isochrone import Isochrone, Isochrones +from tests.data.mock import * + + +class IGNTest(_test.TestCase): + name = "ign" + + def setUp(self): + self.client = IGN() + + @responses.activate + def test_directions_geojson(self): + """Test directions with GeoJSON geometry format.""" + query = deepcopy(ENDPOINTS_QUERIES[self.name]["directions"]) + + responses.add( + responses.GET, + "https://data.geopf.fr/navigation/itineraire", + status=200, + json=ENDPOINTS_RESPONSES["ign"]["directions_geojson"], + content_type="application/json", + ) + + route = self.client.directions(**query) + + self.assertEqual(1, len(responses.calls)) + self.assertIsInstance(route, Direction) + self.assertIsInstance(route.distance, int) + self.assertIsInstance(route.duration, int) + self.assertIsInstance(route.geometry, list) + self.assertIsInstance(route.raw, dict) + + @responses.activate + def test_directions_polyline(self): + """Test directions with Polyline geometry format.""" + query = deepcopy(ENDPOINTS_QUERIES[self.name]["directions"]) + query["geometry_format"] = "polyline" + + responses.add( + responses.GET, + "https://data.geopf.fr/navigation/itineraire", + status=200, + json=ENDPOINTS_RESPONSES["ign"]["directions_polyline"], + content_type="application/json", + ) + + route = self.client.directions(**query) + + self.assertEqual(1, len(responses.calls)) + self.assertIsInstance(route, Direction) + self.assertIsInstance(route.distance, int) + self.assertIsInstance(route.duration, int) + self.assertIsInstance(route.geometry, list) + self.assertIsInstance(route.raw, dict) + + @responses.activate + def test_directions_with_intermediates(self): + """Test directions with intermediate waypoints.""" + query = deepcopy(ENDPOINTS_QUERIES[self.name]["directions"]) + query["locations"][1:1] = [[8.7, 49.42], [8.73, 49.43]] + + responses.add( + responses.GET, + "https://data.geopf.fr/navigation/itineraire", + status=200, + json=ENDPOINTS_RESPONSES["ign"]["directions_geojson"], + content_type="application/json", + ) + + route = self.client.directions(**query) + + self.assertEqual(1, len(responses.calls)) + self.assertIsInstance(route, Direction) + self.assertIsInstance(route.distance, int) + + @responses.activate + def test_full_isochrones(self): + """Test isochrones calculation.""" + query = deepcopy(ENDPOINTS_QUERIES[self.name]["isochrones"]) + + responses.add( + responses.GET, + "https://data.geopf.fr/navigation/isochrone", + status=200, + json=ENDPOINTS_RESPONSES["ign"]["isochrones"], + content_type="application/json", + ) + + isochrones = self.client.isochrones(**query) + + self.assertEqual(1, len(responses.calls)) + self.assertIsInstance(isochrones, Isochrones) + for iso in isochrones: + self.assertIsInstance(iso, Isochrone) + self.assertIsInstance(iso.geometry, (list, dict)) + + @responses.activate + def test_isochrones_distance_type(self): + """Test isochrones with distance-based intervals.""" + query = deepcopy(ENDPOINTS_QUERIES[self.name]["isochrones"]) + query["interval_type"] = "distance" + + responses.add( + responses.GET, + "https://data.geopf.fr/navigation/isochrone", + status=200, + json=ENDPOINTS_RESPONSES["ign"]["isochrones"], + content_type="application/json", + ) + + isochrones = self.client.isochrones(**query) + + self.assertEqual(1, len(responses.calls)) + self.assertIsInstance(isochrones, Isochrones) + + @responses.activate + def test_isochrones_with_constraints(self): + """Test isochrones with routing constraints.""" + query = deepcopy(ENDPOINTS_QUERIES[self.name]["isochrones"]) + query["constraints"] = {"avoid": ["toll"]} + + responses.add( + responses.GET, + "https://data.geopf.fr/navigation/isochrone", + status=200, + json=ENDPOINTS_RESPONSES["ign"]["isochrones"], + content_type="application/json", + ) + + isochrones = self.client.isochrones(**query) + + self.assertEqual(1, len(responses.calls)) + self.assertIsInstance(isochrones, Isochrones)