Source code for gpxity.backends.mmt

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

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

"""
This implements :class:`gpxity.mmt.MMT` for http://www.mapmytracks.com.

There are some problems with the server running at mapmytracks.com:
    * it is not possible to change an existing GpxFile - if the GpxFile changes, the
      GpxFile must be re-uploaded and gets a new id. This invalididates
      references held by other backend instances (maybe on other clients).
      But I could imagine that most similar services have this problem too.
    * does not support GPX very well beyond gpxfile data. One problem is that
      it does not support gpx.time, it ignores it in uploads and uses the time
      of the earliest trackpoint. To be consistent, Gpxity follows that for now
      and does not respect gpx.time either.
    * there is an official description of an API at https://github.com/MapMyTracks
      but this does not implement everything needed. For the missing parts we
      simulate what a web browser would do, see :meth:`MMT._read` and
      :meth:`MMT._write_attribute`. Of course that could fail if MMT changes its site.
      Which is true for the api itself, it can and does get incompatible changes at
      any time without notice to users or deprecation periods.
    * downloading gpxfiles with that abi is very slow and hangs forever for big gpxfiles
      (at least this was so in Feb 2017, maybe have to test again occasionally).
    * not all parts of MMT data are supported like images (not interesting for me,
      at least not now).

"""

# pylint: disable=protected-access

# TODO: logout

from xml.etree import ElementTree
import html
from html.parser import HTMLParser
import datetime
import calendar
from collections import defaultdict
import requests

from .. import Backend
from ..gpx import Gpx
from ..version import VERSION


__all__ = ['MMT']


def _convert_time(raw_time) ->datetime.datetime:
    """MMT uses Linux timestamps. Converts that into datetime.

    Args:
        raw_time (int): The linux timestamp from the MMT server

    Returns:
        The datetime

    """
    return datetime.datetime.utcfromtimestamp(float(raw_time)).replace(tzinfo=datetime.timezone.utc)


class ParseMMTCategories(HTMLParser):  # pylint: disable=abstract-method

    """Parse the legal values for category from html."""

    def __init__(self):
        """See class docstring."""
        super(ParseMMTCategories, self).__init__()
        self.seeing_category = False
        self.result = list()

    def handle_starttag(self, tag, attrs):
        """starttag from the parser."""
        # pylint: disable=too-many-branches
        attributes = dict(attrs)
        self.seeing_category = (
            tag == 'input' and 'name' in attributes and attributes['name'].startswith('add-activity'))

    def handle_data(self, data):
        """handle the data."""
        if self.seeing_category:
            _ = data.strip()
            if _ not in self.result:
                self.result.append(_)
            self.seeing_category = False


class ParseMMTTrack(HTMLParser):  # pylint: disable=abstract-method

    """get some attributes available only on the web page.

    Of course, this is highly unreliable. Just use what we can get."""

    result = dict()

    def __init__(self, backend):
        """See class docstring."""
        super(ParseMMTTrack, self).__init__()
        self.backend = backend
        self.seeing_category = False
        self.seeing_title = False
        self.seeing_description = False
        self.seeing_status = False
        self.seeing_tag = None
        self.result['mid'] = None
        self.result['title'] = None
        self.result['description'] = None
        self.result['category'] = None
        self.result['category_3'] = None
        self.result['public'] = None
        self.result['tags'] = dict()  # key: name, value: id

    def handle_starttag(self, tag, attrs):
        """starttag from the parser."""
        # pylint: disable=too-many-branches
        self.seeing_title = False
        self.seeing_description = False
        self.seeing_category = False
        self.seeing_status = False
        self.seeing_tag = None
        attributes = defaultdict(str)
        for key, value in attrs:
            attributes[key] = value
        if tag == 'input':
            value = attributes['value'].strip()
            if (attributes['id'] == 'activity_type' and attributes['type'] == 'hidden'
                    and attributes['name'] == 'activity_type' and value):  # noqa
                self.result['category_3'] = value
            elif (attributes['id'] == 'mid' and attributes['type'] == 'hidden'
                  and attributes['name'] == 'mid'and value):  # noqa
                self.result['mid'] = value
        elif tag == 'div' and attributes['class'] == 'panel' and 'data-activity' in attributes:
            self.result['category'] = attributes['data-activity']
        elif tag == 'span' and attributes['class'] == 'privacy-status':
            self.seeing_status = True
        elif tag == 'h2' and attributes['id'] == 'track-title':
            self.seeing_title = True
        elif tag == 'p' and attributes['id'] == 'track-desc':
            self.seeing_description = True
        elif tag == 'a' and attributes['class'] == 'tag-link' and attributes['rel'] == 'tag':
            assert attributes['id'].startswith('tag-')
            self.seeing_tag = attributes['id'].split('-')[2]

    def handle_data(self, data):
        """data from the parser."""
        if not data.strip():
            return
        if self.seeing_title:
            self.result['title'] = data.strip()
        if self.seeing_description:
            self.result['description'] = html.unescape(data.strip())
        if self.seeing_status:
            self.result['public'] = data.strip() != 'Only you can see this activity'
        if self.seeing_tag:
            self.result['tags'][data.strip()] = self.seeing_tag


