diff --git a/pycaching/__init__.py b/pycaching/__init__.py index 558a47b..2311bd3 100644 --- a/pycaching/__init__.py +++ b/pycaching/__init__.py @@ -1,11 +1,10 @@ #!/usr/bin/env python3 -from pycaching.area import Rectangle # NOQA from pycaching.cache import Cache # NOQA from pycaching.geocaching import Geocaching # NOQA from pycaching.log import Log # NOQA -from pycaching.point import Point # NOQA from pycaching.trackable import Trackable # NOQA +from pycaching.geo import Point, Rectangle # NOQA def login(username, password): diff --git a/pycaching/area.py b/pycaching/area.py deleted file mode 100644 index 1baaec7..0000000 --- a/pycaching/area.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 - -from pycaching.point import Point - - -class Area: - """Geometrical area""" - pass - - -class Polygon(Area): - """Area defined by bordering Point instances""" - - def __init__(self, *points): - """Define polygon by list of consecutive Points""" - assert len(points) >= 3 - self.points = points - - @property - def bounding_box(self): - """Get extreme latitude and longitude values. - - Return Rectangle that contains all points""" - - lats = sorted([p.latitude for p in self.points]) - lons = sorted([p.longitude for p in self.points]) - return Rectangle(Point(min(lats), min(lons)), - Point(max(lats), max(lons))) - - @property - def mean_point(self): - """Return point with average latitude and longitude of points""" - lats = [p.latitude for p in self.points] - lons = [p.longitude for p in self.points] - return Point(sum(lats) / len(lats), sum(lons) / len(lons)) - - @property - def diagonal(self): - """Return bounding box diagonal""" - return self.bounding_box.diagonal - - -class Rectangle(Polygon): - """Upright rectangle""" - - def __init__(self, point_a, point_b): - """Create rectangle defined by opposite corners - - Parameters point_a and point_b are Point instances.""" - - assert point_a != point_b, "Corner points cannot be the same" - self.corners = [point_a, point_b] - self.points = [point_a, Point(point_a.latitude, point_b.longitude), - point_b, Point(point_b.latitude, point_a.longitude)] - - def inside_area(self, point): - """Is point inside area?""" - lats = sorted([p.latitude for p in self.points]) - lons = sorted([p.longitude for p in self.points]) - if min(lats) <= point.latitude <= max(lats): - if min(lons) <= point.longitude <= max(lons): - return True - return False - - @property - def diagonal(self): - """Return rectangle diagonal""" - return self.corners[0].distance(self.corners[1]) diff --git a/pycaching/cache.py b/pycaching/cache.py index b68ade7..5c84ac7 100644 --- a/pycaching/cache.py +++ b/pycaching/cache.py @@ -3,9 +3,9 @@ import logging import datetime import re +import enum from pycaching import errors -from pycaching.point import Point -from pycaching.enums import Type, Size +from pycaching.geo import Point from pycaching.trackable import Trackable from pycaching.log import Log from pycaching.util import parse_date, rot13, lazy_loaded @@ -87,72 +87,33 @@ class Cache(object): "wirelessbeacon": "Wireless Beacon" } - def __init__(self, geocaching, wp, *, name=None, type=None, location=None, state=None, - found=None, size=None, difficulty=None, terrain=None, author=None, hidden=None, - attributes=None, summary=None, description=None, hint=None, favorites=None, - pm_only=None, url=None, trackable_page_url=None, logbook_token=None, - log_page_url=None): + def __init__(self, geocaching, wp, **kwargs): + self.geocaching = geocaching - # properties if wp is not None: self.wp = wp - if name is not None: - self.name = name - if type is not None: - self.type = type - if location is not None: - self.location = location - if state is not None: - self.state = state - if found is not None: - self.found = found - if size is not None: - self.size = size - if difficulty is not None: - self.difficulty = difficulty - if terrain is not None: - self.terrain = terrain - if author is not None: - self.author = author - if hidden is not None: - self.hidden = hidden - if attributes is not None: - self.attributes = attributes - if summary is not None: - self.summary = summary - if description is not None: - self.description = description - if hint is not None: - self.hint = hint - if favorites is not None: - self.favorites = favorites - if pm_only is not None: - self.pm_only = pm_only - if url is not None: - self.url = url - # related - self.logbook = [] - self.trackables = [] - if logbook_token is not None: - self.logbook_token = logbook_token - if trackable_page_url is not None: - self.trackable_page_url = trackable_page_url - if log_page_url is not None: - self.log_page_url = log_page_url + + known_kwargs = {"name", "type", "location", "state", "found", "size", "difficulty", "terrain", + "author", "hidden", "attributes", "summary", "description", "hint", "favorites", + "pm_only", "url", "trackable_page_url", "logbook_token", "log_page_url"} + + for name in known_kwargs: + if name in kwargs: + setattr(self, name, kwargs[name]) def __str__(self): return self.wp def __eq__(self, other): - return self.wp == other.wp + return self.geocaching == other.geocaching and self.wp == other.wp @classmethod def from_trackable(cls, trackable): return cls(trackable.geocaching, None, url=trackable.location_url) @classmethod - def from_block(cls, geocaching, block): - c = cls(geocaching, block.cache_wp, name=block.cache_name) + def from_block(cls, block): + c = cls(block.tile.geocaching, block.cache_wp, name=block.cache_name) c.location = Point.from_block(block) return c @@ -355,10 +316,6 @@ def pm_only(self): def pm_only(self, pm_only): self._pm_only = bool(pm_only) - def inside_area(self, area): - """Calculate if geocache is inside given area""" - return area.inside_area(self.location) - @property @lazy_loaded def logbook_token(self): @@ -600,3 +557,96 @@ def post_log(self, l): post["ctl00$ContentBody$LogBookPanel1$uxLogInfo"] = l.text self.geocaching._request(self.log_page_url, method="POST", data=post) + + +class Type(enum.Enum): + + # value is cache image filename (http://www.geocaching.com/images/WptTypes/[VALUE].gif) + traditional = "2" + multicache = "3" + mystery = unknown = "8" + letterbox = "5" + event = "6" + mega_event = "mega" + giga_event = "giga" + earthcache = "137" + cito = cache_in_trash_out_event = "13" + webcam = "11" + virtual = "4" + wherigo = "1858" + lost_and_found_event = "10Years_32" + project_ape = "ape_32" + groundspeak_hq = "HQ_32" + gps_adventures_exhibit = "1304" + groundspeak_block_party = "4738" + locationless = reverse = "12" + + @classmethod + def from_filename(cls, filename): + """Returns cache type from its image filename""" + + if filename == "earthcache": + filename = "137" # fuck Groundspeak, they use 2 exactly same icons with 2 different names + + return cls(filename) + + @classmethod + def from_string(cls, name): + """Returns cache type from its human readable name""" + + name = name.replace(" Geocache", "") # with space! + name = name.replace(" Cache", "") # with space! + name = name.lower().strip() + + name_mapping = { + "traditional": cls.traditional, + "multi-cache": cls.multicache, + "mystery": cls.mystery, + "unknown": cls.unknown, + "letterbox hybrid": cls.letterbox, + "event": cls.event, + "mega-event": cls.mega_event, + "giga-event": cls.giga_event, + "earthcache": cls.earthcache, + "cito": cls.cito, + "cache in trash out event": cls.cache_in_trash_out_event, + "webcam": cls.webcam, + "virtual": cls.virtual, + "wherigo": cls.wherigo, + "lost and found event": cls.lost_and_found_event, + "project ape": cls.project_ape, + "groundspeak hq": cls.groundspeak_hq, + "gps adventures exhibit": cls.gps_adventures_exhibit, + "groundspeak block party": cls.groundspeak_block_party, + "locationless (reverse)": cls.locationless, + } + + try: + return name_mapping[name] + except KeyError as e: + raise errors.ValueError("Unknown cache type '{}'.".format(name)) from e + + +class Size(enum.Enum): + micro = "micro" + small = "small" + regular = "regular" + large = "large" + not_chosen = "not chosen" + virtual = "virtual" + other = "other" + + @classmethod + def from_filename(cls, filename): + """Returns cache size from its image filename""" + return cls[filename] + + @classmethod + def from_string(cls, name): + """Returns cache size from its human readable name""" + name = name.strip().lower() + + try: + return cls(name) + except ValueError as e: + raise errors.ValueError("Unknown cache type '{}'.".format(name)) from e diff --git a/pycaching/enums.py b/pycaching/enums.py deleted file mode 100644 index 5399ad8..0000000 --- a/pycaching/enums.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env python3 - -from pycaching.errors import ValueError as PycachingValueError -from enum import Enum - - -class Type(Enum): - - # value is cache image filename (http://www.geocaching.com/images/WptTypes/[VALUE].gif) - traditional = "2" - multicache = "3" - mystery = unknown = "8" - letterbox = "5" - event = "6" - mega_event = "mega" - giga_event = "giga" - earthcache = "137" - cito = cache_in_trash_out_event = "13" - webcam = "11" - virtual = "4" - wherigo = "1858" - lost_and_found_event = "10Years_32" - project_ape = "ape_32" - groundspeak_hq = "HQ_32" - gps_adventures_exhibit = "1304" - groundspeak_block_party = "4738" - locationless = reverse = "12" - - @classmethod - def from_filename(cls, filename): - """Returns cache type from its image filename""" - - if filename == "earthcache": - filename = "137" # fuck Groundspeak, they use 2 exactly same icons with 2 different names - - return cls(filename) - - @classmethod - def from_string(cls, name): - """Returns cache type from its human readable name""" - - name = name.replace(" Geocache", "") # with space! - name = name.replace(" Cache", "") # with space! - name = name.lower().strip() - - name_mapping = { - "traditional": cls.traditional, - "multi-cache": cls.multicache, - "mystery": cls.mystery, - "unknown": cls.unknown, - "letterbox hybrid": cls.letterbox, - "event": cls.event, - "mega-event": cls.mega_event, - "giga-event": cls.giga_event, - "earthcache": cls.earthcache, - "cito": cls.cito, - "cache in trash out event": cls.cache_in_trash_out_event, - "webcam": cls.webcam, - "virtual": cls.virtual, - "wherigo": cls.wherigo, - "lost and found event": cls.lost_and_found_event, - "project ape": cls.project_ape, - "groundspeak hq": cls.groundspeak_hq, - "gps adventures exhibit": cls.gps_adventures_exhibit, - "groundspeak block party": cls.groundspeak_block_party, - "locationless (reverse)": cls.locationless, - } - - try: - return name_mapping[name] - except KeyError as e: - raise PycachingValueError("Unknown cache type '{}'.".format(name)) from e - - -class Size(Enum): - micro = "micro" - small = "small" - regular = "regular" - large = "large" - not_chosen = "not chosen" - virtual = "virtual" - other = "other" - - @classmethod - def from_filename(cls, filename): - """Returns cache size from its image filename""" - return cls[filename] - - @classmethod - def from_string(cls, name): - """Returns cache size from its human readable name""" - name = name.strip().lower() - - try: - return cls(name) - except ValueError as e: - raise PycachingValueError("Unknown cache type '{}'.".format(name)) from e - - -class LogType(Enum): - found_it = "found it" - didnt_find_it = "didn't find it" - note = "write note" - publish_listing = "publish listing" - enable_listing = "enable listing" - archive = "archive" - unarchive = "unarchive" - temp_disable_listing = "temporarily disable listing" - needs_archive = "needs archived" - will_attend = "will attend" - attended = "attended" - retrieved_it = "retrieved it" - placed_it = "placed it" - grabbed_it = "grabbed it" - needs_maintenance = "needs maintenance" - owner_maintenance = "owner maintenance" - update_coordinates = "update coordinates" - discovered_it = "discovered it" - post_reviewer_note = "post reviewer note" - submit_for_review = "submit for review" - visit = "visit" - webcam_photo_taken = "webcam photo taken" - announcement = "announcement" - retract = "retract listing" - marked_missing = "marked missing" - oc_team_comment = "X1" - - @classmethod - def from_string(cls, name): - """Returns log type from its human readable name""" - name = name.strip().lower() - - try: - return cls(name) - except ValueError as e: - raise PycachingValueError("Unknown log type '{}'.".format(name)) from e diff --git a/pycaching/tile.py b/pycaching/geo.py similarity index 54% rename from pycaching/tile.py rename to pycaching/geo.py index 967aaf4..4d9f6a6 100644 --- a/pycaching/tile.py +++ b/pycaching/geo.py @@ -1,12 +1,224 @@ #!/usr/bin/env python3 +import math +import re import logging import weakref -from math import sqrt +import itertools +import geopy +import geopy.distance from statistics import mean from collections import namedtuple +from pycaching.errors import ValueError as PycachingValueError, GeocodeError, BadBlockError, Error from pycaching.util import lazy_loaded -from pycaching.errors import Error, BadBlockError + + +def to_decimal(deg, min): + """Returns a decimal interpretation of coordinate in MinDec format.""" + return round(deg + min / 60, 5) + + +def to_mindec(decimal): + """Returns a DecMin interpretation of coordinate in decimal format.""" + return int(decimal), round(60 * (decimal - int(decimal)), 3) + + +class Point(geopy.Point): + """A point on earth defined by its latitude and longitude and possibly more attributes.""" + + def __new__(cls, *args, **kwargs): + precision = kwargs.pop("precision", None) + self = super(Point, cls).__new__(cls, *args, **kwargs) + self.precision = precision + return self + + @classmethod + def from_location(cls, geocaching, location): + res = geocaching._request("api/geocode", params={"q": location}, expect="json") + + if res["status"] != "success": + raise GeocodeError(res["msg"]) + + return cls(float(res["data"]["lat"]), float(res["data"]["lng"])) + + @classmethod + def from_string(cls, string): + """Parses the coords in Degree Minutes format. Expecting: + + S 36 51.918 E 174 46.725 or + N 6 52.861 W174 43.327 + + Spaces do not matter. Neither does having the degree symbol. + + Returns a geopy.Point instance.""" + + # Make it uppercase for consistency + coords = string.upper().replace("N", " ").replace("S", " ") \ + .replace("E", " ").replace("W", " ").replace("+", " ") + + try: + m = re.match(r"\s*(-?\s*\d+)\D+(\d+[\.,]\d+)\D?\s*(-?\s*\d+)\D+(\d+[\.,]\d+)", coords) + + latDeg, latMin, lonDeg, lonMin = [ + float(part.replace(" ", "").replace(",", ".")) for part in m.groups()] + + if "S" in string: + latDeg *= -1 + if "W" in string: + lonDeg *= -1 + + return cls(to_decimal(latDeg, latMin), to_decimal(lonDeg, lonMin)) + + except AttributeError: + pass + + # fallback + try: + return super(cls, cls).from_string(string) + except ValueError as e: + # wrap possible error to pycaching.errors.ValueError + raise PycachingValueError() from e + + @classmethod + def from_block(cls, block): + return cls.from_tile(block.tile, block.middle_point) + + @classmethod + def from_tile(cls, tile, tile_point=None): + """Calculate location from web map tile coordinates + + Parameter represents a map tile coordinates as specified in + Google Maps JavaScript API [1]. Optional parameter point + determine position inside the tile, starting from northwest + corner. Its x and y coords are in range [0, tile.size]. + This code is modified from OpenStreetMap Wiki [2]. + + [1] https://developers.google.com/maps/documentation/javascript/maptypes#MapCoordinates + [2] http://wiki.openstreetmap.org/wiki/Slippy_map_tilenames + """ + + if tile_point: + dx = float(tile_point.x) / tile.size + dy = float(tile_point.y) / tile.size + else: + dx, dy = 0, 0 + + n = 2.0 ** tile.z + lon_deg = (tile.x + dx) / n * 360.0 - 180.0 + lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * (tile.y + dy) / n))) + lat_deg = math.degrees(lat_rad) + p = cls(lat_deg, lon_deg) + p.precision = tile.precision(p) + return p + + def to_tile(self, geocaching, zoom): + """Calculate web map tile where point is located + + Return x, y, z. If fractions, return x and y as floats. This + code is modified from OpenStreetMap Wiki [1]. + + [1] http://wiki.openstreetmap.org/wiki/Slippy_map_tilenames + """ + lat_deg = self.latitude + lon_deg = self.longitude + lat_rad = math.radians(lat_deg) + n = 2.0 ** zoom + x = int((lon_deg + 180.0) / 360.0 * n) + y = int((1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * n) + return Tile(geocaching, x, y, zoom) + + def __format__(self, format_spec): + return "{:{}}".format(str(self), format_spec) + + +class Area: + """Geometrical area""" + pass + + +class Polygon(Area): + """Area defined by bordering Point instances""" + + def __init__(self, *points): + """Define polygon by list of consecutive Points""" + assert len(points) >= 3 + self.points = points + + @property + def bounding_box(self): + """Get extreme latitude and longitude values. + + Return Rectangle that contains all points""" + + lats = sorted([p.latitude for p in self.points]) + lons = sorted([p.longitude for p in self.points]) + return Rectangle(Point(min(lats), min(lons)), + Point(max(lats), max(lons))) + + @property + def mean_point(self): + """Return point with average latitude and longitude of points""" + x = mean([p.latitude for p in self.points]) + y = mean([p.longitude for p in self.points]) + return Point(x, y) + + @property + def diagonal(self): + """Return bounding box diagonal""" + return self.bounding_box.diagonal + + def to_tiles(self, gc, zoom=None): + """Return list of tiles covering this area""" + + corners = self.bounding_box.corners + + if not zoom: + # calculate zoom, where whole area can fit into one tile + # TODO this can be done without cycle, just by computing - but it + # is too complex, that it is more readable to just try some values + for zoom in range(Tile.max_zoom, 1, -1): + if corners[0].to_tile(gc, zoom) == corners[1].to_tile(gc, zoom): + break + + # get corner tiles + nw_tile = corners[0].to_tile(gc, zoom) + se_tile = corners[1].to_tile(gc, zoom) + + # sort corner coords because of range + x1, x2 = sorted((nw_tile.x, se_tile.x)) + y1, y2 = sorted((nw_tile.y, se_tile.y)) + + logging.debug("Area converted to {} tiles, zoom level {}".format( + (x2 - x1) * (y2 - y1), zoom)) + + # for each tile between corners + for x, y in itertools.product(range(x1, x2 + 1), range(y1, y2 + 1)): + yield Tile(gc, x, y, zoom) + + +class Rectangle(Polygon): + """Upright rectangle""" + + def __init__(self, point_a, point_b): + """Create rectangle defined by opposite corners + + Parameters point_a and point_b are Point instances.""" + + assert point_a != point_b, "Corner points cannot be the same" + self.corners = [point_a, point_b] + self.points = [point_a, Point(point_a.latitude, point_b.longitude), + point_b, Point(point_b.latitude, point_a.longitude)] + + def __contains__(self, p): + """Is point p inside area?""" + lats = sorted([_.latitude for _ in self.points]) + lons = sorted([_.longitude for _ in self.points]) + return min(lats) <= p.latitude <= max(lats) and min(lons) <= p.longitude <= max(lons) + + @property + def diagonal(self): + """Return rectangle diagonal""" + return geopy.distance.distance(self.corners[0], self.corners[1]).meters class Tile(object): @@ -120,13 +332,18 @@ def load(self): utfgrid = self._download_utfgrid() + if not utfgrid: + self._blocks = {} + logging.debug("No block loaded to {}".format(self)) + return + size = len(utfgrid["grid"]) assert len(utfgrid["grid"][1]) == size, "UTFGrid is not square" if size != self.size: logging.warning("UTFGrid has unexpected size.") self.size = size - self._blocks = {} # format: { waypoint: (, ) } + self._blocks = {} # format: { waypoint: } # for all non-empty in coords for coordinate_key in utfgrid["data"]: @@ -146,11 +363,23 @@ def load(self): self._blocks[waypoint].add(point) # try to determine grid coordinate block size - # TODO: move this to TileGroup - # Block.determine_block_size() + Block.determine_block_size() logging.debug("Loaded {} blocks to {}".format(len(self._blocks), self)) + def precision(self, point=None): + """Return (x-axis) coordinate precision for current tile and point""" + diam = geopy.distance.ELLIPSOIDS["WGS-84"][0] * 1e3 * 2 + lat_correction = math.cos(math.radians(point.latitude)) if point else 1 + tile_length = math.pi * diam * lat_correction * 2 ** (-self.z) + return tile_length / self.size + + def __eq__(self, other): + for attr in ['geocaching', 'x', 'y', 'z']: + if getattr(self, attr) != getattr(other, attr): + return False + return True + def __str__(self): return "".format(id(self), self.x, self.y, self.z) @@ -188,7 +417,7 @@ def determine_block_size(cls): if len(cls.instances) < 20: logging.warning("Trying to determine block size with small number of blocks.") - avg_block_size = round(mean((sqrt(len(i().points)) for i in cls.instances))) + avg_block_size = round(mean((math.sqrt(len(i().points)) for i in cls.instances))) if cls.size != avg_block_size: logging.warning("UTFGrid coordinate block has unexpected size.") cls.size = avg_block_size diff --git a/pycaching/geocaching.py b/pycaching/geocaching.py index 3b7122a..026dc78 100644 --- a/pycaching/geocaching.py +++ b/pycaching/geocaching.py @@ -1,15 +1,11 @@ #!/usr/bin/env python3 import logging -import math import requests from mechanicalsoup import Browser from bs4 import BeautifulSoup -from geopy.distance import ELLIPSOIDS -from pycaching.cache import Cache -from pycaching.point import Point -from pycaching.tile import Tile -from pycaching.enums import Type, Size +from pycaching.cache import Cache, Type, Size +from pycaching.geo import Point from pycaching.errors import Error, NotLoggedInException, LoginFailedException from pycaching.util import parse_date @@ -75,7 +71,8 @@ def login(self, username, password): # login fields login_elements = login_page.find_all("input", type=["text", "password", "checkbox"]) - post.update({field["name"]: val for field, val in zip(login_elements, [username, password, 1])}) + post.update({field["name"]: val for field, val in zip( + login_elements, [username, password, 1])}) # other nescessary fields other_elements = login_page.find_all("input", type=["hidden", "submit"]) @@ -83,7 +80,8 @@ def login(self, username, password): # login to the site logging.debug("Submiting login form.") - after_login_page = self._request(self._urls["login_page"], method="POST", data=post, login_check=False) + after_login_page = self._request( + self._urls["login_page"], method="POST", data=post, login_check=False) logging.debug("Checking the result.") if self.get_logged_user(after_login_page): @@ -92,7 +90,8 @@ def login(self, username, password): return else: self.logout() - raise LoginFailedException("Cannot login to the site (probably wrong username or password).") + raise LoginFailedException( + "Cannot login to the site (probably wrong username or password).") def logout(self): """Logs out the user. @@ -120,7 +119,7 @@ def search(self, point, limit=float("inf")): assert isinstance(point, Point) - logging.info("Searching at %s...", point) + logging.info("Searching at {}".format(point)) start_index = 0 while True: @@ -168,7 +167,7 @@ def search(self, point, limit=float("inf")): start_index += 1 def _search_get_page(self, point, start_index): - logging.debug("Loading page from start_index: %d", start_index) + logging.debug("Loading page from start_index {}".format(start_index)) if start_index == 0: # first request has to load normal search page @@ -193,161 +192,17 @@ def _search_get_page(self, point, start_index): return BeautifulSoup(res["HtmlString"].strip()) - # def search_quick(self, area, precision=None, strict=False): - # """Get geocaches inside area, with approximate coordinates - # - # Download geocache map tiles from geocaching.com and calculate - # approximate location of based on tiles. Parameter area is Area - # instance, optional parameter precision is desired location - # precision for the cache in meters. More precise results require - # increasingly more pages to be loaded. - # - # If not strict, return all found geocaches from overlapping - # tiles; else make sure that only caches within given area are - # returned. - # - # Return generator object of Cache instances.""" - # - # logging.info("Performing quick search for cache locations") - # # Calculate initial tiles - # tiles, starting_precision = self._calculate_initial_tiles(area) - # - # # Check for continuation requirement and download tiles - # geocaches = self._get_utfgrid_caches(*tiles) - # - # if precision is not None: - # # On which zoom level grid details exceed required precision - # new_zoom = self._get_zoom_by_distance(precision, area.mean_point.latitude, Tile.size, "ge") - # new_precision = area.mean_point.precision_from_tile_zoom(new_zoom, Tile.size) - # assert precision >= new_precision - # new_zoom = min(new_zoom, Tile.max_zoom) - # - # if precision is None or precision >= starting_precision or new_zoom == tiles[0][-1]: # Previous zoom - # # No need to continue: start yielding caches - # for c in geocaches: - # if strict and not c.inside_area(area): - # continue - # yield c - # return - # - # # Define new tiles for downloading - # logging.info("Downloading again at zoom level {} (precision {:.1f} m)".format(new_zoom, new_precision)) - # round_1_caches = {} - # tiles = set() - # for c in geocaches: - # round_1_caches[c.wp] = c - # tiles.add(c.location.to_map_tile(new_zoom)) - # # Perform query, yield found caches - # for c in self._get_utfgrid_caches(*tiles): - # round_1_caches.pop(c.wp, None) # Mark as found - # if strict and not c.inside_area(area): - # continue - # yield c - # - # # Check if previously found caches are missing - # if not round_1_caches: - # return - # else: - # logging.debug("Oh no, these geocaches were not found: {}.".format(round_1_caches.keys())) - # for c in self._search_from_bordering_tiles(tiles, new_zoom, **round_1_caches): - # if strict and not c.inside_area(area): - # continue - # yield c - # - # def _calculate_initial_tiles(self, area): - # """Calculate which tiles are downloaded initially - # - # Return list of tiles and starting precision.""" - # - # dist = area.diagonal - # # Get zoom where distance between points is less or equal to tile width - # starting_zoom = self._get_zoom_by_distance(dist, area.mean_point.latitude, 1, "le") - # starting_tile_width = area.mean_point.precision_from_tile_zoom(starting_zoom, 1) - # starting_precision = starting_tile_width / Tile.size - # assert dist <= starting_tile_width - # zoom = min(starting_zoom, Tile.max_zoom) - # logging.info("Starting at zoom level {} (precision {:.1f} m, " - # "tile width {:.0f} m)".format(zoom, starting_precision, starting_tile_width)) - # x1, y1, _ = area.bounding_box.corners[0].to_map_tile(zoom) - # x2, y2, _ = area.bounding_box.corners[1].to_map_tile(zoom) - # tiles = [] # [(x, y, z), ...] - # for x_i in range(min(x1, x2), max(x1, x2) + 1): - # for y_i in range(min(y1, y2), max(y1, y2) + 1): - # tiles.append((x_i, y_i, zoom)) - # return tiles, starting_precision - # - # def _get_utfgrid_caches(self, *tiles): - # """Get location of geocaches within tiles, using UTFGrid service - # - # Parameter tiles contains one or more tuples dictionaries that - # are of form (x, y, z). Return generator object of Cache - # instances.""" - # - # found_caches = set() - # for tile in tiles: - # ug = Tile(self, *tile) - # for c in ug.caches: - # # Some geocaches may be found multiple times if they are on the - # # border of the Tile. Throw additional ones away. - # if c.wp in found_caches: - # logging.debug("Found cache {} again".format(c.wp)) - # continue - # found_caches.add(c.wp) - # yield c - # logging.info("{} tiles downloaded".format(len(tiles))) - # - # def _search_from_bordering_tiles(self, previous_tiles, new_zoom, **missing_caches): - # """Extend geocache search to neighbouring tiles - # - # Parameter previous_tiles is a set of tiles that were already - # downloaded. Parameter missing_caches is a dictionary - # {waypoint:} and contains those caches that were found in - # previous zoom level but not anymore. Search around their - # expected coordinates and yield some more caches.""" - # - # new_tiles = set() - # for wp in missing_caches: - # tile = missing_caches[wp].location.to_map_tile(new_zoom, fractions=True) - # neighbours = self._bordering_tiles(*tile) - # new_tiles.update(neighbours.difference(previous_tiles)) - # logging.debug("Extending search to tiles {}".format(new_tiles)) - # for c in self._get_utfgrid_caches(*new_tiles): - # missing_caches.pop(c.wp, None) # Mark as found - # yield c - # if missing_caches: - # logging.debug("Could not just find these caches anymore: ".format(missing_caches)) - # for wp in missing_caches: - # yield missing_caches[wp] - # - # @staticmethod - # def _bordering_tiles(x_float, y_float, z, fraction=0.1): - # """Get possible map tiles near the edge where geocache was found - # - # Return set of (x, y, z) tile coordinates.""" - # - # orig_tile = (int(x_float), int(y_float), z) - # tiles = set() - # for i in range(-1, 2): - # for j in range(-1, 2): - # new_tile = (int(x_float + i * fraction), int(y_float + j * fraction), z) - # if new_tile != orig_tile and new_tile not in tiles: - # tiles.add(new_tile) - # return tiles - # - # @staticmethod - # def _get_zoom_by_distance(dist, lat, tile_resolution=256, comparison="le"): - # """Calculate tile zoom level - # - # Return zoom level on which distance dist (in meters) >= tile - # width / tile_resolution (comparison="ge") or dist <= tile width - # / tile_resolution (comparison="le"). Calculations are performed - # for point where latitude is lat, assuming spherical earth. - # - # Return zoom level as integer.""" - # - # diam = ELLIPSOIDS["WGS-84"][0] * 1e3 * 2 - # if comparison == "le": - # convert = math.floor - # elif comparison == "ge": - # convert = math.ceil - # return convert(-math.log(dist * tile_resolution / (math.pi * diam * math.cos(math.radians(lat)))) / math.log(2)) + def search_quick(self, area, *, strict=False, zoom=None): + logging.info("Searching quick in {}".format(area)) + + tiles = area.to_tiles(self, zoom) + # TODO process tiles by multiple workers + for tile in tiles: + for block in tile.blocks: + cache = Cache.from_block(block) + if strict and cache.location not in area: + # if strict mode is on and cache is not in area + continue + else: + # can yield more caches (which are not exactly in desired area) + yield cache diff --git a/pycaching/log.py b/pycaching/log.py index 8067e76..68d54d2 100644 --- a/pycaching/log.py +++ b/pycaching/log.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 import datetime -from pycaching.errors import ValueError -from pycaching.enums import LogType as Type +import enum +from pycaching import errors from pycaching.util import parse_date # prefix _type() function to avoid colisions with log type @@ -52,7 +52,8 @@ def visited(self, visited): if _type(visited) is str: visited = parse_date(visited) elif _type(visited) is not datetime.date: - raise ValueError("Passed object is not datetime.date instance nor string containing a date.") + raise ValueError( + "Passed object is not datetime.date instance nor string containing a date.") self._visited = visited @property @@ -62,3 +63,42 @@ def author(self): @author.setter def author(self, author): self._author = author.strip() + + +class Type(enum.Enum): + found_it = "found it" + didnt_find_it = "didn't find it" + note = "write note" + publish_listing = "publish listing" + enable_listing = "enable listing" + archive = "archive" + unarchive = "unarchive" + temp_disable_listing = "temporarily disable listing" + needs_archive = "needs archived" + will_attend = "will attend" + attended = "attended" + retrieved_it = "retrieved it" + placed_it = "placed it" + grabbed_it = "grabbed it" + needs_maintenance = "needs maintenance" + owner_maintenance = "owner maintenance" + update_coordinates = "update coordinates" + discovered_it = "discovered it" + post_reviewer_note = "post reviewer note" + submit_for_review = "submit for review" + visit = "visit" + webcam_photo_taken = "webcam photo taken" + announcement = "announcement" + retract = "retract listing" + marked_missing = "marked missing" + oc_team_comment = "X1" + + @classmethod + def from_string(cls, name): + """Returns log type from its human readable name""" + name = name.strip().lower() + + try: + return cls(name) + except ValueError as e: + raise errors.ValueError("Unknown log type '{}'.".format(name)) from e diff --git a/pycaching/point.py b/pycaching/point.py deleted file mode 100644 index f2b20ac..0000000 --- a/pycaching/point.py +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env python3 - -import math -import re -import geopy -from pycaching.errors import ValueError as PycachingValueError, GeocodeError -from pycaching.util import to_decimal - - -class Point(geopy.Point): - - def __new__(cls, *args, **kwargs): - precision = kwargs.pop("precision", None) - self = super(Point, cls).__new__(cls, *args, **kwargs) - self.precision = precision - return self - - @classmethod - def from_location(cls, geocaching, location): - res = geocaching._request("api/geocode", params={"q": location}, expect="json") - - if res["status"] != "success": - raise GeocodeError(res["msg"]) - - return cls(float(res["data"]["lat"]), float(res["data"]["lng"])) - - @classmethod - def from_string(cls, string): - """Parses the coords in Degree Minutes format. Expecting: - - S 36 51.918 E 174 46.725 or - N 6 52.861 W174 43.327 - - Spaces do not matter. Neither does having the degree symbol. - - Returns a geopy.Point instance.""" - - # Make it uppercase for consistency - coords = string.upper().replace("N", " ").replace("S", " ") \ - .replace("E", " ").replace("W", " ").replace("+", " ") - - try: - m = re.match(r"\s*(-?\s*\d+)\D+(\d+[\.,]\d+)\D?\s*(-?\s*\d+)\D+(\d+[\.,]\d+)", coords) - - latDeg, latMin, lonDeg, lonMin = [float(part.replace(" ", "").replace(",", ".")) for part in m.groups()] - - if "S" in string: - latDeg *= -1 - if "W" in string: - lonDeg *= -1 - - return cls(to_decimal(latDeg, latMin), to_decimal(lonDeg, lonMin)) - - except AttributeError: - pass - - # fallback - try: - return super(cls, cls).from_string(string) - except ValueError as e: - # wrap possible error to pycaching.errors.ValueError - raise PycachingValueError() from e - - @classmethod - def from_block(cls, block): - return cls.from_tile(block.tile, block.middle_point) - - @classmethod - def from_tile(cls, tile, point=None): - """Calculate location from web map tile coordinates - - Parameter represents a map tile coordinates as specified in - Google Maps JavaScript API [1]. Optional parameter point - determine position inside the tile, starting from northwest - corner. Its x and y coords are in range [0, tile.size]. - This code is modified from OpenStreetMap Wiki [2]. - - [1] https://developers.google.com/maps/documentation/javascript/maptypes#MapCoordinates - [2] http://wiki.openstreetmap.org/wiki/Slippy_map_tilenames - """ - - if point: - dx = float(point.x) / tile.size - dy = float(point.y) / tile.size - else: - dx, dy = 0, 0 - - n = 2.0 ** tile.z - lon_deg = (tile.x + dx) / n * 360.0 - 180.0 - lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * (tile.y + dy) / n))) - lat_deg = math.degrees(lat_rad) - p = cls(lat_deg, lon_deg) - p.precision = p.precision_from_tile_zoom(tile.z, tile.size) - return p - - def to_tile_coords(self, zoom, fractions=False): - """Calculate web map tile where point is located - - Return x, y, z. If fractions, return x and y as floats. This - code is modified from OpenStreetMap Wiki [1]. - - [1] http://wiki.openstreetmap.org/wiki/Slippy_map_tilenames - - """ - lat_deg = self.latitude - lon_deg = self.longitude - lat_rad = math.radians(lat_deg) - n = 2.0 ** zoom - xtile = (lon_deg + 180.0) / 360.0 * n - ytile = (1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * n - if not fractions: - xtile = int(xtile) - ytile = int(ytile) - return xtile, ytile, zoom - - def precision_from_tile_zoom(self, z, divisor=256): - """Calculate (x-axis) coordinate precision from web map tile - - Parameters z is map zoom value. Divisor denotes how many pixels - there are in a tile. Assume spherical earth. - - Return precision in meters. - - """ - lat = self.latitude - diam = geopy.distance.ELLIPSOIDS["WGS-84"][0] * 1e3 * 2 - tile_length = math.pi * diam * math.cos(math.radians(lat)) * 2 ** (-z) - return tile_length / divisor - - def distance(self, point): - """Return distance from this point to another point in meters""" - return geopy.distance.distance(self, point).meters - - def inside_area(self, area): - """Check if point is inside given area""" - return area.inside_area(self) - - def __format__(self, format_spec): - return "{:{}}".format(str(self), format_spec) diff --git a/pycaching/util.py b/pycaching/util.py index 0877333..28ef3b4 100644 --- a/pycaching/util.py +++ b/pycaching/util.py @@ -20,7 +20,8 @@ def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except AttributeError: - logging.debug("Lazy loading {} into ".format(func.__name__, type(self), id(self))) + logging.debug("Lazy loading {} into ".format( + func.__name__, type(self), id(self))) self.load() return func(*args, **kwargs) # try to return it again @@ -32,16 +33,6 @@ def rot13(text): return str.translate(text, _rot13codeTable) -def to_decimal(deg, min): - """Returns a decimal interpretation of coordinate in MinDec format.""" - return round(deg + min / 60, 5) - - -def to_mindec(decimal): - """Returns a DecMin interpretation of coordinate in decimal format.""" - return int(decimal), round(60 * (decimal - int(decimal)), 3) - - def parse_date(raw): """Returns parsed date.""" raw = raw.strip() @@ -60,7 +51,8 @@ def parse_date(raw): def get_possible_attributes(): """Returns dict of all possible attributes parsed from Groundspeak's website.""" - # imports are here not to slow down other parts of program which normally doesn't use this method + # imports are here not to slow down other parts of program which normally + # doesn't use this method from itertools import chain import requests from bs4 import BeautifulSoup diff --git a/test/test_area.py b/test/test_area.py deleted file mode 100644 index ceed59b..0000000 --- a/test/test_area.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 - -import unittest - -from pycaching.point import Point -from pycaching.area import Polygon, Rectangle - - -class TestPolygon(unittest.TestCase): - - def setUp(self): - self.p = Polygon(*[Point(*i) for i in [ - (10., 20.), (30., -5.), (-10., -170.), (-70., 0.), (0., 40)]]) - - def test_bounding_box(self): - bb = self.p.bounding_box - sw, ne = bb.corners - with self.subTest("Minimum latitude"): - self.assertEqual(sw.latitude, -70.) - with self.subTest("Minimum longitude"): - self.assertEqual(sw.longitude, -170.) - with self.subTest("Maximum latitude"): - self.assertEqual(ne.latitude, 30.) - with self.subTest("Maximum longitude"): - self.assertEqual(ne.longitude, 40.) - - def test_mean_point(self): - mp = self.p.mean_point - with self.subTest("latitude"): - self.assertEqual(mp.latitude, -8.0) - with self.subTest("longitude"): - self.assertEqual(mp.longitude, -23.0) - - def test_diagonal(self): - self.assertAlmostEqual(self.p.diagonal, 15174552.821484847) - - -class TestRectangle(unittest.TestCase): - - def setUp(self): - self.rect = Rectangle(Point(10., 20.), Point(30., -5.)) - - def test_inside_area(self): - inside_points = [Point(*i) for i in [ - (10., 20.), (30., -5.), (18., 15.), (29., -1), (10., -3)]] - outside_points = [Point(*i) for i in [ - (-10., -170.), (-70., 0.), (0., 40), (20., -10.), (50., 0.)]] - for point_list, test_func in [(inside_points, 'assertTrue'), - (outside_points, 'assertFalse')]: - for p in point_list: - with self.subTest("Area -> point: {}".format(p)): - getattr(self, test_func)(self.rect.inside_area(p)) - with self.subTest("Point -> area: {}".format(p)): - getattr(self, test_func)(p.inside_area(self.rect)) - - def test_diagonal(self): - self.assertAlmostEqual(self.rect.diagonal, 3411261.6697135763) diff --git a/test/test_cache.py b/test/test_cache.py index c1d183d..c5dea80 100644 --- a/test/test_cache.py +++ b/test/test_cache.py @@ -4,11 +4,10 @@ from unittest import mock from datetime import date from pycaching.errors import ValueError as PycachingValueError, LoadError, PMOnlyException -from pycaching.enums import Type, Size, LogType -from pycaching.cache import Cache +from pycaching.cache import Cache, Type, Size from pycaching.geocaching import Geocaching -from pycaching.point import Point -from pycaching.log import Log +from pycaching.geo import Point +from pycaching.log import Log, Type as LogType from test.test_geocaching import _username, _password diff --git a/test/test_tile.py b/test/test_geo.py similarity index 50% rename from test/test_tile.py rename to test/test_geo.py index bcce0e3..ca1e4ff 100644 --- a/test/test_tile.py +++ b/test/test_geo.py @@ -1,14 +1,15 @@ #!/usr/bin/env python3 import logging -import unittest import json +import unittest from unittest import mock from os import path - +from geopy.distance import great_circle from pycaching import Geocaching, Cache -from pycaching.tile import Tile, Block, UTFGridPoint -from pycaching.errors import BadBlockError +from pycaching.geo import Point, Polygon, Rectangle, Tile, UTFGridPoint, Block +from pycaching.geo import to_decimal, to_mindec +from pycaching.errors import GeocodeError, BadBlockError from test.test_geocaching import _username, _password @@ -16,9 +17,169 @@ _sample_utfgrid_file = path.join(path.dirname(__file__), "sample_utfgrid.json") +def make_tile(x, y, z, a=0, b=0, size=256): + t = Tile(None, x, y, z) + t.size = size + return t, UTFGridPoint(a, b) + + +class TestPoint(unittest.TestCase): + + def test_from_string(self): + with self.subTest("normal"): + self.assertEqual(Point.from_string("N 49 45.123 E 013 22.123"), + Point(49.75205, 13.36872)) + + with self.subTest("south and west"): + self.assertEqual(Point.from_string("S 49 45.123 W 013 22.123"), + Point(-48.24795, -12.63128)) + + with self.subTest("letter together"): + self.assertEqual(Point.from_string("N49 45.123 E013 22.123"), Point(49.75205, 13.36872)) + + with self.subTest("letter after"): + self.assertEqual(Point.from_string("49N 45.123 013E 22.123"), Point(49.75205, 13.36872)) + + with self.subTest("south and west letter after"): + self.assertEqual(Point.from_string("49S 45.123 013W 22.123"), + Point(-48.24795, -12.63128)) + + with self.subTest("decimal separator: coma"): + self.assertEqual(Point.from_string("N 49 45,123 E 013 22,123"), + Point(49.75205, 13.36872)) + + with self.subTest("degree symbol"): + self.assertEqual(Point.from_string("N 49° 45.123 E 013° 22.123"), + Point(49.75205, 13.36872)) + + with self.subTest("coma between lat and lon"): + self.assertEqual(Point.from_string("N 49 45.123, E 013 22.123"), + Point(49.75205, 13.36872)) + + with self.subTest("marginal values: zeroes"): + self.assertEqual(Point.from_string("N 49 45.000 E 13 0.0"), Point(49.75, 13.0)) + + with self.subTest("include precision"): + self.assertIn("precision", Point(49.75, 13.0).__dict__) + + with self.assertRaises(ValueError): + Point.from_string("123") + + def test_from_location(self): + gc = Geocaching() + gc.login(_username, _password) + + ref_point = Point(49.74774, 13.37752) + + with self.subTest("existing location"): + self.assertLess(great_circle(Point.from_location(gc, "Pilsen"), ref_point).miles, 10) + self.assertLess(great_circle(Point.from_location(gc, "Plzeň"), ref_point).miles, 10) + self.assertLess(great_circle(Point.from_location(gc, "plzen"), ref_point).miles, 10) + + with self.subTest("non-existing location"): + with self.assertRaises(GeocodeError): + Point.from_location(gc, "qwertzuiop") + + with self.subTest("empty request"): + with self.assertRaises(GeocodeError): + Point.from_location(gc, "") + + def test_from_tile(self): + """Test coordinate creation from tile""" + p = Point.from_tile(*make_tile(8800, 5574, 14, 0, 0, 256)) + p_pos = Point(49.752879934150215, 13.359375, 0.0) + + p2 = Point.from_tile(*make_tile(8801, 5575, 14, 0, 0, 256)) + p_half = Point.from_tile(*make_tile(8800, 5574, 14, 1, 1, 2)) + + # Check creation + for att in ['latitude', 'longitude']: + with self.subTest("assumed location: {}".format(att)): + self.assertAlmostEqual(getattr(p, att), getattr(p_pos, att)) + + with self.subTest("fractional tiles: y-axis addition"): + self.assertEqual(Point.from_tile(*make_tile(8800, 5574, 14, 0, 32, 32)), + Point.from_tile(*make_tile(x=8800, y=5575, z=14))) + with self.subTest("fractional tiles: x-axis addition"): + self.assertAlmostEqual(Point.from_tile(*make_tile(8800, 5574, 14, 32, 0, 32)), + Point.from_tile(*make_tile(x=8801, y=5574, z=14))) + with self.subTest("fractional tiles: addition on both axes"): + self.assertEqual(Point.from_tile(*make_tile(8800, 5574, 14, 32, 32, 32)), p2) + + with self.subTest("y increases -> latitude decreases"): + self.assertGreater(p.latitude, p_half.latitude) + with self.subTest("x increases -> latitude increases"): + self.assertLess(p.longitude, p_half.longitude) + + def test_to_tile(self): + t = make_tile(8800, 5574, 14)[0] + point_in_t = Point(49.75, 13.36) + + with self.subTest("from tile and back"): + self.assertEqual(Point.from_tile(t).to_tile(None, t.z), t) + + with self.subTest("random point"): + self.assertEqual(point_in_t.to_tile(None, 14), t) + + with self.subTest("increase in latitude: decrease in y value"): + self.assertLess(Point(50., 13.36).to_tile(None, 14).y, t.y) + + with self.subTest("increase in longitude: increase in x value"): + self.assertGreater(Point(49.75, 14.).to_tile(None, 14).x, t.x) + + +class TestPolygon(unittest.TestCase): + + def setUp(self): + self.p = Polygon(*[Point(*i) for i in [ + (10., 20.), (30., -5.), (-10., -170.), (-70., 0.), (0., 40)]]) + + def test_bounding_box(self): + bb = self.p.bounding_box + sw, ne = bb.corners + with self.subTest("Minimum latitude"): + self.assertEqual(sw.latitude, -70.) + with self.subTest("Minimum longitude"): + self.assertEqual(sw.longitude, -170.) + with self.subTest("Maximum latitude"): + self.assertEqual(ne.latitude, 30.) + with self.subTest("Maximum longitude"): + self.assertEqual(ne.longitude, 40.) + + def test_mean_point(self): + mp = self.p.mean_point + with self.subTest("latitude"): + self.assertEqual(mp.latitude, -8.0) + with self.subTest("longitude"): + self.assertEqual(mp.longitude, -23.0) + + def test_diagonal(self): + self.assertAlmostEqual(self.p.diagonal, 15174552.821484847) + + +class TestRectangle(unittest.TestCase): + + def setUp(self): + self.rect = Rectangle(Point(10., 20.), Point(30., -5.)) + + def test_contains(self): + inside_points = [Point(*i) + for i in [(10., 20.), (30., -5.), (18., 15.), (29., -1), (10., -3)]] + outside_points = [Point(*i) for i in [(-10., -170.), (-70., 0.), + (0., 40), (20., -10.), (50., 0.)]] + for p in inside_points: + self.assertTrue(p in self.rect) + for p in outside_points: + self.assertFalse(p in self.rect) + + def test_diagonal(self): + self.assertAlmostEqual(self.rect.diagonal, 3411261.6697135763) + + class TestTile(unittest.TestCase): - # see http://gis.stackexchange.com/questions/8650/how-to-measure-the-accuracy-of-latitude-and-longitude + # see + # http://gis.stackexchange.com/questions/8650/how-to-measure-the-accuracy-of-latitude-and-longitude POSITION_ACCURANCY = 3 # = up to 110 meters @classmethod @@ -54,14 +215,34 @@ def test_blocks(self, mock_utfgrid): expected_caches[wp] = float(lat), float(lon), bool(int(pm_only)) for b in self.tile.blocks: - c = Cache.from_block(self.gc, b) + c = Cache.from_block(b) self.assertIn(c.wp, expected_caches) if not expected_caches[c.wp][2]: # if not PM only - self.assertAlmostEqual(c.location.latitude, expected_caches[c.wp][0], self.POSITION_ACCURANCY) - self.assertAlmostEqual(c.location.longitude, expected_caches[c.wp][1], self.POSITION_ACCURANCY) + self.assertAlmostEqual(c.location.latitude, expected_caches[ + c.wp][0], self.POSITION_ACCURANCY) + self.assertAlmostEqual(c.location.longitude, expected_caches[ + c.wp][1], self.POSITION_ACCURANCY) expected_caches.pop(c.wp) self.assertEqual(len(expected_caches), 0) + def test_precision(self): + with self.subTest("with point coorection"): + t1 = make_tile(0, 0, 14)[0] + p = Point(49.75, 13.36) + self.assertAlmostEqual(t1.precision(p), 6.173474613462484) + + with self.subTest("precision is larger on greater z values"): + t1 = make_tile(0, 0, 13)[0] + t2 = make_tile(0, 0, 14)[0] + self.assertGreater(t1.precision(), t2.precision()) + + with self.subTest("precision is larger when tile is divided to smaller pieces"): + t1 = make_tile(0, 0, 14)[0] + t1.size = 10 + t2 = make_tile(0, 0, 14)[0] + t2.size = 100 + self.assertGreater(t1.precision(), t2.precision()) + class TestBlock(unittest.TestCase): @@ -220,3 +401,12 @@ def test_get_corrected_limits(self): with self.subTest("{} points, Y axis".format(i)): self.assertEqual(self.b._get_corrected_limits(self.b._ylim), ref_ylim) + + +class TestModule(unittest.TestCase): + + def test_coord_conversion(self): + self.assertEqual(to_decimal(49, 43.850), 49.73083) + self.assertEqual(to_decimal(13, 22.905), 13.38175) + self.assertEqual(to_mindec(13.38175), (13, 22.905)) + self.assertEqual(to_mindec(49.73083), (49, 43.850)) diff --git a/test/test_geocaching.py b/test/test_geocaching.py index e625c8e..b60ca30 100644 --- a/test/test_geocaching.py +++ b/test/test_geocaching.py @@ -3,16 +3,16 @@ # import os import unittest import pycaching -from pycaching import Geocaching, Point -from pycaching.errors import NotLoggedInException, LoginFailedException -# from pycaching.area import Rectangle +import itertools +from pycaching import Geocaching, Point, Rectangle +from pycaching.errors import NotLoggedInException, LoginFailedException, PMOnlyException # please DO NOT CHANGE! _username, _password = "cache-map", "pGUgNw59" -class TestLoading(unittest.TestCase): +class TestMethods(unittest.TestCase): @classmethod def setUpClass(cls): @@ -30,125 +30,44 @@ def test_search(self): caches = list(self.g.search(Point(49.733867, 13.397091), 100)) self.assertNotEqual(caches[0], caches[50]) - # def test_search_quick(self): - # """Perform search and check found caches""" - # rect = Rectangle(Point(49.73, 13.38), Point(49.74, 13.40)) - # caches = list(self.g.search_quick(rect)) - # strict_caches = list(self.g.search_quick(rect, strict=True)) - # precise_caches = list(self.g.search_quick(rect, precision=45.)) - # - # # Check for known geocaches - # expected = ["GC41FJC", "GC17E8Y", "GC5ND9F"] - # for i in expected: - # found = False - # for c in caches: - # if c.wp == i: - # found = True - # break - # with self.subTest("Check if {} is in results".format(c.wp)): - # self.assertTrue(found) - # - # with self.subTest("Precision is in assumed range"): - # self.assertLess(caches[0].location.precision, 49.5) - # self.assertGreater(caches[0].location.precision, 49.3) - # - # with self.subTest("Found roughly correct amount of caches"): - # # At time of writing, there were 108 caches inside inspected tile - # self.assertLess(len(caches), 130) - # self.assertGreater(len(caches), 90) - # - # with self.subTest("Strict handling of cache coordinates"): - # # ...but only 12 inside this stricter area - # self.assertLess(len(strict_caches), 16) - # self.assertGreater(len(strict_caches), 7) - # - # with self.subTest("Precision grows when asking for it"): - # self.assertLess(precise_caches[0].location.precision, 45.) - # - # def test_calculate_initial_tiles(self): - # expect_tiles = [(2331, 1185, 12), (2331, 1186, 12), - # (2332, 1185, 12), (2332, 1186, 12)] - # expect_precision = 76.06702024121832 - # r = Rectangle(Point(60.15, 24.95), Point(60.17, 25.00)) - # tiles, starting_precision = self.g._calculate_initial_tiles(r) - # for t in tiles: - # with self.subTest("Tile {} expected as initial tile".format(t)): - # self.assertIn(t, expect_tiles) - # with self.subTest("Expected precision"): - # self.assertAlmostEqual(starting_precision, expect_precision) - # - # def test_get_utfgrid_caches(self): - # """Load tiles and check if expected caches are found""" - # - # # load expected result - # file_path = os.path.join(os.path.dirname(__file__), "sample_caches.csv") - # expected_caches = set() - # with open(file_path) as f: - # for row in f: - # wp = row.split(',')[0] - # expected_caches.add(wp) - # n_orig = len(expected_caches) - # - # # load search result - # additional_caches = set() - # for c in self.g._get_utfgrid_caches((8800, 5574, 14),): - # if c.wp in expected_caches: - # expected_caches.discard(c.wp) - # else: - # additional_caches.add(c.wp) - # - # with self.subTest("Expected caches found"): - # self.assertLess(len(expected_caches) / n_orig, 0.2, - # "Over 20 % of expected caches are lost.") - # - # with self.subTest("Unexpected caches not found"): - # self.assertLess(len(additional_caches) / n_orig, 0.2, - # "Over 20 % of found caches are unexpected.") - # - # def test_bordering_tiles(self): - # """Check if geocache is near tile border""" - # # description, function parameters, set of bordering tiles - # checks = [ - # ["Not on border", (8800.3, 5575.4, 14), set()], - # ["Not on border", (8800.3, 5575.4, 14, 0.2), set()], - # ["Now inside border", (8800.3, 5575.4, 14, 0.31), - # {(8799, 5575, 14)}], - # ["Also inside border", (8800.05, 5575.4, 14), {(8799, 5575, 14)}], - # ["Inside another border", (8800.3, 5575.95, 14), - # {(8800, 5576, 14)}], - # ["A corner", (8800.05, 5575.95, 14), - # {(8799, 5576, 14), (8800, 5576, 14), (8799, 5575, 14)}] - # ] - # for description, params, output in checks: - # with self.subTest(description): - # self.assertEqual(self.g._bordering_tiles(*params), output) - # - # def test_get_zoom_by_distance(self): - # """Check that calculated zoom levels are correct""" - # with self.subTest("World map zoom level"): - # self.assertEqual( - # self.g._get_zoom_by_distance(40e6, 0., 1., 'le'), 0) - # - # with self.subTest("Next level"): - # self.assertEqual( - # self.g._get_zoom_by_distance(40e6, 0., 1., 'ge'), 1) - # - # with self.subTest("Tile width greater or equal to 1 km"): - # self.assertEqual( - # self.g._get_zoom_by_distance(1e3, 49., 1., 'le'), 14) - # - # with self.subTest("More accurate than 10 m"): - # self.assertEqual( - # self.g._get_zoom_by_distance(10., 49., 256, 'ge'), 14) - # - # with self.subTest("Previous test was correct"): - # p = Point(49., 13.) - # self.assertGreater(p.precision_from_tile_zoom(13), 10) - # self.assertLess(p.precision_from_tile_zoom(14), 10) + def test_search_quick(self): + """Perform search and check found caches""" + # at time of writing, there were exactly 16 caches in this area + one PM only + expected_cache_num = 16 + rect = Rectangle(Point(49.73, 13.38), Point(49.74, 13.40)) - @classmethod - def tearDownClass(cls): - cls.g.logout() + with self.subTest("normal"): + res = [c.wp for c in self.g.search_quick(rect)] + for wp in ["GC41FJC", "GC17E8Y", "GC5ND9F"]: + self.assertIn(wp, res) + # but 108 caches larger tile + self.assertLess(len(res), 130) + self.assertGreater(len(res), 90) + + with self.subTest("strict handling of cache coordinates"): + res = list(self.g.search_quick(rect, strict=True)) + self.assertLess(len(res), expected_cache_num + 5) + self.assertGreater(len(res), expected_cache_num - 5) + + with self.subTest("larger zoom - more precise"): + res1 = list(self.g.search_quick(rect, strict=True, zoom=15)) + res2 = list(self.g.search_quick(rect, strict=True, zoom=14)) + for res in res1, res2: + self.assertLess(len(res), expected_cache_num + 5) + self.assertGreater(len(res), expected_cache_num - 5) + for c1, c2 in itertools.product(res1, res2): + self.assertLess(c1.location.precision, c2.location.precision) + + def test_search_quick_match_load(self): + """Test if search results matches exact cache locations.""" + rect = Rectangle(Point(49.73, 13.38), Point(49.74, 13.39)) + caches = list(self.g.search_quick(rect, strict=True, zoom=15)) + for cache in caches: + try: + cache.load() + self.assertIn(cache.location, rect) + except PMOnlyException: + pass class TestLoginOperations(unittest.TestCase): @@ -185,9 +104,6 @@ def test_logout(self): self.g.logout() self.assertIsNone(self.g.get_logged_user()) - def tearDown(self): - self.g.logout() - class TestPackage(unittest.TestCase): diff --git a/test/test_log.py b/test/test_log.py index 951fea6..0b69920 100644 --- a/test/test_log.py +++ b/test/test_log.py @@ -2,8 +2,7 @@ import unittest from datetime import date -from pycaching.log import Log -from pycaching.enums import LogType as Type +from pycaching.log import Log, Type from pycaching.errors import ValueError as PycachingValueError diff --git a/test/test_point.py b/test/test_point.py deleted file mode 100644 index a1872f0..0000000 --- a/test/test_point.py +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env python3 - -import unittest -from geopy.distance import great_circle -from pycaching import Geocaching, Point -from pycaching.tile import Tile, UTFGridPoint -from pycaching.errors import GeocodeError - -from test.test_geocaching import _username, _password - - -class TestPoint(unittest.TestCase): - - def test_from_string(self): - with self.subTest("normal"): - self.assertEqual(Point.from_string("N 49 45.123 E 013 22.123"), - Point(49.75205, 13.36872)) - - with self.subTest("south and west"): - self.assertEqual(Point.from_string("S 49 45.123 W 013 22.123"), - Point(-48.24795, -12.63128)) - - with self.subTest("letter together"): - self.assertEqual(Point.from_string("N49 45.123 E013 22.123"), Point(49.75205, 13.36872)) - - with self.subTest("letter after"): - self.assertEqual(Point.from_string("49N 45.123 013E 22.123"), Point(49.75205, 13.36872)) - - with self.subTest("south and west letter after"): - self.assertEqual(Point.from_string("49S 45.123 013W 22.123"), - Point(-48.24795, -12.63128)) - - with self.subTest("decimal separator: coma"): - self.assertEqual(Point.from_string("N 49 45,123 E 013 22,123"), - Point(49.75205, 13.36872)) - - with self.subTest("degree symbol"): - self.assertEqual(Point.from_string("N 49° 45.123 E 013° 22.123"), - Point(49.75205, 13.36872)) - - with self.subTest("coma between lat and lon"): - self.assertEqual(Point.from_string("N 49 45.123, E 013 22.123"), - Point(49.75205, 13.36872)) - - with self.subTest("marginal values: zeroes"): - self.assertEqual(Point.from_string("N 49 45.000 E 13 0.0"), Point(49.75, 13.0)) - - with self.subTest("Include precision"): - self.assertIn("precision", Point(49.75, 13.0).__dict__) - - with self.assertRaises(ValueError): - Point.from_string("123") - - def test_from_location(self): - gc = Geocaching() - gc.login(_username, _password) - - ref_point = Point(49.74774, 13.37752) - - with self.subTest("existing location"): - self.assertLess(great_circle(Point.from_location(gc, "Pilsen"), ref_point).miles, 10) - self.assertLess(great_circle(Point.from_location(gc, "Plzeň"), ref_point).miles, 10) - self.assertLess(great_circle(Point.from_location(gc, "plzen"), ref_point).miles, 10) - - with self.subTest("non-existing location"): - with self.assertRaises(GeocodeError): - Point.from_location(gc, "qwertzuiop") - - with self.subTest("empty request"): - with self.assertRaises(GeocodeError): - Point.from_location(gc, "") - - @staticmethod - def make_tile(x, y, z, a=0, b=0, size=256): - t = Tile(None, x, y, z) - t.size = size - return t, UTFGridPoint(a, b) - - def test_from_tile(self): - """Test coordinate creation from tile""" - p = Point.from_tile(*self.make_tile(8800, 5574, 14, 0, 0, 256)) - p_pos = Point(49.752879934150215, 13.359375, 0.0) - - p2 = Point.from_tile(*self.make_tile(8801, 5575, 14, 0, 0, 256)) - p_half = Point.from_tile(*self.make_tile(8800, 5574, 14, 1, 1, 2)) - - # Check creation - for att in ['latitude', 'longitude']: - with self.subTest("Assumed location: {}".format(att)): - self.assertAlmostEqual(getattr(p, att), getattr(p_pos, att)) - - with self.subTest("Fractional tiles: y-axis addition"): - self.assertEqual(Point.from_tile(*self.make_tile(8800, 5574, 14, 0, 32, 32)), - Point.from_tile(*self.make_tile(x=8800, y=5575, z=14))) - with self.subTest("Fractional tiles: x-axis addition"): - self.assertAlmostEqual(Point.from_tile(*self.make_tile(8800, 5574, 14, 32, 0, 32)), - Point.from_tile(*self.make_tile(x=8801, y=5574, z=14))) - with self.subTest("Fractional tiles: addition on both axes"): - self.assertEqual(Point.from_tile(*self.make_tile(8800, 5574, 14, 32, 32, 32)), p2) - - with self.subTest("y increases -> latitude decreases"): - self.assertGreater(p.latitude, p_half.latitude) - with self.subTest("x increases -> latitude increases"): - self.assertLess(p.longitude, p_half.longitude) - - def test_to_tile_coords(self): - t = (8800, 5574, 14) - point_in_t = Point(49.75, 13.36) - - with self.subTest("From tile and back"): - self.assertEqual(Point.from_tile(self.make_tile(*t)[0]).to_tile_coords(t[-1]), t) - - with self.subTest("Random point"): - self.assertEqual(point_in_t.to_tile_coords(14), t) - - with self.subTest("Increase in latitude: decrease in y value"): - self.assertLess(Point(50., 13.36).to_tile_coords(14)[1], t[1]) - - with self.subTest("Increase in longitude: increase in x value"): - self.assertGreater(Point(49.75, 14.).to_tile_coords(14)[0], t[0]) - - def test_precision_from_tile_zoom(self): - p = Point(49.75, 13.36) - - with self.subTest("Random point"): - self.assertAlmostEqual(p.precision_from_tile_zoom(14), - 6.173474613462484) - - with self.subTest("Precision is larger on greater Z values"): - self.assertGreater(p.precision_from_tile_zoom(13), - p.precision_from_tile_zoom(14)) - - with self.subTest("Precision is larger when tile is divided less"): - self.assertGreater(p.precision_from_tile_zoom(14, 10), - p.precision_from_tile_zoom(14, 100)) - - def test_distance(self): - p1, p2 = Point(60.15, 24.95), Point(60.17, 25.00) - self.assertAlmostEqual(p1.distance(p2), 3560.1077441805196) - - def test_inside_area(self): - # This is already tested in test_area.py - pass diff --git a/test/test_util.py b/test/test_util.py index 2bae414..290f93c 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -3,21 +3,15 @@ import unittest import datetime import itertools -from pycaching.util import rot13, to_decimal, to_mindec, parse_date, get_possible_attributes +from pycaching.util import rot13, parse_date, get_possible_attributes -class TestUtil(unittest.TestCase): +class TestModule(unittest.TestCase): def test_rot13(self): self.assertEqual(rot13("Text"), "Grkg") self.assertEqual(rot13("abc'ř"), "nop'ř") - def test_coord_conversion(self): - self.assertEqual(to_decimal(49, 43.850), 49.73083) - self.assertEqual(to_decimal(13, 22.905), 13.38175) - self.assertEqual(to_mindec(13.38175), (13, 22.905)) - self.assertEqual(to_mindec(49.73083), (49, 43.850)) - def test_date_parsing(self): dates = (datetime.date(2014, 1, 30), datetime.date(2000, 1, 1), datetime.date(2020, 12, 13)) patterns = ("%Y-%m-%d", "%Y/%m/%d", "%m/%d/%Y", "%d/%m/%Y",