graphways 0.4.0

Fast OpenStreetMap reachability, routing, and isochrones from Python, powered by Rust -- no routing server required.
Documentation
import json
import unittest
from pathlib import Path

import graphways as gw


FIXTURES = Path(__file__).parent / "fixtures"


class PythonApiTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        xml = (FIXTURES / "tiny_map.osm").read_text(encoding="utf-8")
        cls.graph = gw.SpatialGraph.from_osm(xml, "walk")

    def test_route_returns_structured_result(self):
        route = self.graph.route((48.0, 11.0), (48.001, 11.0))

        self.assertEqual(type(route).__name__, "RouteResult")
        self.assertGreater(route.distance_m, 0)
        self.assertGreater(route.duration_s, 0)
        self.assertGreaterEqual(len(route.coordinates), 2)
        self.assertEqual(route.cumulative_times_s[0], 0)
        self.assertEqual(type(route.origin_snap).__name__, "SnapResult")
        self.assertEqual(type(route.destination_snap).__name__, "SnapResult")

        geojson = json.loads(route.to_geojson())
        self.assertEqual(geojson["type"], "Feature")
        self.assertEqual(geojson["geometry"]["type"], "LineString")
        self.assertIn("origin_snap", geojson["properties"])

    def test_isochrone_returns_structured_results(self):
        isochrones = self.graph.isochrone((48.0, 11.0), [1, 3])

        self.assertEqual([iso.minutes for iso in isochrones], [1.0, 3.0])
        self.assertTrue(all(type(iso).__name__ == "IsochroneResult" for iso in isochrones))
        geojson = json.loads(isochrones[0].to_geojson())
        self.assertEqual(geojson["type"], "Polygon")

    def test_snap_point_returns_structured_result(self):
        snap = self.graph.snap_point(48.0, 11.0)

        self.assertEqual(type(snap).__name__, "SnapResult")
        self.assertEqual(snap.node_id, 1)
        self.assertAlmostEqual(snap.distance_m, 0.0)
        self.assertEqual(snap.as_dict()["node_id"], 1)

    def test_graph_views_return_structured_route_and_isochrones(self):
        reachable = self.graph.reachable((48.0, 11.0), minutes=5)
        route = reachable.route((48.0, 11.0), (48.001, 11.0))
        isochrone = reachable.isochrone((48.0, 11.0), [3])[0]

        self.assertEqual(type(route).__name__, "RouteResult")
        self.assertEqual(type(isochrone).__name__, "IsochroneResult")

        prism = self.graph.prism((48.0, 11.0), (48.001, 11.0), max_minutes=8)
        prism_route = prism.route((48.0, 11.0), (48.001, 11.0))
        prism_iso = prism.isochrone((48.0, 11.0), [3])[0]

        self.assertEqual(type(prism_route).__name__, "RouteResult")
        self.assertEqual(type(prism_iso).__name__, "IsochroneResult")

    def test_default_max_snap_rejects_far_coordinates(self):
        with self.assertRaises(LookupError):
            self.graph.route((47.999, 11.0), (48.001, 11.0))

        route = self.graph.route((47.999, 11.0), (48.001, 11.0), max_snap_m=None)
        self.assertEqual(type(route).__name__, "RouteResult")
        self.assertGreater(route.origin_snap.distance_m, 100.0)

    def test_invalid_osm_raises_value_error(self):
        with self.assertRaises(ValueError):
            gw.SpatialGraph.from_osm("not xml", "walk")

    def test_no_path_raises_lookup_error(self):
        xml = """
        <osm>
          <node id="1" lat="0" lon="0" />
          <node id="2" lat="0" lon="0.001" />
          <node id="3" lat="1" lon="1" />
          <node id="4" lat="1" lon="1.001" />
          <way id="10"><nd ref="1" /><nd ref="2" /><tag k="highway" v="residential" /></way>
          <way id="20"><nd ref="3" /><nd ref="4" /><tag k="highway" v="residential" /></way>
        </osm>
        """
        graph = gw.SpatialGraph.from_osm(xml, "walk", retain_all=True)

        with self.assertRaises(LookupError):
            graph.route((0, 0), (1, 1))


if __name__ == "__main__":
    unittest.main()