class MMTRawTrack:

    """raw data from mapmytracks.get_tracks."""

    # pylint: disable=too-few-public-methods
    def __init__(self, xml):
        """See class docstring."""
        self.track_id = xml.find('id').text
        self.title = html.unescape(xml.find('title').text)
        self.time = _convert_time(xml.find('date').text)
        self.category = html.unescape(xml.find('activity_type').text)
        self.distance = float(xml.find('distance').text)


[docs]class MMT(Backend): """The implementation for MapMyTracks. The gpxfile ident is the number given by MapMyTracks. MMT knows tags. We map :attr:`GpxFile.keywords <gpxity.gpxfile.GpxFile.keywords>` to MMT tags. MMT will change keywords: It converts the first character to upper case. See :attr:`GpxFile.keywords <gpxity.gpxfile.GpxFile.keywords>` for how Gpxity handles this. Args: account (:class:`~gpxity.accounts.Account`): The account to be used. Alternatively a dict can be passed to build an ad hoc :class:`~gpxity.accounts.Account` instance. """ # pylint: disable=abstract-method _default_description = 'None yet. Let everyone know how you got on.' supported_categories = ( 'Cycling', 'Running', 'Mountain biking', 'Sailing', 'Walking', 'Hiking', 'Driving', 'Off road driving', 'Motor racing', 'Motorcycling', 'Enduro', 'Skiing', 'Cross country skiing', 'Canoeing', 'Kayaking', 'Sea kayaking', 'SUP boarding', 'Rowing', 'Swimming', 'Windsurfing', 'Orienteering', 'Mountaineering', 'Skating', 'Horse riding', 'Hang gliding', 'Hand cycling', 'Gliding', 'Flying', 'Kiteboarding', 'Snowboarding', 'Paragliding', 'Hot air ballooning', 'Nordic walking', 'Miscellaneous', 'Skateboarding', 'Snowshoeing', 'Jet skiing', 'Powerboating', 'Wheelchair', 'Indoor cycling') _category_decoding = { 'Cross country skiing': 'Skiing - Touring', 'Hand cycling': 'Cycling - Hand', 'Indoor cycling': 'Cycling - Indoor', 'Mountain biking': 'Cycling - MTB', 'SUP boarding': 'Stand up paddle boarding', } _category_encoding = { 'Cabriolet': 'Driving', 'Coach': 'Miscellaneous', 'Crossskating': 'Skating', 'Cycling - Foot': 'Cycling', 'Cycling - Gravel': 'Cycling', 'Cycling - Hand': 'Hand cycling', 'Cycling - Road': 'Cycling', 'Cycling - Touring': 'Cycling', 'Geocaching': 'Miscellaneous', 'Hiking - Speed': 'Hiking', 'Longboard': 'Miscellaneous', 'Motorhome': 'Driving', 'Pack animal trekking': 'Hiking', 'Pedelec': 'Cycling', 'River navigation': 'Miscellaneous', 'Running - Road': 'Miscellaneous', 'Running - Trail': 'Miscellaneous', 'Running - Urban Trail': 'Miscellaneous', 'Sightseeing': 'Miscellaneous', 'Skating - Inline': 'Skating', 'Skiing - Alpine': 'Skiing', 'Skiing - Backcountry': 'Cross country skiing', 'Skiing - Crosscountry': 'Cross country skiing', 'Skiing - Nordic': 'Cross country skiing', 'Skiing - Roller': 'Skiing', 'Stand up paddle boarding': 'SUP boarding', 'Swimrun': 'Miscellaneous', 'Train': 'Miscellaneous', 'Wintersports': 'Skiing', } default_url = 'https://www.mapmytracks.com' # MMT only accepts one simultaneous lifetracker per login. We make sure # that at least this process does not try to run several at once. # This check is now too strict: We forbid multiple lifetrackers even if # every MMT account only gets one. _current_lifetrack = None def __init__(self, account): """See class docstring.""" super(MMT, self).__init__(account) self.__mid = -1 # member id at MMT for authentication self.__tag_ids = dict() # key: tag name, value: tag id in MMT. It seems that MMT # has a lookup table and never deletes there. So a given tag will always get # the same ID. We use this fact. # MMT internally capitalizes tags but displays them lowercase. self._last_response = None # only used for debugging self.https_url = self.url.replace('http:', 'https:') def _download_legal_categories(self): """Needed only for unittest. Returns: list(str) all legal values for category. """ response = self.__get(url=self.url + '/explore/wall') category_parser = ParseMMTCategories() category_parser.feed(response.text) return sorted(category_parser.result) @property def session(self): """The requests.Session for this backend. Only initialized once. Returns: The session """ ident = str(self) if ident not in self._session: author = self._get_author() self._session[ident] = requests.Session() # I have no idea what ACT=9 does but it seems to be needed payload = {'username': author, 'password': self.account.password, 'ACT': '9'} login_url = '{}/login'.format(self.https_url) headers = {'User-Agent': 'Gpxity'} # see https://github.com/MapMyTracks/api/issues/26 response = self._session[ident].post( login_url, data=payload, headers=headers, timeout=self.timeout) if 'You are now logged in.' not in response.text: raise self.BackendException('Login as {} / {} failed, I got {}'.format( author, self.account.password, response.text)) cookies = requests.utils.dict_from_cookiejar(self._session[ident].cookies) self._session[ident].cookies = requests.utils.cookiejar_from_dict(cookies) return self._session[ident] @property def mid(self): """the member id on MMT belonging to Account. Returns: The mid """ if self.__mid == -1: self._parse_homepage() return self.__mid @property def subscription(self) ->str: """Return free or full. Returns: free or full """ if self._cached_subscription is None: self._parse_homepage() self.logger.debug('%s: subscription: %s', self.account, self._cached_subscription) if self.subscription == 'free': self.supported = self.__class__.supported - set('private', ) return self._cached_subscription def _parse_homepage(self): """Get some interesting values from the home page.""" response = self.__get(with_session=True, url=self.url) if 'Get PLUS' in response.text: self._cached_subscription = 'free' else: self._cached_subscription = 'full' page_parser = ParseMMTTrack(self) page_parser.feed(response.text) self.__mid = page_parser.result['mid'] self.__tag_ids.update(page_parser.result['tags']) self._check_tag_ids() @staticmethod def _encode_keyword(value): """mimic the changes MMT applies to tags. Returns: The changed keywords """ return ' '.join(x.capitalize() for x in value.split()) def _check_tag_ids(self): """Assert that all tags conform to what MMT likes.""" for _ in self.__tag_ids: assert _[0].upper() == _[0], self.__tag_ids def _found_tag_id(self, tag, id_): """We just learned about a new tag id. They never change for a given string.""" self.__tag_ids[tag] = id_ self._check_tag_ids() def __get(self, with_session: bool = False, url: str = None, headers=None): """Helper for the real function with some error handling. Sets User-Agent. Returns: response """ if headers is None: headers = dict() headers['User-Agent'] = 'Gpxity' self.logger.debug('MMT.__get:%s url=%s', 'with session' if with_session else '', url) if with_session: response = self.session.get(url, headers=headers, timeout=self.timeout) else: response = requests.get(url, headers=headers, timeout=self.timeout) return response def __post( # noqa self, with_session: bool = False, url: str = None, data: str = None, expect: str = None, **kwargs) ->str: """Helper for the real function with some error handling. Args: with_session: If given, use self.session. Otherwise, use basic auth. url: Will be appended to self.url. Default is api/. For the basic url, pass an empty string. data: should be xml and will be encoded. May be None. expect: If given, raise an error if this string is not part of the server answer. kwargs: a dict for post(). May be None. data and kwargs must not both be passed. Returns: the result """ # pylint: disable=too-many-branches full_url = self.url + '/' + (url if url else 'api/') headers = {'DNT': '1'} # do not gpxfile headers['User-Agent'] = 'Gpxity' # see https://github.com/MapMyTracks/api/issues/26 if not self.account.username or not self.account.password: raise self.BackendException('{}: Needs authentication data'.format(self.url)) if data: data = data.encode('ascii', 'xmlcharrefreplace') else: data = kwargs try: if with_session: response = self.session.post( full_url, data=data, headers=headers, timeout=self.timeout) else: response = requests.post( full_url, data=data, headers=headers, auth=(self.account.username, self.account.password), timeout=self.timeout) except requests.exceptions.ReadTimeout: self.logger.error('%s: timeout for %s', self, data) raise self._last_response = response # for debugging if response.status_code != requests.codes.ok: # pylint: disable=no-member self.__handle_post_error(full_url, data, response) return None result = response.text if (result == 'access denied') or (expect and expect not in result): raise self.BackendException('{}: expected {} in {}'.format(data, expect, result)) if result.startswith('<?xml'): try: result = ElementTree.fromstring(result) except ElementTree.ParseError: raise self.BackendException('POST {} has parse error: {}'.format(data, response.text)) result_type = result.find('type') if result_type is not None and result_type.text == 'error': _ = result.find('reason') if _ is not None: reason = _.text else: reason = 'no reason given' raise self.BackendException('{}: {}'.format(reason, data)) return result @classmethod def __handle_post_error(cls, url, data, result): """we got status_code != ok.""" try: result.raise_for_status() except BaseException as exc: if isinstance(data, str) and 'request' in data: _ = data['request'] else: _ = data raise cls.BackendException('{}: {} {} {}'.format(exc, url, _, result.text)) def _write_attribute(self, gpxfile, attribute): """change an attribute directly on mapmytracks. Note that we specify iso-8859-1 but use utf-8. If we correctly specify utf-8 in the xml encoding, mapmytracks.com aborts our connection.""" attr_value = getattr(gpxfile, attribute) if attribute == 'description' and attr_value == self._default_description: attr_value = '' # MMT returns 500 Server Error if we set the title to an empty string if attribute == 'title' and not attr_value: attr_value = 'no title' data = '<?xml version="1.0" encoding="ISO-8859-1"?>' \ '<message><nature>update_{attr}</nature><eid>{eid}</eid>' \ '<usr>{usrid}</usr><uid>{uid}</uid>' \ '<{attr}>{value}</{attr}></message>'.format( attr=attribute, eid=gpxfile.id_in_backend, usrid=self.account.username, value=attr_value, uid=self.session.cookies['exp_uniqueid']) self.__post(with_session=True, url='assets/php/interface.php', data=data, expect='success') def _write_title(self, gpxfile): """change title on remote server.""" self._write_attribute(gpxfile, 'title') def _write_description(self, gpxfile): """change description on remote server.""" self._write_attribute(gpxfile, 'description') def _write_public(self, gpxfile): """change public/private on remote server.""" self.__post( with_session=True, url='user-embeds/statuschange-gpxfile', expect='access granted', mid=self.mid, tid=gpxfile.id_in_backend, hash=self.session.cookies['exp_uniqueid'], status=1 if gpxfile.public else 2) # what a strange answer def _write_category(self, gpxfile): """change category directly on mapmytracks. Note that we specify iso-8859-1 but use utf-8. If we correctly specify utf-8 in the xml encoding, mapmytracks.com aborts our connection.""" self.__post( with_session=True, url='handler/change_activity', expect='ok', eid=gpxfile.id_in_backend, activity=self.encode_category(gpxfile.category)) def _current_tags(self, gpxfile): """Return all current MMT tags. Returns: A sorted unique list""" page_scan = self._scan_track_page(gpxfile) return list(sorted(set(page_scan['tags']))) def _write_add_keywords(self, gpxfile, values): """Add keyword as MMT tag. MMT allows adding several at once, comma separated, and we allow this too. But do not expect this to work with all backends.""" if not values: return values = ','.join(sorted(values.split(','))) data = '<?xml version="1.0" encoding="ISO-8859-1"?>' \ '<message><nature>add_tag</nature><eid>{eid}</eid>' \ '<usr>{usrid}</usr><uid>{uid}</uid>' \ '<tagnames>{value}</tagnames></message>'.format( eid=gpxfile.id_in_backend, usrid=self.account.username, value=values, uid=self.session.cookies['exp_uniqueid']) text = self.__post(with_session=True, url='assets/php/interface.php', data=data, expect='success') values = [x.strip() for x in values.split(',')] ids = (text.find('ids').text or '').split(',') tags = (text.find('tags').text or '').split(',') if values != tags or len(ids) != len(values): if values != tags: raise self.BackendException( '{}: _write_add_keywords({}): MMT does not like some of your keywords: mmt tags={}'.format( gpxfile, ','.join(values), ','.join(tags))) if len(ids) != len(values): raise self.BackendException( '{}: _write_add_keywords({}): MMT does not like some of your keywords: mmt ids={}'.format( gpxfile, ','.join(values), ','.join(ids))) for tag, id_ in zip(values, ids): self._found_tag_id(tag, id_) def _write_remove_keywords(self, gpxfile, values): """Remove keywords from gpxfile.""" # with GpxFile.batch_changes() active, gpxfile.keywords is already in the future # state after all batched changes have been applied, but we need the current # state. Ask MMT. current = self._get_current_keywords(gpxfile) wanted = set(current) - {x.strip() for x in values.split(',')} if True: # pylint: disable=using-constant-test # First remove all keywords and then re-add the still wanted ones. This works! # Because even if MMT does not remove the correct keyword, it always does # remove one of them. for value in current: self._remove_single_keyword(gpxfile, value) self._write_add_keywords(gpxfile, ','.join(wanted)) else: # Specifically remove unwanted keywords. This does not work, MMT does not # always remove the correct keyword. No idea why. for value in values.split(','): if value in current: self._remove_single_keyword(gpxfile, value) def _remove_single_keyword(self, gpxfile, value): """Remove a specific keyword from gpxfile. Does not work correctly, see above.""" tag = value.strip().capitalize() if tag not in self.__tag_ids: self.__tag_ids.update(self._scan_track_page(gpxfile)['tags']) self._check_tag_ids() if tag not in self.__tag_ids: raise self.BackendException( '{}: Cannot remove tag {}, it is not one of {}'.format( gpxfile, tag, self.__tag_ids)) if tag in self.__tag_ids: self.__post( with_session=True, url='handler/delete-tag.php', tag_id=self.__tag_ids[tag], entry_id=gpxfile.id_in_backend)
[docs] def get_time(self) ->datetime.datetime: """get MMT server time. Returns: The server time """ return _convert_time(self.__post(request='get_time').find('server_time').text)
def _list(self): """get all gpxfiles for this user.""" while True: old_len = self.real_len() response = self.__post( request='get_activities', author=self._get_author(), offset=old_len) chunk = response.find('activities') if not chunk: return for _ in chunk: raw_data = MMTRawTrack(_) gpx = Gpx() gpx.is_complete = False gpx.name = raw_data.title gpx.time = raw_data.time gpxfile = self._found_gpxfile(raw_data.track_id, gpx) gpxfile.category = self.decode_category(raw_data.category) gpxfile.distance = raw_data.distance assert self.real_len() > old_len def _scan_track_page(self, gpxfile): """The MMT api does not deliver all attributes we want. This gets some more by scanning the web page and returns it in page_parser.result""" response = self.__get( with_session=True, url='{}/explore/activity/{}'.format(self.url, gpxfile.id_in_backend)) page_parser = ParseMMTTrack(self) page_parser.feed(response.text) return page_parser.result def _get_current_keywords(self, gpxfile): """Ask MMT for current keywords, return them as a list.""" page_scan = self._scan_track_page(gpxfile) if page_scan['tags']: return sorted(page_scan['tags'].keys()) return list() def _use_webpage_results(self, gpxfile): """Get things directly. if the title has not been set, get_activities says something like "GpxFile 2016-09-04 ..." while the home page says "Cycling activity". We prefer the value from the home page and silently ignore this inconsistency. """ page_scan = self._scan_track_page(gpxfile) if page_scan['title']: gpxfile.title = page_scan['title'] if page_scan['description']: _ = html.unescape(page_scan['description']) if _ == self._default_description: _ = '' gpxfile.description = _ if page_scan['tags']: gpxfile.keywords = page_scan['tags'].keys() # MMT sends different values of the current gpxfile type, hopefully category_3 is always the # correct one. if page_scan['category_3']: gpxfile.category = self.decode_category(page_scan['category_3']) if page_scan['public'] is not None: gpxfile.public = page_scan['public'] def _read(self, gpxfile): """get the entire gpxfile.""" session = self.session if session is None: # https access not implemented for TrackMMT return response = self.__get(with_session=True, url='{}/assets/php/gpx.php?tid={}&mid={}&uid={}'.format( self.url, gpxfile.id_in_backend, self.mid, self.session.cookies['exp_uniqueid'])) # some gpxfiles download only a few points if mid/uid are not given, but I # have not been able to write a unittest triggering that ... gpxfile.gpx = Gpx.parse(response.text) # but this does not give us gpxfile type and other things, # get them from the web page. self._use_webpage_results(gpxfile) def _remove_ident(self, ident: str): """remove on the server.""" self.__post( with_session=True, url='handler/delete_track', expect='access granted', tid=ident, hash=self.session.cookies['exp_uniqueid']) def _write_all(self, gpxfile) ->str: """save full gpx gpxfile on the MMT server. We must upload the title separately. Returns: The new id_in_backend """ response = self.__post( request='upload_activity', gpx_file=gpxfile.xml(), status='public' if gpxfile.public else 'private', description=gpxfile.description, activity=self.encode_category(gpxfile.category)) new_ident = response.find('id').text if not new_ident: raise self.BackendException('No id found in response') gpxfile.id_in_backend = new_ident # the caller will do the above too, never mind if 'write_title' in self.supported: self._write_title(gpxfile) # MMT can add several keywords at once if gpxfile.keywords and 'write_add_keywords' in self.supported: self._write_add_keywords(gpxfile, ', '.join(gpxfile.keywords)) gpxfile.id_in_backend = new_ident return new_ident @staticmethod def __formatted_lifetrack_points(points) ->str: """format points for life tracking. Returns: The formatted points """ _ = list() for point in points: _.append('{} {} {} {}'.format( point.latitude, point.longitude, point.elevation if point.elevation is not None else 0, calendar.timegm(point.time.utctimetuple()))) return ' '.join(_) def _lifetrack_start(self, gpxfile, points) ->str: """Start a new lifetrack with initial points. Returns: new_ident: New gpxfile id """ if self.subscription == 'free': self.logger.info('Your free MMT account does not allow lifetracking, I will send the entire gpxfile') return super(MMT, self)._lifetrack_start(gpxfile, points) if MMT._current_lifetrack is not None: raise Exception('start: MMT only accepts one simultaneous lifetracker per username') MMT._current_lifetrack = gpxfile result = self.__post( request='start_activity', title=gpxfile.title, privacy='public' if gpxfile.public else 'private', activity=self.encode_category(gpxfile.category), points=self.__formatted_lifetrack_points(points), source='Gpxity', version=VERSION, expect='activity_started', # tags='TODO', unique_token='{}'.format(id(gpxfile))) result = result.find('activity_id').text self.logger.error('%s: lifetracking started', self) return result def _lifetrack_update(self, gpxfile, points): """Update a lifetrack with points. Args: gpxfile: The lifetrack points: The new points """ if self.subscription == 'free': super(MMT, self)._lifetrack_update(gpxfile, points) return if MMT._current_lifetrack != gpxfile: raise Exception('lifetrack_update: MMT only accepts one simultaneous lifetracker per username') self.__post( request='update_activity', activity_id=gpxfile.id_in_backend, points=self.__formatted_lifetrack_points(points), expect='activity_updated') def _lifetrack_end(self, gpxfile): """End a lifetrack. Args: gpxfile: The lifetrack """ if self.subscription == 'free': super(MMT, self)._lifetrack_end(gpxfile) else: if MMT._current_lifetrack != gpxfile: raise Exception('end: MMT only accepts one simultaneous lifetracker per username') self.__post(request='stop_activity') MMT._current_lifetrack = None
[docs] def detach(self): """also close session.""" # TODO: session/detach are quite similar between MMT and GPSIES super(MMT, self).detach() ident = str(self) if ident in self._session: self._session[ident].close()