Source code for gpxity.diff

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# Copyright (c) 2019 Wolfgang Rohdewald <wolfgang@rohdewald.de>
# See LICENSE for details.

"""This module defines :class:`~gpxity.diff.BackendDiff`."""

# pylint: disable=protected-access

import datetime

from collections import defaultdict
from difflib import SequenceMatcher

from .backend import Backend
from .gpxfile import GpxFile

__all__ = ['BackendDiff']


[docs]class BackendDiff: """Compares two backends.directory. Args: left (Backend): A backend, a gpxfile or a list of either right (Backend): Same as for the left side Attributes: left(:class:`BackendDiffSide`): Attributes for the left side right(:class:`BackendDiffSide`): Attributes for the right side identical(list(GpxFile)): Tracks appearing on both sides. similar(list(Pair)): Pairs of Tracks are on both sides with differences. This includes all gpxfiles having at least 100 identical positions without being identical. diff_flags: T=time, D=description, C=category, S=status, K=keywords, P=positions, Z=time offset """ # pylint: disable=too-few-public-methods diff_flags = 'TDCSKPZ'
[docs] class Pair: """Holds two comparable Items and the diff result Attributes: differences(dict()): Keys are Flags for differences, see BackendDiff.diff_flags. Values is a list(str) with additional info """ def __init__(self, left, right): """See class docstring.""" self.left = left self.right = right self.differences = self.__compare() def __compare_metadata(self): """Compare some metadata between left and right. Returns: a dict with the differences """ result = defaultdict(list) def compare_attribute(code, attribute, func=None): """Compare a specific attribute.""" left_value = getattr(self.left, attribute) or '' right_value = getattr(self.right, attribute) or '' if func: left_value = func(left_value) right_value = func(right_value) if left_value != right_value: result[code].append('"{}" <> "{}"'.format(left_value, right_value)) compare_attribute('T', 'title') compare_attribute('D', 'description') compare_attribute('C', 'category') compare_attribute('K', 'keywords', lambda x: ', '.join(x)) # pylint: disable=unnecessary-lambda compare_attribute('S', 'public', lambda x: 'public' if x else 'private') return result def __compare(self): # noqa """Compare both gpxfiles. Returns: defaultdict(list): Keys are Flags for differences, see BackendDiff.diff_flags. Values is a list(str) with additional info """ # pylint: disable=too-many-locals, too-many-branches, too-many-nested-blocks result = self.__compare_metadata() def lists(gpxfile): """Returns two lists of tuples: once with time, once without time.""" times = list() positions = list() for _ in gpxfile.points(): times.append(_.time or datetime.datetime(year=1970, month=1, day=1)) positions.append(tuple([_.latitude or 0, _.longitude or 0, _.elevation or 0])) # noqa return times, positions def pretty_times(time1, time2): """If time2 has the same date, use only the time.""" if time1.date() == time2.date(): time2 = time2.time() return time1, time2 left_times, left_positions = lists(self.left) right_times, right_positions = lists(self.right) for tag, left_start, left_end, right_start, right_end in SequenceMatcher( None, left_positions, right_positions).get_opcodes(): left_found = left_positions[left_start:left_end] right_found = right_positions[right_start:right_end] for idx, _ in enumerate(left_found): left_found[idx] = list(_) left_found[idx].append(left_times[left_start + idx]) for idx, _ in enumerate(right_found): right_found[idx] = list(_) right_found[idx].append(right_times[right_start + idx]) if tag == 'delete': result['P'].append( 'points between {} and {} are missing on the right'.format( *pretty_times(left_times[left_start], left_times[left_end - 1]))) elif tag == 'insert': result['P'].append( 'points between {} and {} are missing on the left'.format( *pretty_times(right_times[right_start], right_times[right_end - 1]))) elif tag == 'replace': if [x[:2] for x in left_found] == [x[:2] for x in right_found]: if len( {(right_found[x][3] - left_found[x][3]) for x in range(len(left_found))}) == 1: time1, time2 = pretty_times(left_found[0][3], left_found[-1][3]) timedelta = right_found[0][3] - left_found[0][3] if timedelta: result['Z'].append( '{} points between {} and {} on the left are {} later on the right'.format( len(left_found), time1, time2, timedelta)) else: # if points are different but times are the same, it must be the height. Ignore that. pass else: result['Z'].append('Points have different times') else: result['P'].append( 'points between {} and {} are different'.format( *pretty_times( min([left_times[left_start], right_times[right_start]]), max([left_times[left_end - 1], right_times[right_end - 1]])))) for left, right in zip(left_found, right_found): for data, sign in ((left, '<'), (right, '>')): result['P'].append( ' {sign} {data[0]:8.6f} {data[1]:8.6f} {data[2]:5.2f} {data[3]}'.format( sign=sign, data=data)) # some files have a problem with the time zone _ = self.left.time_offset(self.right) if _: result['Z'].append('Time offset: {}'.format(_)) return result
[docs] class BackendDiffSide: """Represents a side (left or right) in BackendDiff. Attributes: gpxfiles: An GpxFile, a list of gpxfiles, a backend or a list of backends exclusive(list): Acivities existing only on this side """ # pylint: disable=too-few-public-methods def __init__(self, gpxfiles): """See class docstring.""" self.gpxfiles = list(self.flatten(gpxfiles)) self.build_positions() self.exclusive = []
[docs] @staticmethod def flatten(whatever): """Flatten Backends or Tracks into a list of gpxfiles.""" if isinstance(whatever, list): for list_item in whatever: if isinstance(list_item, GpxFile): yield list_item elif isinstance(list_item, Backend): for _ in list_item: yield _ else: if isinstance(whatever, GpxFile): yield whatever elif isinstance(whatever, Backend): for _ in whatever: yield _
[docs] def build_positions(self): """Return a set of long/lat tuples.""" for _ in self.gpxfiles: _.positions = {(x.longitude, x.latitude) for x in _.points()}
def _find_exclusives(self, matched): """use data from the other side.""" for _ in self.gpxfiles: if _ not in matched: self.exclusive.append(_)
def __init__(self, left, right): """See class docstring.""" self.similar = [] self.identical = [] matched = [] self.left = BackendDiff.BackendDiffSide(left) self.right = BackendDiff.BackendDiffSide(right) # pylint: disable=too-many-nested-blocks for left_track in self.left.gpxfiles: for right_track in self.right.gpxfiles: if left_track == right_track: self.identical.append(left_track) matched.append(left_track) matched.append(right_track) else: maxlen = max(len(left_track.positions), len(right_track.positions)) if len(left_track.positions & right_track.positions) >= maxlen * 0.9: self.similar.append(BackendDiff.Pair(left_track, right_track)) matched.append(left_track) matched.append(right_track) self.left._find_exclusives(matched) self.right._find_exclusives(matched)