diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml new file mode 100644 index 0000000..2047400 --- /dev/null +++ b/.github/workflows/pypi-release.yml @@ -0,0 +1,25 @@ +name: PyPI release +on: [push] + +jobs: + pypi: + runs-on: ubuntu-18.04 + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.x + uses: actions/setup-python@v1 + with: + python-version: 3.x + - name: Install dependencies + run: | + python -m pip install --user --upgrade setuptools wheel + - name: Build + run: | + python setup.py sdist bdist_wheel + python setup_meta.py sdist bdist_wheel + - name: Publish package + if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@v1.1.0 + with: + user: __token__ + password: ${{ secrets.pypi_password }} diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index d4f4ce6..4b3ec81 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -1,4 +1,4 @@ -name: Garden flower +name: Tests on: [push, pull_request] jobs: @@ -55,7 +55,7 @@ jobs: draft: true - name: Test with pytest run: | - python3 -m pytest --cov=kivy_garden.mapview --cov-report term --cov-branch tests/ + python3 -m pytest tests/ - name: Coveralls # use this custom action until Coveralls fixes uptream issues # https://github.com/coverallsapp/github-action/issues/30 @@ -92,7 +92,7 @@ jobs: run: python -m pip install -e .[dev,ci] --extra-index-url https://kivy-garden.github.io/simple/ - name: Test with pytest run: | - python -m pytest --cov=kivy_garden.mapview --cov-report term --cov-branch tests/ + python -m pytest tests/ docs: runs-on: ubuntu-18.04 diff --git a/.travis.yml b/.travis.yml index 69b0f5e..d2b04cd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,14 +11,3 @@ install: script: - docker run -e DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix mapview-linux /bin/sh -c 'tox' - -# Deploy to PyPI using token set in `PYPI_PASSWORD` environment variable -# https://pypi.org/manage/account/token/ -# https://travis-ci.com/github/kivy-garden/mapview/settings -deploy: - provider: pypi - distributions: sdist bdist_wheel - user: "__token__" - on: - tags: true - repo: kivy-garden/mapview diff --git a/CHANGELOG.md b/CHANGELOG.md index 30159cc..d543f75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,12 @@ # Change Log +## [Unreleased] + + - Fix `Downloader` now checks for HTTP status code, refs #6 + ## [1.0.2] - - Fix `AttributeError` on `GeoJsonMapLayer.canvas_line` + - Fix `AttributeError` on `GeoJsonMapLayer.canvas_line`, refs #12 ## [1.0.1] diff --git a/Dockerfile b/Dockerfile index dda88e3..c34bf81 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,35 +5,21 @@ # docker run mapview-linux /bin/sh -c 'tox' # Or for interactive shell: # docker run -it --rm mapview-linux +# For using the UI from Docker you may need to: +# xhost +local: FROM ubuntu:18.04 -# configure locale -RUN apt -y update -qq > /dev/null && apt install --yes --no-install-recommends \ - locales \ - && locale-gen en_US.UTF-8 \ - && apt -y autoremove \ - && apt -y clean \ - && rm -rf /var/lib/apt/lists/* -ENV LANG="en_US.UTF-8" \ - LANGUAGE="en_US.UTF-8" \ - LC_ALL="en_US.UTF-8" - # install system dependencies RUN apt -y update -qq > /dev/null && apt install --yes --no-install-recommends \ build-essential \ - git \ - lsb-release \ libsdl2-dev \ libsdl2-image-dev \ libsdl2-mixer-dev \ libsdl2-ttf-dev \ - libssl-dev \ - make \ pkg-config \ python3-pip \ python3-setuptools \ tox \ - virtualenv \ && python3 -m pip install --upgrade --no-cache setuptools \ && apt -y autoremove \ && apt -y clean \ diff --git a/README.md b/README.md index 6141114..7655eaf 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # Mapview -[![Github Build Status](https://github.com/kivy-garden/mapview/workflows/Garden%20flower/badge.svg)](https://github.com/kivy-garden/mapview/actions) +[![Github Build Status](https://github.com/kivy-garden/mapview/workflows/Tests/badge.svg)](https://github.com/kivy-garden/mapview/actions?query=workflow%3ATests) [![Build Status](https://travis-ci.com/kivy-garden/mapview.svg?branch=develop)](https://travis-ci.com/kivy-garden/mapview) [![Coverage Status](https://coveralls.io/repos/github/kivy-garden/mapview/badge.svg?branch=develop)](https://coveralls.io/github/kivy-garden/mapview?branch=develop) +[![PyPI version](https://badge.fury.io/py/mapview.svg)](https://badge.fury.io/py/mapview) Mapview is a Kivy widget for displaying interactive maps. It has been designed with lot of inspirations of @@ -31,13 +32,7 @@ the latests state-of-the-art Kivy's methods. # Requirements -It requires the `concurrent.futures` and `requests`. If you are on python 2.7, -you can use `futures`: - -``` -pip install futures requests -``` - +It requires the `concurrent.futures` and `requests`. If you use it on Android / iOS, don't forget to add `openssl` as a requirements, otherwise you'll have an issue when importing `urllib3` from `requests`. @@ -64,7 +59,9 @@ class MapViewApp(App): MapViewApp().run() ``` -More extensive documentation will come soon. +Find out more: +- [examples/](https://github.com/kivy-garden/mapview/tree/master/examples) +- Contributing diff --git a/examples/clustered_geojson.py b/examples/clustered_geojson.py index 06ecbf1..8503a4a 100644 --- a/examples/clustered_geojson.py +++ b/examples/clustered_geojson.py @@ -1,14 +1,11 @@ -from kivy.base import runTouchApp import sys -if __name__ == '__main__' and __package__ is None: - from os import path - sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) +from kivy.base import runTouchApp -from kivy_garden.mapview import MapView, MapMarker -from kivy_garden.mapview.geojson import GeoJsonMapLayer +from kivy_garden.mapview import MapMarker, MapView from kivy_garden.mapview.clustered_marker_layer import ClusteredMarkerLayer -from kivy_garden.mapview.utils import haversine, get_zoom_for_radius +from kivy_garden.mapview.geojson import GeoJsonMapLayer +from kivy_garden.mapview.utils import get_zoom_for_radius, haversine source = sys.argv[1] @@ -27,9 +24,7 @@ view = MapView(**options) view.add_layer(layer) -marker_layer = ClusteredMarkerLayer( - cluster_radius=200 -) +marker_layer = ClusteredMarkerLayer(cluster_radius=200) view.add_layer(marker_layer) # create marker if they exists diff --git a/examples/map_browser.py b/examples/map_browser.py index f8e5117..6c4e475 100644 --- a/examples/map_browser.py +++ b/examples/map_browser.py @@ -3,9 +3,11 @@ if __name__ == '__main__' and __package__ is None: from os import sys, path + sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) -root = Builder.load_string(""" +root = Builder.load_string( + """ #:import MapSource kivy_garden.mapview.MapSource : @@ -69,6 +71,7 @@ text: "Longitude: {}".format(mapview.lon) Label: text: "Latitude: {}".format(mapview.lat) - """) + """ +) runTouchApp(root) diff --git a/examples/map_with_marker_popup.py b/examples/map_with_marker_popup.py index 642c87a..e26c906 100644 --- a/examples/map_with_marker_popup.py +++ b/examples/map_with_marker_popup.py @@ -1,13 +1,16 @@ import sys + from kivy.base import runTouchApp from kivy.lang import Builder if __name__ == '__main__' and __package__ is None: from os import path + sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) -root = Builder.load_string(""" +root = Builder.load_string( + """ #:import sys sys #:import MapSource kivy_garden.mapview.MapSource MapView: @@ -33,6 +36,7 @@ markup: True halign: "center" -""") +""" +) runTouchApp(root) diff --git a/examples/simple_geojson.py b/examples/simple_geojson.py index 29b8a9f..b8e3931 100644 --- a/examples/simple_geojson.py +++ b/examples/simple_geojson.py @@ -1,13 +1,10 @@ -from kivy.base import runTouchApp import sys -if __name__ == '__main__' and __package__ is None: - from os import path - sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) +from kivy.base import runTouchApp -from kivy_garden.mapview import MapView, MapMarker +from kivy_garden.mapview import MapMarker, MapView from kivy_garden.mapview.geojson import GeoJsonMapLayer -from kivy_garden.mapview.utils import haversine, get_zoom_for_radius +from kivy_garden.mapview.utils import get_zoom_for_radius, haversine if len(sys.argv) > 1: source = sys.argv[1] diff --git a/examples/simple_map.py b/examples/simple_map.py index 86256ea..3e279be 100644 --- a/examples/simple_map.py +++ b/examples/simple_map.py @@ -1,11 +1,8 @@ import sys -from kivy.base import runTouchApp -if __name__ == '__main__' and __package__ is None: - from os import path - sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) +from kivy.base import runTouchApp -from kivy_garden.mapview import MapView, MapSource +from kivy_garden.mapview import MapSource, MapView kwargs = {} if len(sys.argv) > 1: diff --git a/examples/simple_mbtiles.py b/examples/simple_mbtiles.py index be3e364..d94c8f8 100644 --- a/examples/simple_mbtiles.py +++ b/examples/simple_mbtiles.py @@ -9,18 +9,18 @@ """ import sys + from kivy.base import runTouchApp -if __name__ == '__main__' and __package__ is None: - from os import path - sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) from kivy_garden.mapview import MapView from kivy_garden.mapview.mbtsource import MBTilesMapSource - source = MBTilesMapSource(sys.argv[1]) -runTouchApp(MapView( - map_source=source, - lat=source.default_lat, - lon=source.default_lon, - zoom=source.default_zoom)) +runTouchApp( + MapView( + map_source=source, + lat=source.default_lat, + lon=source.default_lon, + zoom=source.default_zoom, + ) +) diff --git a/examples/test_kdbush.py b/examples/test_kdbush.py index 89b3cc0..ecb66c5 100644 --- a/examples/test_kdbush.py +++ b/examples/test_kdbush.py @@ -5,11 +5,13 @@ the selected red dot. """ +from random import random + from kivy.app import App +from kivy.graphics import Canvas, Color, Ellipse, Rectangle from kivy.uix.widget import Widget -from kivy.graphics import Color, Rectangle, Ellipse, Canvas + from kivy_garden.mapview.clustered_marker_layer import KDBush, Marker -from random import random # creating markers points = [] @@ -36,8 +38,7 @@ def build(self): with self.canvas_points: Color(1, 0, 0) for marker in points: - Rectangle( - pos=(marker.x * 600, marker.y * 600), size=(2, 2)) + Rectangle(pos=(marker.x * 600, marker.y * 600), size=(2, 2)) self.canvas.before.clear() with self.canvas.before: @@ -66,7 +67,7 @@ def on_touch_move(self, touch): def select(self, x, y): self.selection_center = (x, y) - self.selection = kdbush.within(x / 600., y / 600., self.radius) + self.selection = kdbush.within(x / 600.0, y / 600.0, self.radius) self.build() diff --git a/kivy_garden/mapview/__init__.py b/kivy_garden/mapview/__init__.py index 19a6280..fd2301b 100644 --- a/kivy_garden/mapview/__init__.py +++ b/kivy_garden/mapview/__init__.py @@ -7,26 +7,23 @@ MapView is a Kivy widget that display maps. """ - -__all__ = ["Coordinate", "Bbox", "MapView", "MapSource", "MapMarker", - "MapLayer", "MarkerMapLayer", "MapMarkerPopup"] -__version__ = "0.2" - -MIN_LATITUDE = -90. -MAX_LATITUDE = 90. -MIN_LONGITUDE = -180. -MAX_LONGITUDE = 180. -CACHE_DIR = "cache" - -try: - # fix if used within garden - import sys - sys.modules['mapview'] = sys.modules['kivy.garden.mapview.mapview'] - del sys -except KeyError: - pass - -from kivy_garden.mapview.types import Coordinate, Bbox from kivy_garden.mapview.source import MapSource -from kivy_garden.mapview.view import MapView, MapMarker, MapLayer, MarkerMapLayer, \ - MapMarkerPopup +from kivy_garden.mapview.types import Bbox, Coordinate +from kivy_garden.mapview.view import ( + MapLayer, + MapMarker, + MapMarkerPopup, + MapView, + MarkerMapLayer, +) + +__all__ = [ + "Coordinate", + "Bbox", + "MapView", + "MapSource", + "MapMarker", + "MapLayer", + "MarkerMapLayer", + "MapMarkerPopup", +] diff --git a/kivy_garden/mapview/_version.py b/kivy_garden/mapview/_version.py index a6221b3..976498a 100644 --- a/kivy_garden/mapview/_version.py +++ b/kivy_garden/mapview/_version.py @@ -1 +1 @@ -__version__ = '1.0.2' +__version__ = "1.0.3" diff --git a/kivy_garden/mapview/clustered_marker_layer.py b/kivy_garden/mapview/clustered_marker_layer.py index b9c7c2e..c2eaac0 100644 --- a/kivy_garden/mapview/clustered_marker_layer.py +++ b/kivy_garden/mapview/clustered_marker_layer.py @@ -4,15 +4,22 @@ =================================== """ +from math import atan, exp, floor, log, pi, sin, sqrt from os.path import dirname, join -from math import sin, log, pi, atan, exp, floor, sqrt -from kivy_garden.mapview.view import MapLayer, MapMarker + from kivy.lang import Builder from kivy.metrics import dp -from kivy.properties import (ObjectProperty, NumericProperty, StringProperty, ListProperty) +from kivy.properties import ( + ListProperty, + NumericProperty, + ObjectProperty, + StringProperty, +) +from kivy_garden.mapview.view import MapLayer, MapMarker -Builder.load_string(""" +Builder.load_string( + """ : size_hint: None, None source: root.source @@ -25,12 +32,13 @@ size: root.size text: "{}".format(root.num_points) font_size: dp(18) -""") +""" +) # longitude/latitude to spherical mercator in [0..1] range def lngX(lng): - return lng / 360. + 0.5 + return lng / 360.0 + 0.5 def latY(lat): @@ -38,8 +46,8 @@ def latY(lat): return 0 if lat == -90: return 1 - s = sin(lat * pi / 180.) - y = (0.5 - 0.25 * log((1 + s) / (1 - s)) / pi) + s = sin(lat * pi / 180.0) + y = 0.5 - 0.25 * log((1 + s) / (1 - s)) / pi return min(1, max(0, y)) @@ -53,11 +61,13 @@ def yLat(y): return 360 * atan(exp(y2)) / pi - 90 -class KDBush(object): - # kdbush implementation from https://github.com/mourner/kdbush/blob/master/src/kdbush.js - # +class KDBush: + """ + kdbush implementation from: + https://github.com/mourner/kdbush/blob/master/src/kdbush.js + """ + def __init__(self, points, node_size=64): - super(KDBush, self).__init__() self.points = points self.node_size = node_size @@ -71,8 +81,9 @@ def __init__(self, points, node_size=64): self._sort(ids, coords, node_size, 0, len(ids) - 1, 0) def range(self, min_x, min_y, max_x, max_y): - return self._range(self.ids, self.coords, min_x, min_y, max_x, max_y, - self.node_size) + return self._range( + self.ids, self.coords, min_x, min_y, max_x, max_y, self.node_size + ) def within(self, x, y, r): return self._within(self.ids, self.coords, x, y, r, self.node_size) @@ -80,7 +91,7 @@ def within(self, x, y, r): def _sort(self, ids, coords, node_size, left, right, depth): if right - left <= node_size: return - m = int(floor((left + right) / 2.)) + m = int(floor((left + right) / 2.0)) self._select(ids, coords, m, left, right, depth % 2) self._sort(ids, coords, node_size, left, m - 1, depth + 1) self._sort(ids, coords, node_size, m + 1, right, depth + 1) @@ -92,9 +103,8 @@ def _select(self, ids, coords, k, left, right, inc): n = float(right - left + 1) m = k - left + 1 z = log(n) - s = 0.5 + exp(2 * z / 3.) - sd = 0.5 * sqrt(z * s * (n - s) / n) * (-1 - if (m - n / 2.) < 0 else 1) + s = 0.5 + exp(2 * z / 3.0) + sd = 0.5 * sqrt(z * s * (n - s) / n) * (-1 if (m - n / 2.0) < 0 else 1) new_left = max(left, int(floor(k - m * s / n + sd))) new_right = min(right, int(floor(k + (n - m) * s / n + sd))) self._select(ids, coords, k, new_left, new_right, inc) @@ -152,26 +162,25 @@ def _range(self, ids, coords, min_x, min_y, max_x, max_y, node_size): for i in range(left, right + 1): x = coords[2 * i] y = coords[2 * i + 1] - if (x >= min_x and x <= max_x and y >= min_y and - y <= max_y): + if x >= min_x and x <= max_x and y >= min_y and y <= max_y: result.append(ids[i]) continue - m = int(floor((left + right) / 2.)) + m = int(floor((left + right) / 2.0)) x = coords[2 * m] y = coords[2 * m + 1] - if (x >= min_x and x <= max_x and y >= min_y and y <= max_y): + if x >= min_x and x <= max_x and y >= min_y and y <= max_y: result.append(ids[m]) nextAxis = (axis + 1) % 2 - if (min_x <= x if axis == 0 else min_y <= y): + if min_x <= x if axis == 0 else min_y <= y: stack.append(left) stack.append(m - 1) stack.append(nextAxis) - if (max_x >= x if axis == 0 else max_y >= y): + if max_x >= x if axis == 0 else max_y >= y: stack.append(m + 1) stack.append(right) stack.append(nextAxis) @@ -195,7 +204,7 @@ def _within(self, ids, coords, qx, qy, r, node_size): result.append(ids[i]) continue - m = int(floor((left + right) / 2.)) + m = int(floor((left + right) / 2.0)) x = coords[2 * m] y = coords[2 * m + 1] @@ -222,9 +231,8 @@ def _sq_dist(self, ax, ay, bx, by): return dx * dx + dy * dy -class Cluster(object): +class Cluster: def __init__(self, x, y, num_points, id, props): - super(Cluster, self).__init__() self.x = x self.y = y self.num_points = num_points @@ -239,9 +247,8 @@ def __init__(self, x, y, num_points, id, props): self.lat = yLat(y) -class Marker(object): +class Marker: def __init__(self, lon, lat, cls=MapMarker, options=None): - super(Marker, self).__init__() self.lon = lon self.lat = lat self.cls = cls @@ -258,21 +265,16 @@ def __init__(self, lon, lat, cls=MapMarker, options=None): self.widget = None def __repr__(self): - return "".format(self.lon, self.lat, - self.source) + return "".format( + self.lon, self.lat, self.source + ) -class SuperCluster(object): +class SuperCluster: """Port of supercluster from mapbox in pure python """ - def __init__(self, - min_zoom=0, - max_zoom=16, - radius=40, - extent=512, - node_size=64): - super(SuperCluster, self).__init__() + def __init__(self, min_zoom=0, max_zoom=16, radius=40, extent=512, node_size=64): self.min_zoom = min_zoom self.max_zoom = max_zoom self.radius = radius @@ -284,6 +286,7 @@ def load(self, points): Once loaded, the index is immutable. """ from time import time + self.trees = {} self.points = points @@ -365,7 +368,9 @@ def _cluster(self, points, zoom): c_append(p) else: p.parent_id = i - c_append(Cluster(wx / num_points, wy / num_points, num_points, i, props)) + c_append( + Cluster(wx / num_points, wy / num_points, num_points, i, props) + ) return clusters @@ -373,7 +378,7 @@ class ClusterMapMarker(MapMarker): source = StringProperty(join(dirname(__file__), "icons", "cluster.png")) cluster = ObjectProperty() num_points = NumericProperty() - text_color = ListProperty([.1, .1, .1, 1]) + text_color = ListProperty([0.1, 0.1, 0.1, 1]) def on_cluster(self, instance, cluster): self.num_points = cluster.num_points @@ -393,7 +398,7 @@ class ClusteredMarkerLayer(MapLayer): def __init__(self, **kwargs): self.cluster = None self.cluster_markers = [] - super(ClusteredMarkerLayer, self).__init__(**kwargs) + super().__init__(**kwargs) def add_marker(self, lon, lat, cls=MapMarker, options=None): if options is None: @@ -427,7 +432,7 @@ def build_cluster(self): max_zoom=self.cluster_max_zoom, radius=self.cluster_radius, extent=self.cluster_extent, - node_size=self.cluster_node_size + node_size=self.cluster_node_size, ) self.cluster.load(self.cluster_markers) diff --git a/kivy_garden/mapview/constants.py b/kivy_garden/mapview/constants.py new file mode 100644 index 0000000..b6998f8 --- /dev/null +++ b/kivy_garden/mapview/constants.py @@ -0,0 +1,5 @@ +MIN_LATITUDE = -90.0 +MAX_LATITUDE = 90.0 +MIN_LONGITUDE = -180.0 +MAX_LONGITUDE = 180.0 +CACHE_DIR = "cache" diff --git a/kivy_garden/mapview/downloader.py b/kivy_garden/mapview/downloader.py index e5f887e..2706ad8 100644 --- a/kivy_garden/mapview/downloader.py +++ b/kivy_garden/mapview/downloader.py @@ -2,30 +2,35 @@ __all__ = ["Downloader"] -from kivy.clock import Clock -from os.path import join, exists -from os import makedirs, environ +import logging +import traceback from concurrent.futures import ThreadPoolExecutor, TimeoutError, as_completed +from os import environ, makedirs +from os.path import exists, join from random import choice -import requests -import traceback from time import time -from kivy_garden.mapview import CACHE_DIR +import requests +from kivy.clock import Clock +from kivy.logger import LOG_LEVELS, Logger + +from kivy_garden.mapview.constants import CACHE_DIR + +if "MAPVIEW_DEBUG_DOWNLOADER" in environ: + Logger.setLevel(LOG_LEVELS['debug']) -DEBUG = "MAPVIEW_DEBUG_DOWNLOADER" in environ # user agent is needed because since may 2019 OSM gives me a 429 or 403 server error # I tried it with a simpler one (just Mozilla/5.0) this also gets rejected USER_AGENT = 'Kivy-garden.mapview' -class Downloader(object): +class Downloader: _instance = None MAX_WORKERS = 5 CAP_TIME = 0.064 # 15 FPS @staticmethod - def instance(cache_dir): + def instance(cache_dir=None): if Downloader._instance is None: if not cache_dir: cache_dir = CACHE_DIR @@ -38,12 +43,11 @@ def __init__(self, max_workers=None, cap_time=None, **kwargs): max_workers = Downloader.MAX_WORKERS if cap_time is None: cap_time = Downloader.CAP_TIME - super(Downloader, self).__init__() self.is_paused = False self.cap_time = cap_time self.executor = ThreadPoolExecutor(max_workers=max_workers) self._futures = [] - Clock.schedule_interval(self._check_executor, 1 / 60.) + Clock.schedule_interval(self._check_executor, 1 / 60.0) if not exists(self.cache_dir): makedirs(self.cache_dir) @@ -52,47 +56,45 @@ def submit(self, f, *args, **kwargs): self._futures.append(future) def download_tile(self, tile): - if DEBUG: - print("Downloader: queue(tile) zoom={} x={} y={}".format( - tile.zoom, tile.tile_x, tile.tile_y)) + Logger.debug( + "Downloader: queue(tile) zoom={} x={} y={}".format( + tile.zoom, tile.tile_x, tile.tile_y + ) + ) future = self.executor.submit(self._load_tile, tile) self._futures.append(future) def download(self, url, callback, **kwargs): - if DEBUG: - print("Downloader: queue(url) {}".format(url)) - future = self.executor.submit( - self._download_url, url, callback, kwargs) + Logger.debug("Downloader: queue(url) {}".format(url)) + future = self.executor.submit(self._download_url, url, callback, kwargs) self._futures.append(future) def _download_url(self, url, callback, kwargs): - if DEBUG: - print("Downloader: download(url) {}".format(url)) - r = requests.get(url, **kwargs) - return callback, (url, r, ) + Logger.debug("Downloader: download(url) {}".format(url)) + response = requests.get(url, **kwargs) + response.raise_for_status() + return callback, (url, response) def _load_tile(self, tile): if tile.state == "done": return cache_fn = tile.cache_fn if exists(cache_fn): - if DEBUG: - print("Downloader: use cache {}".format(cache_fn)) - return tile.set_source, (cache_fn, ) + Logger.debug("Downloader: use cache {}".format(cache_fn)) + return tile.set_source, (cache_fn,) tile_y = tile.map_source.get_row_count(tile.zoom) - tile.tile_y - 1 - uri = tile.map_source.url.format(z=tile.zoom, x=tile.tile_x, y=tile_y, - s=choice(tile.map_source.subdomains)) - if DEBUG: - print("Downloader: download(tile) {}".format(uri)) - req = requests.get(uri, headers={'User-agent': USER_AGENT}, timeout=5) + uri = tile.map_source.url.format( + z=tile.zoom, x=tile.tile_x, y=tile_y, s=choice(tile.map_source.subdomains) + ) + Logger.debug("Downloader: download(tile) {}".format(uri)) + response = requests.get(uri, headers={'User-agent': USER_AGENT}, timeout=5) try: - req.raise_for_status() - data = req.content + response.raise_for_status() + data = response.content with open(cache_fn, "wb") as fd: fd.write(data) - if DEBUG: - print("Downloaded {} bytes: {}".format(len(data), uri)) - return tile.set_source, (cache_fn, ) + Logger.debug("Downloaded {} bytes: {}".format(len(data), uri)) + return tile.set_source, (cache_fn,) except Exception as e: print("Downloader error: {!r}".format(e)) diff --git a/kivy_garden/mapview/geojson.py b/kivy_garden/mapview/geojson.py index 7c0fccc..5fafe62 100644 --- a/kivy_garden/mapview/geojson.py +++ b/kivy_garden/mapview/geojson.py @@ -20,16 +20,26 @@ __all__ = ["GeoJsonMapLayer"] import json -from kivy.properties import StringProperty, ObjectProperty -from kivy.graphics import (Canvas, PushMatrix, PopMatrix, MatrixInstruction, - Translate, Scale) -from kivy.graphics import Mesh, Line, Color -from kivy.graphics.tesselator import Tesselator, WINDING_ODD, TYPE_POLYGONS -from kivy.utils import get_color_from_hex + +from kivy.graphics import ( + Canvas, + Color, + Line, + MatrixInstruction, + Mesh, + PopMatrix, + PushMatrix, + Scale, + Translate, +) +from kivy.graphics.tesselator import TYPE_POLYGONS, WINDING_ODD, Tesselator from kivy.metrics import dp -from kivy_garden.mapview import CACHE_DIR -from kivy_garden.mapview.view import MapLayer +from kivy.properties import ObjectProperty, StringProperty +from kivy.utils import get_color_from_hex + +from kivy_garden.mapview.constants import CACHE_DIR from kivy_garden.mapview.downloader import Downloader +from kivy_garden.mapview.view import MapLayer COLORS = { 'aliceblue': '#f0f8ff', @@ -178,7 +188,7 @@ 'white': '#ffffff', 'whitesmoke': '#f5f5f5', 'yellow': '#ffff00', - 'yellowgreen': '#9acd32' + 'yellowgreen': '#9acd32', } @@ -195,7 +205,7 @@ class GeoJsonMapLayer(MapLayer): def __init__(self, **kwargs): self.first_time = True self.initial_zoom = None - super(GeoJsonMapLayer, self).__init__(**kwargs) + super().__init__(**kwargs) with self.canvas: self.canvas_polygon = Canvas() self.canvas_line = Canvas() @@ -216,12 +226,12 @@ def reposition(self): if zoom is None: self.initial_zoom = zoom = pzoom if zoom != pzoom: - diff = 2**(pzoom - zoom) + diff = 2 ** (pzoom - zoom) vx /= diff vy /= diff self.g_scale.x = self.g_scale.y = diff else: - self.g_scale.x = self.g_scale.y = 1. + self.g_scale.x = self.g_scale.y = 1.0 self.g_translate.xy = vx, vy self.g_matrix.matrix = self.parent._scatter.transform @@ -269,39 +279,38 @@ def _get_bounds(feature): for polygon in geometry["coordinates"]: for coordinate in polygon[0]: _submit_coordinate(coordinate) + self.traverse_feature(_get_bounds) return bounds @property def center(self): min_lon, max_lon, min_lat, max_lat = self.bounds - cx = (max_lon - min_lon) / 2. - cy = (max_lat - min_lat) / 2. + cx = (max_lon - min_lon) / 2.0 + cy = (max_lat - min_lat) / 2.0 return min_lon + cx, min_lat + cy def on_geojson(self, instance, geojson, update=False): if self.parent is None: return if not update: - # print "Reload geojson (polygon)" self.g_canvas_polygon.clear() self._geojson_part(geojson, geotype="Polygon") - # print "Reload geojson (LineString)" self.canvas_line.clear() self._geojson_part(geojson, geotype="LineString") def on_source(self, instance, value): - if value.startswith("http://") or value.startswith("https://"): - Downloader.instance( - cache_dir=self.cache_dir - ).download(value, self._load_geojson_url) + if value.startswith(("http://", "https://")): + Downloader.instance(cache_dir=self.cache_dir).download( + value, self._load_geojson_url + ) else: with open(value, "rb") as fd: geojson = json.load(fd) self.geojson = geojson - def _load_geojson_url(self, url, r): - self.geojson = r.json() + def _load_geojson_url(self, url, response): + self.geojson = response.json() def _geojson_part(self, part, geotype=None): tp = part["type"] @@ -344,10 +353,8 @@ def _geojson_part_geometry(self, geometry, properties): graphics.append(Color(*color)) for vertices, indices in tess.meshes: graphics.append( - Mesh( - vertices=vertices, - indices=indices, - mode="triangle_fan")) + Mesh(vertices=vertices, indices=indices, mode="triangle_fan") + ) elif tp == "LineString": stroke = get_color_from_hex(properties.get("stroke", "#ffffff")) diff --git a/kivy_garden/mapview/mbtsource.py b/kivy_garden/mapview/mbtsource.py index 4d8eabb..e90f87a 100644 --- a/kivy_garden/mapview/mbtsource.py +++ b/kivy_garden/mapview/mbtsource.py @@ -10,17 +10,20 @@ __all__ = ["MBTilesMapSource"] -from kivy_garden.mapview.source import MapSource -from kivy_garden.mapview.downloader import Downloader -from kivy.core.image import Image as CoreImage, ImageLoader -import threading -import sqlite3 import io +import sqlite3 +import threading + +from kivy.core.image import Image as CoreImage +from kivy.core.image import ImageLoader + +from kivy_garden.mapview.downloader import Downloader +from kivy_garden.mapview.source import MapSource class MBTilesMapSource(MapSource): def __init__(self, filename, **kwargs): - super(MBTilesMapSource, self).__init__(**kwargs) + super().__init__(**kwargs) self.filename = filename self.db = sqlite3.connect(filename) @@ -33,21 +36,21 @@ def __init__(self, filename, **kwargs): self.max_zoom = int(metadata["maxzoom"]) self.attribution = metadata.get("attribution", "") self.bounds = bounds = None - cx = cy = 0. + cx = cy = 0.0 cz = 5 if "bounds" in metadata: self.bounds = bounds = map(float, metadata["bounds"].split(",")) if "center" in metadata: cx, cy, cz = map(float, metadata["center"].split(",")) elif self.bounds: - cx = (bounds[2] + bounds[0]) / 2. - cy = (bounds[3] + bounds[1]) / 2. + cx = (bounds[2] + bounds[0]) / 2.0 + cy = (bounds[3] + bounds[1]) / 2.0 cz = self.min_zoom self.default_lon = cx self.default_lat = cy self.default_zoom = int(cz) self.projection = metadata.get("projection", "") - self.is_xy = (self.projection == "xy") + self.is_xy = self.projection == "xy" def fill_tile(self, tile): if tile.state == "done": @@ -63,10 +66,12 @@ def _load_tile(self, tile): # get the right tile c = ctx.db.cursor() c.execute( - ("SELECT tile_data FROM tiles WHERE " - "zoom_level=? AND tile_column=? AND tile_row=?"), - (tile.zoom, tile.tile_x, tile.tile_y)) - # print "fetch", tile.zoom, tile.tile_x, tile.tile_y + ( + "SELECT tile_data FROM tiles WHERE " + "zoom_level=? AND tile_column=? AND tile_row=?" + ), + (tile.zoom, tile.tile_x, tile.tile_y), + ) row = c.fetchone() if not row: tile.state = "done" @@ -79,15 +84,17 @@ def _load_tile(self, tile): # android issue, "buffer" does not have the buffer interface # ie row[0] buffer is not compatible with BytesIO on Android?? data = io.BytesIO(bytes(row[0])) - im = CoreImage(data, ext='png', - filename="{}.{}.{}.png".format(tile.zoom, tile.tile_x, - tile.tile_y)) + im = CoreImage( + data, + ext='png', + filename="{}.{}.{}.png".format(tile.zoom, tile.tile_x, tile.tile_y), + ) if im is None: tile.state = "done" return - return self._load_tile_done, (tile, im, ) + return self._load_tile_done, (tile, im,) def _load_tile_done(self, tile, im): tile.texture = im.texture @@ -96,19 +103,19 @@ def _load_tile_done(self, tile, im): def get_x(self, zoom, lon): if self.is_xy: return lon - return super(MBTilesMapSource, self).get_x(zoom, lon) + return super().get_x(zoom, lon) def get_y(self, zoom, lat): if self.is_xy: return lat - return super(MBTilesMapSource, self).get_y(zoom, lat) + return super().get_y(zoom, lat) def get_lon(self, zoom, x): if self.is_xy: return x - return super(MBTilesMapSource, self).get_lon(zoom, x) + return super().get_lon(zoom, x) def get_lat(self, zoom, y): if self.is_xy: return y - return super(MBTilesMapSource, self).get_lat(zoom, y) + return super().get_lat(zoom, y) diff --git a/kivy_garden/mapview/source.py b/kivy_garden/mapview/source.py index 7e6c346..61722fd 100644 --- a/kivy_garden/mapview/source.py +++ b/kivy_garden/mapview/source.py @@ -2,16 +2,23 @@ __all__ = ["MapSource"] +import hashlib +from math import atan, ceil, cos, exp, log, pi, tan + from kivy.metrics import dp -from math import cos, ceil, log, tan, pi, atan, exp -from kivy_garden.mapview import MIN_LONGITUDE, MAX_LONGITUDE, MIN_LATITUDE, MAX_LATITUDE, \ - CACHE_DIR + +from kivy_garden.mapview.constants import ( + CACHE_DIR, + MAX_LATITUDE, + MAX_LONGITUDE, + MIN_LATITUDE, + MIN_LONGITUDE, +) from kivy_garden.mapview.downloader import Downloader from kivy_garden.mapview.utils import clamp -import hashlib -class MapSource(object): +class MapSource: """Base class for implementing a map source / provider """ @@ -21,34 +28,91 @@ class MapSource(object): # list of available providers # cache_key: (is_overlay, minzoom, maxzoom, url, attribution) providers = { - "osm": (0, 0, 19, "http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", attribution_osm), - "osm-hot": (0, 0, 19, "http://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png", ""), - "osm-de": (0, 0, 18, "http://{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png", "Tiles @ OSM DE"), - "osm-fr": (0, 0, 20, "http://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png", "Tiles @ OSM France"), - "cyclemap": (0, 0, 17, "http://{s}.tile.opencyclemap.org/cycle/{z}/{x}/{y}.png", "Tiles @ Andy Allan"), - "thunderforest-cycle": (0, 0, 19, "http://{s}.tile.thunderforest.com/cycle/{z}/{x}/{y}.png", attribution_thunderforest), - "thunderforest-transport": (0, 0, 19, "http://{s}.tile.thunderforest.com/transport/{z}/{x}/{y}.png", attribution_thunderforest), - "thunderforest-landscape": (0, 0, 19, "http://{s}.tile.thunderforest.com/landscape/{z}/{x}/{y}.png", attribution_thunderforest), - "thunderforest-outdoors": (0, 0, 19, "http://{s}.tile.thunderforest.com/outdoors/{z}/{x}/{y}.png", attribution_thunderforest), - + "osm": ( + 0, + 0, + 19, + "http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + attribution_osm, + ), + "osm-hot": ( + 0, + 0, + 19, + "http://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png", + "", + ), + "osm-de": ( + 0, + 0, + 18, + "http://{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png", + "Tiles @ OSM DE", + ), + "osm-fr": ( + 0, + 0, + 20, + "http://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png", + "Tiles @ OSM France", + ), + "cyclemap": ( + 0, + 0, + 17, + "http://{s}.tile.opencyclemap.org/cycle/{z}/{x}/{y}.png", + "Tiles @ Andy Allan", + ), + "thunderforest-cycle": ( + 0, + 0, + 19, + "http://{s}.tile.thunderforest.com/cycle/{z}/{x}/{y}.png", + attribution_thunderforest, + ), + "thunderforest-transport": ( + 0, + 0, + 19, + "http://{s}.tile.thunderforest.com/transport/{z}/{x}/{y}.png", + attribution_thunderforest, + ), + "thunderforest-landscape": ( + 0, + 0, + 19, + "http://{s}.tile.thunderforest.com/landscape/{z}/{x}/{y}.png", + attribution_thunderforest, + ), + "thunderforest-outdoors": ( + 0, + 0, + 19, + "http://{s}.tile.thunderforest.com/outdoors/{z}/{x}/{y}.png", + attribution_thunderforest, + ), # no longer available - #"mapquest-osm": (0, 0, 19, "http://otile{s}.mqcdn.com/tiles/1.0.0/map/{z}/{x}/{y}.jpeg", "Tiles Courtesy of Mapquest", {"subdomains": "1234", "image_ext": "jpeg"}), - #"mapquest-aerial": (0, 0, 19, "http://oatile{s}.mqcdn.com/tiles/1.0.0/sat/{z}/{x}/{y}.jpeg", "Tiles Courtesy of Mapquest", {"subdomains": "1234", "image_ext": "jpeg"}), - + # "mapquest-osm": (0, 0, 19, "http://otile{s}.mqcdn.com/tiles/1.0.0/map/{z}/{x}/{y}.jpeg", "Tiles Courtesy of Mapquest", {"subdomains": "1234", "image_ext": "jpeg"}), + # "mapquest-aerial": (0, 0, 19, "http://oatile{s}.mqcdn.com/tiles/1.0.0/sat/{z}/{x}/{y}.jpeg", "Tiles Courtesy of Mapquest", {"subdomains": "1234", "image_ext": "jpeg"}), # more to add with # https://github.com/leaflet-extras/leaflet-providers/blob/master/leaflet-providers.js # not working ? - #"openseamap": (0, 0, 19, "http://tiles.openseamap.org/seamark/{z}/{x}/{y}.png", + # "openseamap": (0, 0, 19, "http://tiles.openseamap.org/seamark/{z}/{x}/{y}.png", # "Map data @ OpenSeaMap contributors"), } - def __init__(self, - url="http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - cache_key=None, min_zoom=0, max_zoom=19, tile_size=256, - image_ext="png", - attribution="© OpenStreetMap contributors", - subdomains="abc", **kwargs): - super(MapSource, self).__init__() + def __init__( + self, + url="http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + cache_key=None, + min_zoom=0, + max_zoom=19, + tile_size=256, + image_ext="png", + attribution="© OpenStreetMap contributors", + subdomains="abc", + **kwargs + ): if cache_key is None: # possible cache hit, but very unlikely cache_key = hashlib.sha224(url.encode("utf8")).hexdigest()[:10] @@ -74,39 +138,46 @@ def from_provider(key, **kwargs): is_overlay, min_zoom, max_zoom, url, attribution = provider[:5] if len(provider) > 5: options = provider[5] - return MapSource(cache_key=key, min_zoom=min_zoom, - max_zoom=max_zoom, url=url, cache_dir=cache_dir, - attribution=attribution, **options) + return MapSource( + cache_key=key, + min_zoom=min_zoom, + max_zoom=max_zoom, + url=url, + cache_dir=cache_dir, + attribution=attribution, + **options + ) def get_x(self, zoom, lon): """Get the x position on the map using this map source's projection (0, 0) is located at the top left. """ lon = clamp(lon, MIN_LONGITUDE, MAX_LONGITUDE) - return ((lon + 180.) / 360. * pow(2., zoom)) * self.dp_tile_size + return ((lon + 180.0) / 360.0 * pow(2.0, zoom)) * self.dp_tile_size def get_y(self, zoom, lat): """Get the y position on the map using this map source's projection (0, 0) is located at the top left. """ lat = clamp(-lat, MIN_LATITUDE, MAX_LATITUDE) - lat = lat * pi / 180. - return ((1.0 - log(tan(lat) + 1.0 / cos(lat)) / pi) / - 2. * pow(2., zoom)) * self.dp_tile_size + lat = lat * pi / 180.0 + return ( + (1.0 - log(tan(lat) + 1.0 / cos(lat)) / pi) / 2.0 * pow(2.0, zoom) + ) * self.dp_tile_size def get_lon(self, zoom, x): """Get the longitude to the x position in the map source's projection """ dx = x / float(self.dp_tile_size) - lon = dx / pow(2., zoom) * 360. - 180. + lon = dx / pow(2.0, zoom) * 360.0 - 180.0 return clamp(lon, MIN_LONGITUDE, MAX_LONGITUDE) def get_lat(self, zoom, y): """Get the latitude to the y position in the map source's projection """ dy = y / float(self.dp_tile_size) - n = pi - 2 * pi * dy / pow(2., zoom) - lat = -180. / pi * atan(.5 * (exp(n) - exp(-n))) + n = pi - 2 * pi * dy / pow(2.0, zoom) + lat = -180.0 / pi * atan(0.5 * (exp(n) - exp(-n))) return clamp(lat, MIN_LATITUDE, MAX_LATITUDE) def get_row_count(self, zoom): diff --git a/kivy_garden/mapview/utils.py b/kivy_garden/mapview/utils.py index 775ed23..1ecc84f 100644 --- a/kivy_garden/mapview/utils.py +++ b/kivy_garden/mapview/utils.py @@ -2,7 +2,7 @@ __all__ = ["clamp", "haversine", "get_zoom_for_radius"] -from math import radians, cos, sin, asin, sqrt, pi +from math import asin, cos, pi, radians, sin, sqrt from kivy.core.window import Window from kivy.metrics import dp @@ -24,21 +24,21 @@ def haversine(lon1, lat1, lon2, lat2): # haversine formula dlon = lon2 - lon1 dlat = lat2 - lat1 - a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2 + a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2 c = 2 * asin(sqrt(a)) km = 6367 * c return km -def get_zoom_for_radius(radius_km, lat=None, tile_size=256.): +def get_zoom_for_radius(radius_km, lat=None, tile_size=256.0): """See: https://wiki.openstreetmap.org/wiki/Zoom_levels""" - radius = radius_km * 1000. + radius = radius_km * 1000.0 if lat is None: - lat = 0. # Do not compensate for the latitude + lat = 0.0 # Do not compensate for the latitude # Calculate the equatorial circumference based on the WGS-84 radius - earth_circumference = 2. * pi * 6378137. * cos(lat * pi / 180.) + earth_circumference = 2.0 * pi * 6378137.0 * cos(lat * pi / 180.0) # Check how many tiles that are currently in view nr_tiles_shown = min(Window.size) / dp(tile_size) diff --git a/kivy_garden/mapview/view.py b/kivy_garden/mapview/view.py index b5b3d5c..48b5457 100644 --- a/kivy_garden/mapview/view.py +++ b/kivy_garden/mapview/view.py @@ -1,32 +1,45 @@ # coding=utf-8 -__all__ = ["MapView", "MapMarker", "MapMarkerPopup", "MapLayer", - "MarkerMapLayer"] +__all__ = ["MapView", "MapMarker", "MapMarkerPopup", "MapLayer", "MarkerMapLayer"] + +import webbrowser +from itertools import takewhile +from math import ceil +from os.path import dirname, join -from os.path import join, dirname from kivy.clock import Clock -from kivy.metrics import dp -from kivy.uix.widget import Widget -from kivy.uix.label import Label -from kivy.uix.image import Image -from kivy.uix.scatter import Scatter -from kivy.uix.behaviors import ButtonBehavior -from kivy.properties import NumericProperty, ObjectProperty, ListProperty, \ - AliasProperty, BooleanProperty, StringProperty +from kivy.compat import string_types from kivy.graphics import Canvas, Color, Rectangle from kivy.graphics.transformation import Matrix from kivy.lang import Builder -from kivy.compat import string_types -from math import ceil -from kivy_garden.mapview import MIN_LONGITUDE, MAX_LONGITUDE, MIN_LATITUDE, MAX_LATITUDE, \ - CACHE_DIR, Coordinate, Bbox +from kivy.metrics import dp +from kivy.properties import ( + AliasProperty, + BooleanProperty, + ListProperty, + NumericProperty, + ObjectProperty, + StringProperty, +) +from kivy.uix.behaviors import ButtonBehavior +from kivy.uix.image import Image +from kivy.uix.label import Label +from kivy.uix.scatter import Scatter +from kivy.uix.widget import Widget + +from kivy_garden.mapview import Bbox, Coordinate +from kivy_garden.mapview.constants import ( + CACHE_DIR, + MAX_LATITUDE, + MAX_LONGITUDE, + MIN_LATITUDE, + MIN_LONGITUDE, +) from kivy_garden.mapview.source import MapSource from kivy_garden.mapview.utils import clamp -from itertools import takewhile - -import webbrowser -Builder.load_string(""" +Builder.load_string( + """ : size_hint: None, None source: root.source @@ -81,7 +94,8 @@ center_x: root.center_x size: root.popup_size -""") +""" +) class ClickableLabel(Label): @@ -91,7 +105,7 @@ def on_ref_press(self, *args): class Tile(Rectangle): def __init__(self, *args, **kwargs): - super(Tile, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.cache_dir = kwargs.get('cache_dir', CACHE_DIR) @property @@ -100,7 +114,8 @@ def cache_fn(self): fn = map_source.cache_fmt.format( image_ext=map_source.image_ext, cache_key=map_source.cache_key, - **self.__dict__) + **self.__dict__ + ) return join(self.cache_dir, fn) def set_source(self, cache_fn): @@ -152,7 +167,7 @@ def add_widget(self, widget): if not self.placeholder: self.placeholder = widget if self.is_open: - super(MapMarkerPopup, self).add_widget(self.placeholder) + super().add_widget(self.placeholder) else: self.placeholder.add_widget(widget) @@ -160,7 +175,7 @@ def remove_widget(self, widget): if widget is not self.placeholder: self.placeholder.remove_widget(widget) else: - super(MapMarkerPopup, self).remove_widget(widget) + super().remove_widget(widget) def on_is_open(self, *args): self.refresh_open_status() @@ -170,15 +185,16 @@ def on_release(self, *args): def refresh_open_status(self): if not self.is_open and self.placeholder.parent: - super(MapMarkerPopup, self).remove_widget(self.placeholder) + super().remove_widget(self.placeholder) elif self.is_open and not self.placeholder.parent: - super(MapMarkerPopup, self).add_widget(self.placeholder) + super().add_widget(self.placeholder) class MapLayer(Widget): """A map layer, that is repositionned everytime the :class:`MapView` is moved. """ + viewport_x = NumericProperty(0) viewport_y = NumericProperty(0) @@ -197,22 +213,22 @@ def unload(self): class MarkerMapLayer(MapLayer): """A map layer for :class:`MapMarker` """ + order_marker_by_latitude = BooleanProperty(True) def __init__(self, **kwargs): self.markers = [] - super(MarkerMapLayer, self).__init__(**kwargs) + super().__init__(**kwargs) def insert_marker(self, marker, **kwargs): if self.order_marker_by_latitude: - before = list(takewhile( - lambda i_m: i_m[1].lat < marker.lat, - enumerate(self.children) - )) + before = list( + takewhile(lambda i_m: i_m[1].lat < marker.lat, enumerate(self.children)) + ) if before: kwargs['index'] = before[-1][0] + 1 - super(MarkerMapLayer, self).add_widget(marker, **kwargs) + super().add_widget(marker, **kwargs) def add_widget(self, marker): marker._layer = self @@ -223,7 +239,7 @@ def remove_widget(self, marker): marker._layer = None if marker in self.markers: self.markers.remove(marker) - super(MarkerMapLayer, self).remove_widget(marker) + super().remove_widget(marker) def reposition(self): if not self.markers: @@ -241,7 +257,7 @@ def reposition(self): if not marker.parent: self.insert_marker(marker) else: - super(MarkerMapLayer, self).remove_widget(marker) + super().remove_widget(marker) def set_marker_position(self, mapview, marker): x, y = mapview.get_window_xy_from(marker.lat, marker.lon, mapview.zoom) @@ -256,11 +272,10 @@ def unload(self): class MapViewScatter(Scatter): # internal def on_transform(self, *args): - super(MapViewScatter, self).on_transform(*args) + super().on_transform(*args) self.parent.on_transform(self.transform) def collide_point(self, x, y): - # print "collide_point", x, y return True @@ -309,11 +324,11 @@ class MapView(Widget): delta_x = NumericProperty(0) delta_y = NumericProperty(0) - background_color = ListProperty([181 / 255., 208 / 255., 208 / 255., 1]) + background_color = ListProperty([181 / 255.0, 208 / 255.0, 208 / 255.0, 1]) cache_dir = StringProperty(CACHE_DIR) _zoom = NumericProperty(0) _pause = BooleanProperty(False) - _scale = 1. + _scale = 1.0 _disabled_count = 0 __events__ = ["on_map_relocated"] @@ -337,8 +352,7 @@ def get_bbox(self, margin=0): top/right (lat2, lon2). """ x1, y1 = self.to_local(0 - margin, 0 - margin) - x2, y2 = self.to_local((self.width + margin), - (self.height + margin)) + x2, y2 = self.to_local((self.width + margin), (self.height + margin)) c1 = self.get_latlon_at(x1, y1) c2 = self.get_latlon_at(x2, y2) return Bbox((c1.lat, c1.lon, c2.lat, c2.lon)) @@ -395,23 +409,25 @@ def set_zoom_at(self, zoom, x, y, scale=None): """Sets the zoom level, leaving the (x, y) at the exact same point in the view. """ - zoom = clamp(zoom, - self.map_source.get_min_zoom(), - self.map_source.get_max_zoom()) + zoom = clamp( + zoom, self.map_source.get_min_zoom(), self.map_source.get_max_zoom() + ) if int(zoom) == int(self._zoom): if scale is None: return elif scale == self.scale: return - scale = scale or 1. + scale = scale or 1.0 # first, rescale the scatter scatter = self._scatter scale = clamp(scale, scatter.scale_min, scatter.scale_max) rescale = scale * 1.0 / scatter.scale - scatter.apply_transform(Matrix().scale(rescale, rescale, rescale), - post_multiply=True, - anchor=scatter.to_local(x, y)) + scatter.apply_transform( + Matrix().scale(rescale, rescale, rescale), + post_multiply=True, + anchor=scatter.to_local(x, y), + ) # adjust position if the zoom changed c1 = self.map_source.get_col_count(self._zoom) @@ -421,9 +437,9 @@ def set_zoom_at(self, zoom, x, y, scale=None): self.delta_x = scatter.x + self.delta_x * f self.delta_y = scatter.y + self.delta_y * f # back to 0 every time - scatter.apply_transform(Matrix().translate( - -scatter.x, -scatter.y, 0 - ), post_multiply=True) + scatter.apply_transform( + Matrix().translate(-scatter.x, -scatter.y, 0), post_multiply=True + ) # avoid triggering zoom changes. self._zoom = zoom @@ -447,7 +463,8 @@ def get_latlon_at(self, x, y, zoom=None): scale = self._scale return Coordinate( lat=self.map_source.get_lat(zoom, y / scale + vy), - lon=self.map_source.get_lon(zoom, x / scale + vx)) + lon=self.map_source.get_lon(zoom, x / scale + vx), + ) def add_marker(self, marker, layer=None): """Add a marker into the layer. If layer is None, it will be added in @@ -477,9 +494,8 @@ def add_layer(self, layer, mode="window"): widget yourself: think as Z-sprite / billboard. Defaults to "window". """ - assert (mode in ("scatter", "window")) - if self._default_marker_layer is None and \ - isinstance(layer, MarkerMapLayer): + assert mode in ("scatter", "window") + if self._default_marker_layer is None and isinstance(layer, MarkerMapLayer): self._default_marker_layer = layer self._layers.append(layer) c = self.canvas @@ -488,7 +504,7 @@ def add_layer(self, layer, mode="window"): else: self.canvas = self.canvas_layers_out layer.canvas_parent = self.canvas - super(MapView, self).add_widget(layer) + super().add_widget(layer) self.canvas = c def remove_layer(self, layer): @@ -497,7 +513,7 @@ def remove_layer(self, layer): c = self.canvas self._layers.remove(layer) self.canvas = layer.canvas_parent - super(MapView, self).remove_widget(layer) + super().remove_widget(layer) self.canvas = c def sync_to(self, other): @@ -511,6 +527,7 @@ def sync_to(self, other): def __init__(self, **kwargs): from kivy.base import EventLoop + EventLoop.ensure_window() self._invalid_scale = True self._tiles = [] @@ -530,13 +547,13 @@ def __init__(self, **kwargs): with self.canvas: self.canvas_layers_out = Canvas() self._scale_target_anim = False - self._scale_target = 1. + self._scale_target = 1.0 self._touch_count = 0 self.map_source.cache_dir = self.cache_dir - Clock.schedule_interval(self._animate_color, 1 / 60.) + Clock.schedule_interval(self._animate_color, 1 / 60.0) self.lat = kwargs.get("lat", self.lat) self.lon = kwargs.get("lon", self.lon) - super(MapView, self).__init__(**kwargs) + super().__init__(**kwargs) def _animate_color(self, dt): # fast path @@ -544,14 +561,14 @@ def _animate_color(self, dt): if d == 0: for tile in self._tiles: if tile.state == "need-animation": - tile.g_color.a = 1. + tile.g_color.a = 1.0 tile.state = "animated" for tile in self._tiles_bg: if tile.state == "need-animation": - tile.g_color.a = 1. + tile.g_color.a = 1.0 tile.state = "animated" else: - d = d / 1000. + d = d / 1000.0 for tile in self._tiles: if tile.state != "need-animation": continue @@ -571,7 +588,7 @@ def add_widget(self, widget): elif isinstance(widget, MapLayer): self.add_layer(widget) else: - super(MapView, self).add_widget(widget) + super().add_widget(widget) def remove_widget(self, widget): if isinstance(widget, MapMarker): @@ -579,13 +596,13 @@ def remove_widget(self, widget): elif isinstance(widget, MapLayer): self.remove_layer(widget) else: - super(MapView, self).remove_widget(widget) + super().remove_widget(widget) def on_map_relocated(self, zoom, coord): pass def animated_diff_scale_at(self, d, x, y): - self._scale_target_time = 1. + self._scale_target_time = 1.0 self._scale_target_pos = x, y if self._scale_target_anim is False: self._scale_target_anim = True @@ -593,10 +610,10 @@ def animated_diff_scale_at(self, d, x, y): else: self._scale_target += d Clock.unschedule(self._animate_scale) - Clock.schedule_interval(self._animate_scale, 1 / 60.) + Clock.schedule_interval(self._animate_scale, 1 / 60.0) def _animate_scale(self, dt): - diff = self._scale_target / 3. + diff = self._scale_target / 3.0 if abs(diff) < 0.01: diff = self._scale_target self._scale_target = 0 @@ -618,17 +635,18 @@ def scale_at(self, scale, x, y): scatter = self._scatter scale = clamp(scale, scatter.scale_min, scatter.scale_max) rescale = scale * 1.0 / scatter.scale - scatter.apply_transform(Matrix().scale(rescale, rescale, rescale), - post_multiply=True, - anchor=scatter.to_local(x, y)) + scatter.apply_transform( + Matrix().scale(rescale, rescale, rescale), + post_multiply=True, + anchor=scatter.to_local(x, y), + ) def on_touch_down(self, touch): if not self.collide_point(*touch.pos): return if self.pause_on_action: self._pause = True - if "button" in touch.profile and touch.button in ( - "scrolldown", "scrollup"): + if "button" in touch.profile and touch.button in ("scrolldown", "scrollup"): d = 1 if touch.button == "scrollup" else -1 self.animated_diff_scale_at(d, *touch.pos) return True @@ -639,7 +657,7 @@ def on_touch_down(self, touch): self._touch_count += 1 if self._touch_count == 1: self._touch_zoom = (self.zoom, self._scale) - return super(MapView, self).on_touch_down(touch) + return super().on_touch_down(touch) def on_touch_up(self, touch): if touch.grab_current == self: @@ -651,12 +669,12 @@ def on_touch_up(self, touch): cur_zoom = self.zoom cur_scale = self._scale if cur_zoom < zoom or cur_scale < scale: - self.animated_diff_scale_at(1. - cur_scale, *touch.pos) + self.animated_diff_scale_at(1.0 - cur_scale, *touch.pos) elif cur_zoom > zoom or cur_scale > scale: - self.animated_diff_scale_at(2. - cur_scale, *touch.pos) + self.animated_diff_scale_at(2.0 - cur_scale, *touch.pos) self._pause = False return True - return super(MapView, self).on_touch_up(touch) + return super().on_touch_up(touch) def on_transform(self, *args): self._invalid_scale = True @@ -668,19 +686,19 @@ def on_transform(self, *args): zoom = self._zoom scatter = self._scatter scale = scatter.scale - if scale >= 2.: + if scale >= 2.0: zoom += 1 - scale /= 2. + scale /= 2.0 elif scale < 1: zoom -= 1 - scale *= 2. + scale *= 2.0 zoom = clamp(zoom, map_source.min_zoom, map_source.max_zoom) if zoom != self._zoom: self.set_zoom_at(zoom, scatter.x, scatter.y, scale=scale) self.trigger_update(True) else: - if zoom == map_source.min_zoom and scatter.scale < 1.: - scatter.scale = 1. + if zoom == map_source.min_zoom and scatter.scale < 1.0: + scatter.scale = 1.0 self.trigger_update(True) else: self.trigger_update(False) @@ -705,16 +723,16 @@ def _apply_bounds(self): oxmin, oymin = self._scatter.to_local(self.x, self.y) oxmax, oymax = self._scatter.to_local(self.right, self.top) s = self._scale - cxmin = (oxmin - dx) + cxmin = oxmin - dx if cxmin < xmin: self._scatter.x += (cxmin - xmin) * s - cymin = (oymin - dy) + cymin = oymin - dy if cymin < ymin: self._scatter.y += (cymin - ymin) * s - cxmax = (oxmax - dx) + cxmax = oxmax - dx if cxmax > xmax: self._scatter.x -= (xmax - cxmax) * s - cymax = (oymax - dy) + cymax = oymax - dy if cymax > ymax: self._scatter.y -= (ymax - cymax) * s @@ -730,12 +748,12 @@ def trigger_update(self, full): def do_update(self, dt): zoom = self._zoom scale = self._scale - self.lon = self.map_source.get_lon(zoom, - ( - self.center_x - self._scatter.x) / scale - self.delta_x) - self.lat = self.map_source.get_lat(zoom, - ( - self.center_y - self._scatter.y) / scale - self.delta_y) + self.lon = self.map_source.get_lon( + zoom, (self.center_x - self._scatter.x) / scale - self.delta_x + ) + self.lat = self.map_source.get_lat( + zoom, (self.center_y - self._scatter.y) / scale - self.delta_y + ) self.dispatch("on_map_relocated", zoom, Coordinate(self.lon, self.lat)) for layer in self._layers: layer.reposition() @@ -768,8 +786,7 @@ def bbox_for_zoom(self, vx, vy, w, h, zoom): x_count = tile_x_last - tile_x_first y_count = tile_y_last - tile_y_first - return (tile_x_first, tile_y_first, tile_x_last, tile_y_last, - x_count, y_count) + return (tile_x_first, tile_y_first, tile_x_last, tile_y_last, x_count, y_count) def load_visible_tiles(self): map_source = self.map_source @@ -779,12 +796,14 @@ def load_visible_tiles(self): bbox_for_zoom = self.bbox_for_zoom size = map_source.dp_tile_size - tile_x_first, tile_y_first, tile_x_last, tile_y_last, \ - x_count, y_count = bbox_for_zoom(vx, vy, self.width, self.height, zoom) - - # print "Range {},{} to {},{}".format( - # tile_x_first, tile_y_first, - # tile_x_last, tile_y_last) + ( + tile_x_first, + tile_y_first, + tile_x_last, + tile_y_last, + x_count, + y_count, + ) = bbox_for_zoom(vx, vy, self.width, self.height, zoom) # Adjust tiles behind us for tile in self._tiles_bg[:]: @@ -794,11 +813,21 @@ def load_visible_tiles(self): f = 2 ** (zoom - tile.zoom) w = self.width / f h = self.height / f - btile_x_first, btile_y_first, btile_x_last, btile_y_last, \ - _, _ = bbox_for_zoom(vx / f, vy / f, w, h, tile.zoom) - - if tile_x < btile_x_first or tile_x >= btile_x_last or \ - tile_y < btile_y_first or tile_y >= btile_y_last: + ( + btile_x_first, + btile_y_first, + btile_x_last, + btile_y_last, + _, + _, + ) = bbox_for_zoom(vx / f, vy / f, w, h, tile.zoom) + + if ( + tile_x < btile_x_first + or tile_x >= btile_x_last + or tile_y < btile_y_first + or tile_y >= btile_y_last + ): tile.state = "done" self._tiles_bg.remove(tile) self.canvas_map.before.remove(tile.g_color) @@ -807,17 +836,19 @@ def load_visible_tiles(self): tsize = size * f tile.size = tsize, tsize - tile.pos = ( - tile_x * tsize + self.delta_x, - tile_y * tsize + self.delta_y) + tile.pos = (tile_x * tsize + self.delta_x, tile_y * tsize + self.delta_y) # Get rid of old tiles first for tile in self._tiles[:]: tile_x = tile.tile_x tile_y = tile.tile_y - if tile_x < tile_x_first or tile_x >= tile_x_last or \ - tile_y < tile_y_first or tile_y >= tile_y_last: + if ( + tile_x < tile_x_first + or tile_x >= tile_x_last + or tile_y < tile_y_first + or tile_y >= tile_y_last + ): tile.state = "done" self.tile_map_set(tile_x, tile_y, False) self._tiles.remove(tile) @@ -825,8 +856,7 @@ def load_visible_tiles(self): self.canvas_map.remove(tile.g_color) else: tile.size = (size, size) - tile.pos = ( - tile_x * size + self.delta_x, tile_y * size + self.delta_y) + tile.pos = (tile_x * size + self.delta_x, tile_y * size + self.delta_y) # Load new tiles if needed x = tile_x_first + x_count // 2 - 1 @@ -836,9 +866,13 @@ def load_visible_tiles(self): turn = 0 while arm_size < arm_max: for i in range(arm_size): - if not self.tile_in_tile_map(x, y) and \ - y >= tile_y_first and y < tile_y_last and \ - x >= tile_x_first and x < tile_x_last: + if ( + not self.tile_in_tile_map(x, y) + and y >= tile_y_first + and y < tile_y_last + and x >= tile_x_first + and x < tile_x_last + ): self.load_tile(x, y, size, zoom) x += dirs[turn % 4 + 1] @@ -852,7 +886,7 @@ def load_visible_tiles(self): def load_tile(self, x, y, size, zoom): if self.tile_in_tile_map(x, y) or zoom != self._zoom: return - self.load_tile_for_source(self.map_source, 1., size, x, y, zoom) + self.load_tile_for_source(self.map_source, 1.0, size, x, y, zoom) # XXX do overlay support self.tile_map_set(x, y, True) @@ -947,15 +981,19 @@ def on_map_source(self, instance, source): self.map_source = MapSource.from_provider(source) elif isinstance(source, (tuple, list)): cache_key, min_zoom, max_zoom, url, attribution, options = source - self.map_source = MapSource(url=url, cache_key=cache_key, - min_zoom=min_zoom, max_zoom=max_zoom, - attribution=attribution, - cache_dir=self.cache_dir, **options) + self.map_source = MapSource( + url=url, + cache_key=cache_key, + min_zoom=min_zoom, + max_zoom=max_zoom, + attribution=attribution, + cache_dir=self.cache_dir, + **options + ) elif isinstance(source, MapSource): self.map_source = source else: raise Exception("Invalid map source provider") - self.zoom = clamp(self.zoom, - self.map_source.min_zoom, self.map_source.max_zoom) + self.zoom = clamp(self.zoom, self.map_source.min_zoom, self.map_source.max_zoom) self.remove_all_tiles() self.trigger_update(True) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9acad64 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.black] +line-length = 88 +skip-string-normalization = true diff --git a/setup.cfg b/setup.cfg index a343008..1bfde65 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,14 +1,18 @@ [flake8] max-line-length = 88 extend-ignore = - E122, - E128, - E203, - E265, F401, - E402, - E501, - W504 + E501 + +[isort] +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 [coverage:run] relative_files = True + +[tool:pytest] +addopts = --cov=kivy_garden.mapview --cov-report term --cov-branch diff --git a/tests/maps-devrel-google.json b/tests/maps-devrel-google.json new file mode 100644 index 0000000..be2f35f --- /dev/null +++ b/tests/maps-devrel-google.json @@ -0,0 +1,146 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "letter": "G", + "color": "blue", + "rank": "7", + "ascii": "71" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [123.61, -22.14], [122.38, -21.73], [121.06, -21.69], [119.66, -22.22], [119.00, -23.40], + [118.65, -24.76], [118.43, -26.07], [118.78, -27.56], [119.22, -28.57], [120.23, -29.49], + [121.77, -29.87], [123.57, -29.64], [124.45, -29.03], [124.71, -27.95], [124.80, -26.70], + [124.80, -25.60], [123.61, -25.64], [122.56, -25.64], [121.72, -25.72], [121.81, -26.62], + [121.86, -26.98], [122.60, -26.90], [123.57, -27.05], [123.57, -27.68], [123.35, -28.18], + [122.51, -28.38], [121.77, -28.26], [121.02, -27.91], [120.49, -27.21], [120.14, -26.50], + [120.10, -25.64], [120.27, -24.52], [120.67, -23.68], [121.72, -23.32], [122.43, -23.48], + [123.04, -24.04], [124.54, -24.28], [124.58, -23.20], [123.61, -22.14] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "letter": "o", + "color": "red", + "rank": "15", + "ascii": "111" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [128.84, -25.76], [128.18, -25.60], [127.96, -25.52], [127.88, -25.52], [127.70, -25.60], + [127.26, -25.79], [126.60, -26.11], [126.16, -26.78], [126.12, -27.68], [126.21, -28.42], + [126.69, -29.49], [127.74, -29.80], [128.80, -29.72], [129.41, -29.03], [129.72, -27.95], + [129.68, -27.21], [129.33, -26.23], [128.84, -25.76] + ], + [ + [128.45, -27.44], [128.32, -26.94], [127.70, -26.82], [127.35, -27.05], [127.17, -27.80], + [127.57, -28.22], [128.10, -28.42], [128.49, -27.80], [128.45, -27.44] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "letter": "o", + "color": "yellow", + "rank": "15", + "ascii": "111" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [131.87, -25.76], [131.35, -26.07], [130.95, -26.78], [130.82, -27.64], [130.86, -28.53], + [131.26, -29.22], [131.92, -29.76], [132.45, -29.87], [133.06, -29.76], [133.72, -29.34], + [134.07, -28.80], [134.20, -27.91], [134.07, -27.21], [133.81, -26.31], [133.37, -25.83], + [132.71, -25.64], [131.87, -25.76] + ], + [ + [133.15, -27.17], [132.71, -26.86], [132.09, -26.90], [131.74, -27.56], [131.79, -28.26], + [132.36, -28.45], [132.93, -28.34], [133.15, -27.76], [133.15, -27.17] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "letter": "g", + "color": "blue", + "rank": "7", + "ascii": "103" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [138.12, -25.04], [136.84, -25.16], [135.96, -25.36], [135.26, -25.99], [135, -26.90], + [135.04, -27.91], [135.26, -28.88], [136.05, -29.45], [137.02, -29.49], [137.81, -29.49], + [137.94, -29.99], [137.90, -31.20], [137.85, -32.24], [136.88, -32.69], [136.45, -32.36], + [136.27, -31.80], [134.95, -31.84], [135.17, -32.99], [135.52, -33.43], [136.14, -33.76], + [137.06, -33.83], [138.12, -33.65], [138.86, -33.21], [139.30, -32.28], [139.30, -31.24], + [139.30, -30.14], [139.21, -28.96], [139.17, -28.22], [139.08, -27.41], [139.08, -26.47], + [138.99, -25.40], [138.73, -25.00 ], [138.12, -25.04] + ], + [ + [137.50, -26.54], [136.97, -26.47], [136.49, -26.58], [136.31, -27.13], [136.31, -27.72], + [136.58, -27.99], [137.50, -28.03], [137.68, -27.68], [137.59, -26.78], [137.50, -26.54] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "letter": "l", + "color": "green", + "rank": "12", + "ascii": "108" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [140.14,-21.04], [140.31,-29.42], [141.67,-29.49], [141.59,-20.92], [140.14,-21.04] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "letter": "e", + "color": "red", + "rank": "5", + "ascii": "101" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [144.14, -27.41], [145.67, -27.52], [146.86, -27.09], [146.82, -25.64], [146.25, -25.04], + [145.45, -24.68], [144.66, -24.60], [144.09, -24.76], [143.43, -25.08], [142.99, -25.40], + [142.64, -26.03], [142.64, -27.05], [142.64, -28.26], [143.30, -29.11], [144.18, -29.57], + [145.41, -29.64], [146.46, -29.19], [146.64, -28.72], [146.82, -28.14], [144.84, -28.42], + [144.31, -28.26], [144.14, -27.41] + ], + [ + [144.18, -26.39], [144.53, -26.58], [145.19, -26.62], [145.72, -26.35], [145.81, -25.91], + [145.41, -25.68], [144.97, -25.68], [144.49, -25.64], [144, -25.99], [144.18, -26.39] + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/test_downloader.py b/tests/test_downloader.py new file mode 100644 index 0000000..9cb44a9 --- /dev/null +++ b/tests/test_downloader.py @@ -0,0 +1,59 @@ +from unittest import mock + +from kivy.clock import Clock + +from kivy_garden.mapview.constants import CACHE_DIR +from kivy_garden.mapview.downloader import Downloader +from tests.utils import patch_requests_get + + +class TestDownloader: + def teardown_method(self): + Downloader._instance = None + + def test_instance(self): + """Makes sure instance is a singleton.""" + assert Downloader._instance is None + downloader = Downloader.instance() + assert downloader == Downloader._instance + assert type(downloader) == Downloader + assert downloader.cache_dir == CACHE_DIR + Downloader._instance = None + new_cache_dir = "new_cache_dir" + downloader = Downloader.instance(new_cache_dir) + assert downloader.cache_dir == new_cache_dir + + def test_download(self): + """Checks download() callback.""" + callback = mock.Mock() + url = "https://ifconfig.me/" + downloader = Downloader.instance() + assert len(downloader._futures) == 0 + with patch_requests_get() as m_get: + downloader.download(url, callback) + assert m_get.call_args_list == [mock.call(url)] + assert callback.call_args_list == [] + assert len(downloader._futures) == 1 + Clock.tick() + assert callback.call_args_list == [mock.call(url, mock.ANY)] + assert len(downloader._futures) == 0 + + def test_download_status_error(self): + """ + Error status code should be checked. + Callback function will not be invoked on error. + """ + callback = mock.Mock() + url = "https://httpstat.us/404" + status_code = 404 + downloader = Downloader.instance() + assert len(downloader._futures) == 0 + with patch_requests_get(status_code=status_code) as m_get: + downloader.download(url, callback) + assert m_get.call_args_list == [mock.call(url)] + assert len(downloader._futures) == 1 + assert callback.call_args_list == [] + while len(downloader._futures) > 0: + Clock.tick() + assert callback.call_args_list == [] + assert len(downloader._futures) == 0 diff --git a/tests/test_geojson.py b/tests/test_geojson.py new file mode 100644 index 0000000..f4eabf7 --- /dev/null +++ b/tests/test_geojson.py @@ -0,0 +1,72 @@ +import json +import os +from unittest import mock + +from kivy.clock import Clock + +from kivy_garden.mapview import MapView +from kivy_garden.mapview.geojson import GeoJsonMapLayer +from tests.utils import patch_requests_get + + +def load_json(filename): + test_dir = os.path.dirname(__file__) + json_file_path = os.path.join(test_dir, filename) + with open(json_file_path) as f: + json_file_content = f.read() + return json.loads(json_file_content) + + +class TestGeoJsonMapLayer: + def test_init_simple(self): + """Makes sure we can initialize a simple GeoJsonMapLayer object.""" + kwargs = {} + maplayer = GeoJsonMapLayer(**kwargs) + assert maplayer.source == "" + assert maplayer.geojson is None + assert maplayer.cache_dir == "cache" + + def test_init_source(self): + """ + Providing the source from http(s). + The json object should get downloaded using the requests library. + """ + source = "https://storage.googleapis.com/maps-devrel/google.json" + kwargs = {"source": source} + response_json = { + "type": "FeatureCollection", + } + with patch_requests_get(response_json) as m_get: + maplayer = GeoJsonMapLayer(**kwargs) + assert maplayer.source == source + assert maplayer.geojson is None + while maplayer.geojson is None: + Clock.tick() + assert maplayer.geojson == response_json + assert m_get.call_args_list == [mock.call(source)] + + def test_init_geojson(self): + """Providing the geojson directly, polygons should get added.""" + options = {} + mapview = MapView(**options) + geojson = {} + kwargs = {"geojson": geojson} + maplayer = GeoJsonMapLayer(**kwargs) + mapview.add_layer(maplayer) + Clock.tick() + assert maplayer.source == "" + assert maplayer.geojson == geojson + assert len(maplayer.canvas_line.children) == 0 + assert len(maplayer.canvas_polygon.children) == 3 + assert len(maplayer.g_canvas_polygon.children) == 0 + geojson = load_json("maps-devrel-google.json") + kwargs = {"geojson": geojson} + maplayer = GeoJsonMapLayer(**kwargs) + mapview = MapView(**options) + mapview.add_layer(maplayer) + Clock.tick() + assert maplayer.source == "" + assert maplayer.geojson == geojson + assert len(maplayer.canvas_line.children) == 0 + assert len(maplayer.canvas_polygon.children) == 3 + assert len(maplayer.g_canvas_polygon.children) == 132 diff --git a/tests/test_mapview.py b/tests/test_mapview.py index 4023003..4020a12 100644 --- a/tests/test_mapview.py +++ b/tests/test_mapview.py @@ -1,18 +1,9 @@ -import unittest from kivy_garden.mapview import MapView -class TextInputTest(unittest.TestCase): - +class TestMapView: def test_init_simple_map(self): - """ - Makes sure we can initialize a simple MapView object. - """ + """Makes sure we can initialize a simple MapView object.""" kwargs = {} mapview = MapView(**kwargs) - self.assertEqual(len(mapview.children), 2) - - -if __name__ == '__main__': - import unittest - unittest.main() + assert len(mapview.children) == 2 diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..8e43c44 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,11 @@ +from unittest import mock + +from requests.models import Response + + +def patch_requests_get(response_json=None, status_code=200): + response = Response() + response.json = lambda: response_json + response.status_code = status_code + m_get = mock.Mock(return_value=response) + return mock.patch("requests.get", m_get) diff --git a/tox.ini b/tox.ini index 6a0775d..a846486 100644 --- a/tox.ini +++ b/tox.ini @@ -10,4 +10,4 @@ commands = [testenv:pep8] deps = flake8 -commands = flake8 kivy_garden/ examples/ +commands = flake8 tests/ kivy_garden/ examples/