diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d176d94 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,42 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: test + +on: [ push, pull_request] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.x'] + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + sudo apt install -y libgirepository1.0-dev + python -m pip install --upgrade pip + python -m pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Run tests + run: | + if [ -f requirements-test.txt ]; then pip install -r requirements-test.txt; fi + python -m unittest discover -s tests + coverage run run_tests.py && coverage report -m diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 4131539..0000000 --- a/.travis.yml +++ /dev/null @@ -1,20 +0,0 @@ -language: python -python: - - "2.7" - - "3.3" - - "3.4" - - "3.5" - - "3.6" - - "nightly" - - "pypy3" - - "pypy" - -install: - - pip install -r requirements-test.txt - -script: - - python -m unittest discover -s tests - - coverage run run_tests.py && coverage report -m - -notifications: - email: false \ No newline at end of file diff --git a/LICENSE b/LICENSE index 3f8862a..901fe39 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ MIT License -Copyright (c) 2011-2020, David Cooper -Copyright (c) 2017-2020, Carey Metcalfe +Copyright (c) 2011-2025, David Cooper +Copyright (c) 2017-2025, Carey Metcalfe Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index b221c59..d526522 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,23 @@ python-fitparse =============== +> :warning: **NOTE:** *I have **limited to no time** to work on this package +> these days!* +> +> I am looking for a maintainer to help with issues and updating/releasing the package. +> Please reach out via email at if you have interest in helping. +> +> If you're having trouble using this package for whatever reason, might we suggest using +> an alternative library: [fitdecode](https://github.com/polyvertex/fitdecode) by +> [polyvertex](https://github.com/polyvertex). +> +> Cheers, +> +> David + Here's a Python library to parse ANT/Garmin `.FIT` files. +[![Build Status](https://github.com/dtcooper/python-fitparse/workflows/test/badge.svg)](https://github.com/dtcooper/python-fitparse/actions?query=workflow%3Atest) + Install from [![PyPI](https://img.shields.io/pypi/v/fitparse.svg)](https://pypi.python.org/pypi/fitparse/): ``` @@ -15,7 +31,58 @@ FIT files by [ANT](http://www.thisisant.com/). - The SDK, code examples, and detailed documentation can be found in the [ANT FIT SDK](http://www.thisisant.com/resources/fit). - + + +Usage +----- +A simple example of printing records from a fit file: + +```python +import fitparse + +# Load the FIT file +fitfile = fitparse.FitFile("my_activity.fit") + +# Iterate over all messages of type "record" +# (other types include "device_info", "file_creator", "event", etc) +for record in fitfile.get_messages("record"): + + # Records can contain multiple pieces of data (ex: timestamp, latitude, longitude, etc) + for data in record: + + # Print the name and value of the data (and the units if it has any) + if data.units: + print(" * {}: {} ({})".format(data.name, data.value, data.units)) + else: + print(" * {}: {}".format(data.name, data.value)) + + print("---") +``` + +The library also provides a `fitdump` script for command line usage: +``` +$ fitdump --help +usage: fitdump [-h] [-v] [-o OUTPUT] [-t {readable,json}] [-n NAME] [--ignore-crc] FITFILE + +Dump .FIT files to various formats + +positional arguments: + FITFILE Input .FIT file (Use - for stdin) + +optional arguments: + -h, --help show this help message and exit + -v, --verbose + -o OUTPUT, --output OUTPUT + File to output data into (defaults to stdout) + -t {readable,json}, --type {readable,json} + File type to output. (DEFAULT: readable) + -n NAME, --name NAME Message name (or number) to filter + --ignore-crc Some devices can write invalid crc's, ignore these. +``` + +See the documentation for more: http://dtcooper.github.io/python-fitparse + + Major Changes From Original Version ----------------------------------- @@ -27,7 +94,7 @@ The old version is archived as and internal parts. (Still unstable and partially complete.) * Proper documentation! - [Available here](http://dtcooper.github.com/python-fitparse/). + [Available here](https://dtcooper.github.io/python-fitparse/). * Unit tests and example programs. diff --git a/docs/api.rst b/docs/api.rst index 8d64e0f..e5db642 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -76,7 +76,7 @@ The ``FitFile`` Object try: fitfile = FitFile('/path.to/fitfile.fit') fitfile.parse() - except FitParseError, e: + except FitParseError as e: print "Error while parsing .FIT file: %s" % e sys.exit(1) diff --git a/docs/index.rst b/docs/index.rst index 78400e9..4a4fc30 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -56,13 +56,7 @@ Requirements The following are required to install :mod:`fitparse`, -* `Python `_ 2.5 and above (Python 3 is currently not - supported) - -* The `argparse `_ is required for the - :command:`fitdump` command, but it is included in the Python standard library - as of version 2.7. Using ``pip`` to install the package will install this if - needed. +* `Python `_ 3.6 and above API Documentation diff --git a/fitparse/__init__.py b/fitparse/__init__.py index 9053f66..3453ef9 100644 --- a/fitparse/__init__.py +++ b/fitparse/__init__.py @@ -1,10 +1,10 @@ -from fitparse.base import FitFile, FitParseError +#!/usr/bin/env python + +# Make classes available +from fitparse.base import FitFile, FitFileDecoder, UncachedFitFile, \ + FitParseError, CacheMixin, DataProcessorMixin from fitparse.records import DataMessage from fitparse.processors import FitFileDataProcessor, StandardUnitsDataProcessor -__version__ = '1.1.0' -__all__ = [ - 'FitFileDataProcessor', 'FitFile', 'FitParseError', - 'StandardUnitsDataProcessor', 'DataMessage' -] +__version__ = '1.2.0' diff --git a/fitparse/base.py b/fitparse/base.py index d3370b4..b0ebf87 100644 --- a/fitparse/base.py +++ b/fitparse/base.py @@ -1,40 +1,142 @@ +#!/usr/bin/env python + import io import os import struct - -# Python 2 compat -try: - num_types = (int, float, long) -except NameError: - num_types = (int, float) +import warnings from fitparse.processors import FitFileDataProcessor from fitparse.profile import FIELD_TYPE_TIMESTAMP, MESSAGE_TYPES from fitparse.records import ( - Crc, DataMessage, FieldData, FieldDefinition, DevFieldDefinition, DefinitionMessage, MessageHeader, - BASE_TYPES, BASE_TYPE_BYTE, - add_dev_data_id, add_dev_field_description, get_dev_type + Crc, DevField, DataMessage, FieldData, FieldDefinition, DevFieldDefinition, DefinitionMessage, + MessageHeader, BASE_TYPES, BASE_TYPE_BYTE, ) from fitparse.utils import fileish_open, is_iterable, FitParseError, FitEOFError, FitCRCError, FitHeaderError -class FitFile(object): - def __init__(self, fileish, check_crc=True, data_processor=None): +class DeveloperDataMixin: + def __init__(self, *args, check_developer_data=True, **kwargs): + self.check_developer_data = check_developer_data + self.dev_types = {} + + super().__init__(*args, **kwargs) + + def _append_dev_data_id(self, dev_data_index, application_id=None, fields=None): + if fields is None: + fields = {} + + # Note that nothing in the spec says overwriting an existing type is invalid + self.dev_types[dev_data_index] = { + 'dev_data_index': dev_data_index, + 'application_id': application_id, + 'fields': fields + } + + def add_dev_data_id(self, message): + dev_data_index = message.get_raw_value('developer_data_index') + application_id = message.get_raw_value('application_id') + + self._append_dev_data_id(dev_data_index, application_id) + + def _append_dev_field_description(self, dev_data_index, field_def_num, type=BASE_TYPE_BYTE, name=None, + units=None, native_field_num=None): + if dev_data_index not in self.dev_types: + if self.check_developer_data: + raise FitParseError("No such dev_data_index=%s found" % (dev_data_index)) + + warnings.warn( + "Dev type for dev_data_index=%s missing. Adding dummy dev type." % (dev_data_index) + ) + self._append_dev_data_id(dev_data_index) + + self.dev_types[dev_data_index]["fields"][field_def_num] = DevField( + dev_data_index=dev_data_index, + def_num=field_def_num, + type=type, + name=name, + units=units, + native_field_num=native_field_num + ) + + def add_dev_field_description(self, message): + dev_data_index = message.get_raw_value('developer_data_index') + field_def_num = message.get_raw_value('field_definition_number') + base_type_id = message.get_raw_value('fit_base_type_id') + field_name = message.get_raw_value('field_name') or "unnamed_dev_field_%s" % field_def_num + units = message.get_raw_value("units") + native_field_num = message.get_raw_value('native_field_num') + + if dev_data_index not in self.dev_types: + if self.check_developer_data: + raise FitParseError("No such dev_data_index=%s found" % (dev_data_index)) + + warnings.warn( + "Dev type for dev_data_index=%s missing. Adding dummy dev type." % (dev_data_index) + ) + self._append_dev_data_id(dev_data_index) + + fields = self.dev_types[int(dev_data_index)]['fields'] + + # Note that nothing in the spec says overwriting an existing field is invalid + fields[field_def_num] = DevField( + dev_data_index=dev_data_index, + def_num=field_def_num, + type=BASE_TYPES[base_type_id], + name=field_name, + units=units, + native_field_num=native_field_num + ) + + def get_dev_type(self, dev_data_index, field_def_num): + if dev_data_index not in self.dev_types: + if self.check_developer_data: + raise FitParseError( + f"No such dev_data_index={dev_data_index} found when looking up field {field_def_num}" + ) + + warnings.warn( + "Dev type for dev_data_index=%s missing. Adding dummy dev type." % (dev_data_index) + ) + self._append_dev_data_id(dev_data_index) + + dev_type = self.dev_types[dev_data_index] + + if field_def_num not in dev_type['fields']: + if self.check_developer_data: + raise FitParseError( + f"No such field {field_def_num} for dev_data_index {dev_data_index}" + ) + + warnings.warn( + f"Field {field_def_num} for dev_data_index {dev_data_index} missing. Adding dummy field." + ) + self._append_dev_field_description( + dev_data_index=dev_data_index, + field_def_num=field_def_num + ) + + return dev_type['fields'][field_def_num] + + +class FitFileDecoder(DeveloperDataMixin): + """Basic decoder for fit files""" + + def __init__(self, fileish, *args, check_crc=True, data_processor=None, **kwargs): self._file = fileish_open(fileish, 'rb') self.check_crc = check_crc self._crc = None - self._processor = data_processor or FitFileDataProcessor() # Get total filesize self._file.seek(0, os.SEEK_END) self._filesize = self._file.tell() self._file.seek(0, os.SEEK_SET) - self._messages = [] # Start off by parsing the file header (sets initial attribute values) self._parse_file_header() + super().__init__(*args, **kwargs) + def __del__(self): self.close() @@ -79,12 +181,13 @@ def _read_struct(self, fmt, endian='<', data=None, always_tuple=False): def _read_and_assert_crc(self, allow_zero=False): # CRC Calculation is little endian from SDK + # TODO - How to handle the case of unterminated file? Error out and have user retry with check_crc=false? crc_computed, crc_read = self._crc.value, self._read_struct(Crc.FMT) if not self.check_crc: return if crc_computed == crc_read or (allow_zero and crc_read == 0): return - raise FitCRCError('CRC Mismatch [computed: %s, read: %s]' % ( + raise FitCRCError('CRC Mismatch [computed: {}, read: {}]'.format( Crc.format(crc_computed), Crc.format(crc_read))) ########## @@ -131,7 +234,8 @@ def _parse_file_header(self): def _parse_message(self): # When done, calculate the CRC and return None if self._bytes_left <= 0: - if not self._complete: + # Don't assert CRC if requested not + if not self._complete and self.check_crc: self._read_and_assert_crc() if self._file.tell() >= self._filesize: @@ -151,11 +255,10 @@ def _parse_message(self): message = self._parse_data_message(header) if message.mesg_type is not None: if message.mesg_type.name == 'developer_data_id': - add_dev_data_id(message) + self.add_dev_data_id(message) elif message.mesg_type.name == 'field_description': - add_dev_field_description(message) + self.add_dev_field_description(message) - self._messages.append(message) return message def _parse_message_header(self): @@ -191,10 +294,11 @@ def _parse_definition_message(self, header): base_type = BASE_TYPES.get(base_type_num, BASE_TYPE_BYTE) if (field_size % base_type.size) != 0: - # NOTE: we could fall back to byte encoding if there's any - # examples in the wild. For now, just throw an exception - raise FitParseError("Invalid field size %d for type '%s' (expected a multiple of %d)" % ( - field_size, base_type.name, base_type.size)) + warnings.warn( + "Invalid field size %d for field '%s' of type '%s' (expected a multiple of %d); falling back to byte encoding." % ( + field_size, field.name, base_type.name, base_type.size) + ) + base_type = BASE_TYPE_BYTE # If the field has components that are accumulators # start recording their accumulation at 0 @@ -216,7 +320,7 @@ def _parse_definition_message(self, header): num_dev_fields = self._read_struct('B', endian=endian) for n in range(num_dev_fields): field_def_num, field_size, dev_data_index = self._read_struct('3B', endian=endian) - field = get_dev_type(dev_data_index, field_def_num) + field = self.get_dev_type(dev_data_index, field_def_num) dev_field_defs.append(DevFieldDefinition( field=field, dev_data_index=dev_data_index, @@ -245,9 +349,14 @@ def _parse_raw_values_from_data_message(self, def_mesg): struct_fmt = str(int(field_def.size / base_type.size)) + base_type.fmt # Extract the raw value, ask for a tuple if it's a byte type - raw_value = self._read_struct( - struct_fmt, endian=def_mesg.endian, always_tuple=is_byte, - ) + try: + raw_value = self._read_struct( + struct_fmt, endian=def_mesg.endian, always_tuple=is_byte, + ) + except FitEOFError: + # file was suddenly terminated + warnings.warn("File was terminated unexpectedly, some data will not be loaded.") + break # If the field returns with a tuple of values it's definitely an # oddball, but we'll parse it on a per-value basis it. @@ -281,7 +390,7 @@ def _apply_scale_offset(self, field, raw_value): if isinstance(raw_value, tuple): # Contains multiple values, apply transformations to all of them return tuple(self._apply_scale_offset(field, x) for x in raw_value) - elif isinstance(raw_value, num_types): + elif isinstance(raw_value, (int, float)): if field.scale: raw_value = float(raw_value) / field.scale if field.offset: @@ -299,7 +408,7 @@ def _apply_compressed_accumulation(raw_value, accumulation, num_bits): return base_value - def _parse_data_message(self, header): + def _parse_data_message_components(self, header): def_mesg = self._local_mesgs.get(header.local_mesg_num) if not def_mesg: raise FitParseError('Got data message with invalid local message type %d' % ( @@ -389,17 +498,33 @@ def _parse_data_message(self, header): ) ) - # Apply data processors - for field_data in field_datas: - # Apply type name processor - self._processor.run_type_processor(field_data) - self._processor.run_field_processor(field_data) - self._processor.run_unit_processor(field_data) + return header, def_mesg, field_datas - data_message = DataMessage(header=header, def_mesg=def_mesg, fields=field_datas) - self._processor.run_message_processor(data_message) + def _parse_data_message(self, header): + header, def_mesg, field_datas = self._parse_data_message_components(header) + return DataMessage(header=header, def_mesg=def_mesg, fields=field_datas) - return data_message + @staticmethod + def _should_yield(message, with_definitions, names): + if not message: + return False + if with_definitions or message.type == 'data': + # name arg is None we return all + if names is None: + return True + elif (message.name in names) or (message.mesg_num in names): + return True + return False + + @staticmethod + def _make_set(obj): + if obj is None: + return None + + if is_iterable(obj): + return set(obj) + else: + return {obj} ########## # Public API @@ -408,44 +533,92 @@ def get_messages(self, name=None, with_definitions=False, as_dict=False): if with_definitions: # with_definitions implies as_dict=False as_dict = False - if name is not None: - if is_iterable(name): - names = set(name) - else: - names = set((name,)) - - def should_yield(message): - if with_definitions or message.type == 'data': - # name arg is None we return all - if name is None: - return True - else: - if (message.name in names) or (message.mesg_num in names): - return True - return False + names = self._make_set(name) + + while not self._complete: + message = self._parse_message() + if self._should_yield(message, with_definitions, names): + yield message.as_dict() if as_dict else message + + def __iter__(self): + return self.get_messages() + + +class CacheMixin: + """Add message caching to the FitFileDecoder""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._messages = [] + + def _parse_message(self): + self._messages.append(super()._parse_message()) + return self._messages[-1] + + def get_messages(self, name=None, with_definitions=False, as_dict=False): + if with_definitions: # with_definitions implies as_dict=False + as_dict = False + + names = self._make_set(name) # Yield all parsed messages first for message in self._messages: - if should_yield(message): + if self._should_yield(message, with_definitions, names): yield message.as_dict() if as_dict else message - # If there are unparsed messages, yield those too - while not self._complete: - message = self._parse_message() - if message and should_yield(message): - yield message.as_dict() if as_dict else message + for message in super().get_messages(names, with_definitions, as_dict): + yield message @property def messages(self): - # TODO: could this be more efficient? return list(self.get_messages()) def parse(self): while self._parse_message(): pass - def __iter__(self): - return self.get_messages() + +class DataProcessorMixin: + """Add data processing to the FitFileDecoder""" + + def __init__(self, *args, **kwargs): + self._processor = kwargs.pop("data_processor", None) or FitFileDataProcessor() + super().__init__(*args, **kwargs) + + def _parse_data_message(self, header): + header, def_mesg, field_datas = self._parse_data_message_components(header) + + # Apply data processors + for field_data in field_datas: + # Apply type name processor + self._processor.run_type_processor(field_data) + self._processor.run_field_processor(field_data) + self._processor.run_unit_processor(field_data) + + data_message = DataMessage(header=header, def_mesg=def_mesg, fields=field_datas) + self._processor.run_message_processor(data_message) + + return data_message + + +class UncachedFitFile(DataProcessorMixin, FitFileDecoder): + """FitFileDecoder with data processing""" + + def __init__(self, fileish, *args, check_crc=True, data_processor=None, **kwargs): + # Ensure all optional params are passed as kwargs + super().__init__( + fileish, + *args, + check_crc=check_crc, + data_processor=data_processor, + **kwargs + ) + + +class FitFile(CacheMixin, UncachedFitFile): + """FitFileDecoder with caching and data processing""" + pass + # TODO: Create subclasses like Activity and do per-value monkey patching diff --git a/fitparse/processors.py b/fitparse/processors.py index 810233f..34b36ba 100644 --- a/fitparse/processors.py +++ b/fitparse/processors.py @@ -1,11 +1,11 @@ import datetime -from fitparse.utils import scrub_method_name +from fitparse.utils import scrub_method_name, is_iterable # Datetimes (uint32) represent seconds since this UTC_REFERENCE UTC_REFERENCE = 631065600 # timestamp for UTC 00:00 Dec 31 1989 -class FitFileDataProcessor(object): +class FitFileDataProcessor: # TODO: Document API # Functions that will be called to do the processing: #def run_type_processor(field_data) @@ -70,7 +70,7 @@ def process_type_bool(self, field_data): def process_type_date_time(self, field_data): value = field_data.value if value is not None and value >= 0x10000000: - field_data.value = datetime.datetime.utcfromtimestamp(UTC_REFERENCE + value) + field_data.value = datetime.datetime.fromtimestamp(timestamp=(UTC_REFERENCE + value), tz=datetime.timezone.utc).replace(tzinfo=None) field_data.units = None # Units were 's', set to None def process_type_local_date_time(self, field_data): @@ -78,14 +78,23 @@ def process_type_local_date_time(self, field_data): # NOTE: This value was created on the device using it's local timezone. # Unless we know that timezone, this value won't be correct. However, if we # assume UTC, at least it'll be consistent. - field_data.value = datetime.datetime.utcfromtimestamp(UTC_REFERENCE + field_data.value) + field_data.value = datetime.datetime.fromtimestamp(timestamp=(UTC_REFERENCE + field_data.value), tz=datetime.timezone.utc).replace(tzinfo=None) field_data.units = None def process_type_localtime_into_day(self, field_data): if field_data.value is not None: - m, s = divmod(field_data.value, 60) - h, m = divmod(m, 60) - field_data.value = datetime.time(h, m, s) + # NOTE: Values larger or equal to 86400 should not be possible. + # Additionally, if the value is exactly 86400, it will lead to an error when trying to + # create the time with datetime.time(24, 0 , 0). + # + # E.g. Garmin does add "sleep_time": 86400 to its fit files, + # which causes an error if not properly handled. + if field_data.value >= 86400: + field_data.value = datetime.time.max + else: + m, s = divmod(field_data.value, 60) + h, m = divmod(m, 60) + field_data.value = datetime.time(h, m, s) field_data.units = None @@ -98,7 +107,7 @@ def run_field_processor(self, field_data): if field_data.name.endswith("_speed"): self.process_field_speed(field_data) else: - super(StandardUnitsDataProcessor, self).run_field_processor(field_data) + super().run_field_processor(field_data) def process_field_distance(self, field_data): if field_data.value is not None: @@ -107,7 +116,13 @@ def process_field_distance(self, field_data): def process_field_speed(self, field_data): if field_data.value is not None: - field_data.value *= 60.0 * 60.0 / 1000.0 + factor = 60.0 * 60.0 / 1000.0 + + # record.enhanced_speed field can be a tuple + if is_iterable(field_data.value): + field_data.value = tuple(x * factor for x in field_data.value) + else: + field_data.value *= factor field_data.units = 'km/h' def process_units_semicircles(self, field_data): diff --git a/fitparse/profile.py b/fitparse/profile.py index aa6ddbd..daaf579 100644 --- a/fitparse/profile.py +++ b/fitparse/profile.py @@ -1,4 +1,3 @@ - # ***************** BEGIN AUTOMATICALLY GENERATED FIT PROFILE ****************** # *************************** DO NOT EDIT THIS FILE **************************** # ************ EXPORTED PROFILE FROM SDK VERSION 20.8 ON 2019-03-05 ************ @@ -1381,6 +1380,14 @@ 7: 'swiss_ball_dumbbell_flye', }, ), + 'strava_product': FieldType( + name='strava_product', + base_type=BASE_TYPES[0x84], # uint16 + values={ + 101: 'Strava iPhone App', # recent versions of Strava iPhone app + 102: 'Strava Android App', # recent versions of Strava Android app + } + ), 'garmin_product': FieldType( name='garmin_product', base_type=BASE_TYPES[0x84], # uint16 @@ -4010,6 +4017,19 @@ value='dynastream_oem', raw_value=13, ), + ) + ), + SubField( + name='strava_product', + def_num=2, + type=FIELD_TYPES['strava_product'], + ref_fields=( + ReferenceField( + name='manufacturer', + def_num=1, + value='strava', + raw_value=265, + ), ), ), ), @@ -7078,6 +7098,19 @@ ), ), ), + SubField( + name='strava_product', + def_num=4, + type=FIELD_TYPES['strava_product'], + ref_fields=( + ReferenceField( + name='manufacturer', + def_num=2, + value='strava', + raw_value=265, + ), + ), + ), ), ), 5: Field( @@ -8581,6 +8614,19 @@ ), ), ), + SubField( + name='strava_product', + def_num=1, + type=FIELD_TYPES['strava_product'], + ref_fields=( + ReferenceField( + name='manufacturer', + def_num=0, + value='strava', + raw_value=265, + ), + ), + ), ), ), }, @@ -11447,6 +11493,19 @@ ), ), ), + SubField( + name='strava_product', + def_num=1, + type=FIELD_TYPES['strava_product'], + ref_fields=( + ReferenceField( + name='manufacturer', + def_num=0, + value='strava', + raw_value=265, + ), + ), + ), ), ), 2: Field( # Corresponds to file_id of scheduled workout / course. diff --git a/fitparse/records.py b/fitparse/records.py index d007f0a..e9fcd6a 100644 --- a/fitparse/records.py +++ b/fitparse/records.py @@ -1,26 +1,10 @@ import math import struct -# Python 2 compat -try: - int_types = (int, long,) - byte_iter = bytearray -except NameError: - int_types = (int,) - byte_iter = lambda x: x +from itertools import zip_longest -try: - from itertools import zip_longest -except ImportError: - from itertools import izip_longest as zip_longest -from fitparse.utils import FitParseError - - -DEV_TYPES = {} - - -class RecordBase(object): +class RecordBase: # namedtuple-like base class. Subclasses should must __slots__ __slots__ = () @@ -88,7 +72,7 @@ class DevFieldDefinition(RecordBase): __slots__ = ('field', 'dev_data_index', 'base_type', 'def_num', 'size') def __init__(self, **kwargs): - super(DevFieldDefinition, self).__init__(**kwargs) + super().__init__(**kwargs) # For dev fields, the base_type and type are always the same. self.base_type = self.type @@ -120,6 +104,12 @@ def get(self, field_name, as_dict=False): if field_data.is_named(field_name): return field_data.as_dict() if as_dict else field_data + def get_raw_value(self, field_name): + field_data = self.get(field_name) + if field_data: + return field_data.raw_value + return None + def get_value(self, field_name): # SIMPLIFY: get rid of this completely field_data = self.get(field_name) @@ -128,7 +118,7 @@ def get_value(self, field_name): def get_values(self): # SIMPLIFY: get rid of this completely - return dict((f.name if f.name else f.def_num, f.value) for f in self.fields) + return {f.name if f.name else f.def_num: f.value for f in self.fields} @property def name(self): @@ -158,7 +148,7 @@ def __iter__(self): def __repr__(self): return '' % ( self.name, self.mesg_num, self.header.local_mesg_num, - ', '.join(["%s: %s" % (fd.name, fd.value) for fd in self.fields]), + ', '.join([f"{fd.name}: {fd.value}" for fd in self.fields]), ) def __str__(self): @@ -170,7 +160,7 @@ class FieldData(RecordBase): __slots__ = ('field_def', 'field', 'parent_field', 'value', 'raw_value', 'units') def __init__(self, *args, **kwargs): - super(FieldData, self).__init__(self, *args, **kwargs) + super().__init__(self, *args, **kwargs) if not self.units and self.field: # Default to units on field, otherwise None. # NOTE:Not a property since you may want to override this in a data processor @@ -232,7 +222,7 @@ def __repr__(self): ) def __str__(self): - return '%s: %s%s' % ( + return '{}: {}{}'.format( self.name, self.value, ' [%s]' % self.units if self.units else '', ) @@ -259,7 +249,7 @@ class FieldType(RecordBase): __slots__ = ('name', 'base_type', 'values') def __repr__(self): - return '' % (self.name, self.base_type) + return f'' class MessageType(RecordBase): @@ -335,13 +325,13 @@ def render(self, raw_value): raw_value = unpacked_num # Mask and shift like a normal number - if isinstance(raw_value, int_types): + if isinstance(raw_value, int): raw_value = (raw_value >> self.bit_offset) & ((1 << self.bits) - 1) return raw_value -class Crc(object): +class Crc: """FIT file CRC computation.""" CRC_TABLE = ( @@ -357,7 +347,7 @@ def __init__(self, value=0, byte_arr=None): self.update(byte_arr) def __repr__(self): - return '<%s %s>' % (self.__class__.__name__, self.value or "-") + return '<{} {}>'.format(self.__class__.__name__, self.value or "-") def __str__(self): return self.format(self.value) @@ -375,7 +365,7 @@ def format(value): @classmethod def calculate(cls, byte_arr, crc=0): """Compute CRC for input bytes.""" - for byte in byte_iter(byte_arr): + for byte in byte_arr: # Taken verbatim from FIT SDK docs tmp = cls.CRC_TABLE[crc & 0xF] crc = (crc >> 4) & 0x0FFF @@ -389,10 +379,7 @@ def calculate(cls, byte_arr, crc=0): def parse_string(string): try: - try: - s = string[:string.index(0x00)] - except TypeError: # Python 2 compat - s = string[:string.index('\x00')] + s = string[:string.index(0x00)] except ValueError: # FIT specification defines the 'string' type as follows: "Null # terminated string encoded in UTF-8 format". @@ -429,50 +416,3 @@ def parse_string(string): 0x8F: BaseType(name='uint64', identifier=0x8F, fmt='Q', parse=lambda x: None if x == 0xFFFFFFFFFFFFFFFF else x), 0x90: BaseType(name='uint64z', identifier=0x90, fmt='Q', parse=lambda x: None if x == 0 else x), } - - -def add_dev_data_id(message): - global DEV_TYPES - dev_data_index = message.get('developer_data_index').raw_value - if message.get('application_id'): - application_id = message.get('application_id').raw_value - else: - application_id = None - - # Note that nothing in the spec says overwriting an existing type is invalid - DEV_TYPES[dev_data_index] = {'dev_data_index': dev_data_index, 'application_id': application_id, 'fields': {}} - - -def add_dev_field_description(message): - global DEV_TYPES - - dev_data_index = message.get('developer_data_index').raw_value - field_def_num = message.get('field_definition_number').raw_value - base_type_id = message.get('fit_base_type_id').raw_value - field_name = message.get('field_name').raw_value - units = message.get('units').raw_value - - native_field_num = message.get('native_field_num') - if native_field_num is not None: - native_field_num = native_field_num.raw_value - - if dev_data_index not in DEV_TYPES: - raise FitParseError("No such dev_data_index=%s found" % (dev_data_index)) - fields = DEV_TYPES[int(dev_data_index)]['fields'] - - # Note that nothing in the spec says overwriting an existing field is invalid - fields[field_def_num] = DevField(dev_data_index=dev_data_index, - def_num=field_def_num, - type=BASE_TYPES[base_type_id], - name=field_name, - units=units, - native_field_num=native_field_num) - - -def get_dev_type(dev_data_index, field_def_num): - if dev_data_index not in DEV_TYPES: - raise FitParseError("No such dev_data_index=%s found when looking up field %s" % (dev_data_index, field_def_num)) - elif field_def_num not in DEV_TYPES[dev_data_index]['fields']: - raise FitParseError("No such field %s for dev_data_index %s" % (field_def_num, dev_data_index)) - - return DEV_TYPES[dev_data_index]['fields'][field_def_num] diff --git a/fitparse/utils.py b/fitparse/utils.py index d3e4e02..65f424a 100644 --- a/fitparse/utils.py +++ b/fitparse/utils.py @@ -1,9 +1,8 @@ import io import re -try: - from collections.abc import Iterable -except ImportError: - from collections import Iterable +from collections.abc import Iterable + +from pathlib import PurePath class FitParseError(ValueError): @@ -50,15 +49,15 @@ def fileish_open(fileish, mode): # BytesIO-like object return fileish elif isinstance(fileish, str): - # Python2 - file path, file contents in the case of a TypeError - # Python3 - file path - try: - return open(fileish, mode) - except TypeError: - return io.BytesIO(fileish) - else: - # Python 3 - file contents - return io.BytesIO(fileish) + # file path + return open(fileish, mode) + + # pathlib obj + if isinstance(fileish, PurePath): + return fileish.open(mode) + + # file contents + return io.BytesIO(fileish) def is_iterable(obj): diff --git a/requirements-test.txt b/requirements-test.txt index b6627b4..5cc4f02 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,12 +1,2 @@ -# -# This file is autogenerated by pip-compile -# To update, run: -# -# pip-compile --output-file requirements-test.txt etc/requirements-test.in -# - -click==7.0 # via pip-tools -coverage==4.5.2 -pip-tools==3.2.0 -six==1.12.0 # via pip-tools -coveralls==1.7.0 # via pip-tools +coverage>=4.5.2 +coveralls>=1.7.0 # via pip-tools diff --git a/scripts/fitdump b/scripts/fitdump index 5068456..629f61b 100755 --- a/scripts/fitdump +++ b/scripts/fitdump @@ -1,34 +1,27 @@ #!/usr/bin/env python -from __future__ import print_function import argparse import codecs import datetime +import itertools import json -import sys +import os.path import types -# Python 2 compat -try: - BrokenPipeError -except NameError: - import socket - BrokenPipeError = socket.error - import fitparse def format_message(num, message, options): - s = ["{}. {}".format(num, message.name)] + s = [f"{num}. {message.name}"] if options.with_defs: - s.append(' [{}]'.format(message.type)) + s.append(f' [{message.type}]') s.append('\n') if message.type == 'data': for field_data in message: - s.append(' * {}: {}'.format(field_data.name, field_data.value)) + s.append(f' * {field_data.name}: {field_data.value}') if field_data.units: - s.append(' [{}]'.format(field_data.units)) + s.append(f' [{field_data.units}]') s.append('\n') s.append('\n') @@ -42,12 +35,13 @@ def parse_args(args=None): ) parser.add_argument('-v', '--verbose', action='count', default=0) parser.add_argument( - '-o', '--output', type=argparse.FileType(mode='w'), default="-", + '-o', '--output', type=argparse.FileType(mode='w', encoding="utf-8"), + default="-", help='File to output data into (defaults to stdout)', ) parser.add_argument( # TODO: csv - '-t', '--type', choices=('readable', 'json'), default='readable', + '-t', '--type', choices=('readable', 'json', 'gpx'), default='readable', help='File type to output. (DEFAULT: %(default)s)', ) parser.add_argument( @@ -63,12 +57,6 @@ def parse_args(args=None): options = parser.parse_args(args) - # Work around argparse.FileType not accepting an `encoding` kwarg in - # Python < 3.4 by closing and reopening the file (unless it's stdout) - if options.output is not sys.stdout: - options.output.close() - options.output = codecs.open(options.output.name, 'w', encoding='UTF-8') - options.verbose = options.verbose >= 1 options.with_defs = (options.type == "readable" and options.verbose) options.as_dict = (options.type != "readable" and options.verbose) @@ -80,7 +68,7 @@ class RecordJSONEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, types.GeneratorType): return list(obj) - if isinstance(obj, datetime.datetime): + if isinstance(obj, (datetime.datetime, datetime.time)): return obj.isoformat() if isinstance(obj, fitparse.DataMessage): return { @@ -90,16 +78,96 @@ class RecordJSONEncoder(json.JSONEncoder): } } # Fall back to original to raise a TypeError - return super(RecordJSONEncoder, self).default(obj) + return super().default(obj) + + +def generate_gpx(records, filename=None): + # TODO: Use xml.etree.ElementTree ? + + GPX_TIME_FMT = "%Y-%m-%dT%H:%M:%SZ" # ISO 8601 format + + records = iter(records) + + # header + open tags + yield '\n' + yield '\n' + yield ' \n' + + # file creation time (if a file_id record exists) + first_record = [] + for message in records: + if message.name == "file_id": + for field_data in message: + if field_data.name == "time_created" and type(field_data.value) == datetime.datetime: + yield f' \n' + break + else: + # No time found in the fields, check next record + continue + break + elif message.name == "record": + first_record.append(message) + break + + if filename: + yield f' {filename}\n' + + yield ' \n' + yield ' \n' + + if filename: + yield f' {filename}\n' + + yield ' \n' + + # track points + for message in itertools.chain(first_record, records): + if message.name != "record": + continue + + trkpt = {} + + # TODO: support more data types (heart rate, cadence, etc) + for field_data in message: + if field_data.name == "position_lat": + # Units are decimal degrees + trkpt["lat"] = field_data.value + elif field_data.name == "position_long": + # Units are decimal degrees + trkpt["lon"] = field_data.value + elif field_data.name == "enhanced_altitude": + # Units are m + trkpt["ele"] = field_data.value + elif field_data.name == "timestamp" and type(field_data.value) == datetime.datetime: + trkpt["time"] = field_data.value.strftime(GPX_TIME_FMT) + elif field_data.name == "enhanced_speed" and type(field_data.value) == float: + # convert from km/h to m/s + trkpt["speed"] = field_data.value / 3.6 + + # Add trackpoint + if "lat" in trkpt and "lon" in trkpt: + yield ' \n'.format(**trkpt) + if "ele" in trkpt: + yield ' {ele}\n'.format(**trkpt) + if "time" in trkpt: + yield ' \n'.format(**trkpt) + if "speed" in trkpt: + yield ' {speed}\n'.format(**trkpt) + yield ' \n' + + # close tags + yield ' \n' + yield ' \n' + yield '\n' def main(args=None): options = parse_args(args) - fitfile = fitparse.FitFile( + fitfile = fitparse.UncachedFitFile( options.infile, data_processor=fitparse.StandardUnitsDataProcessor(), - check_crc = not(options.ignore_crc), + check_crc=not(options.ignore_crc), ) records = fitfile.get_messages( name=options.name, @@ -107,12 +175,23 @@ def main(args=None): as_dict=options.as_dict ) - if options.type == "json": - json.dump(records, fp=options.output, cls=RecordJSONEncoder) - elif options.type == "readable": - options.output.writelines(format_message(n, record, options) - for n, record in enumerate(records, 1)) - + try: + if options.type == "json": + json.dump(records, fp=options.output, cls=RecordJSONEncoder) + elif options.type == "readable": + options.output.writelines( + format_message(n, record, options) for n, record in enumerate(records, 1) + ) + elif options.type == "gpx": + filename = getattr(options.infile, "name") + if filename: + filename = os.path.basename(filename) + options.output.writelines(generate_gpx(records, filename)) + finally: + try: + options.output.close() + except OSError: + pass if __name__ == '__main__': try: diff --git a/scripts/generate_profile.py b/scripts/generate_profile.py index c88ab60..5d5a5fb 100755 --- a/scripts/generate_profile.py +++ b/scripts/generate_profile.py @@ -28,14 +28,14 @@ def header(header, indent=0): - return '%s# %s' % (' ' * indent, (' %s ' % header).center(78 - indent, '*')) + return '{}# {}'.format(' ' * indent, (' %s ' % header).center(78 - indent, '*')) def scrub_symbol_name(symbol_name): return SYMBOL_NAME_SCRUBBER.sub('_', symbol_name) -PROFILE_HEADER_FIRST_PART = "%s\n%s" % ( +PROFILE_HEADER_FIRST_PART = "{}\n{}".format( header('BEGIN AUTOMATICALLY GENERATED FIT PROFILE'), header('DO NOT EDIT THIS FILE'), ) @@ -92,7 +92,7 @@ def scrub_symbol_name(symbol_name): def render_type(name): if name in BASE_TYPES: - return "BASE_TYPES[%s], # %s" % (BASE_TYPES[name], name) + return "BASE_TYPES[{}], # {}".format(BASE_TYPES[name], name) else: return "FIELD_TYPES['%s']," % name @@ -121,7 +121,7 @@ def get_mesg_num(self, name): def __str__(self): s = 'FIELD_TYPES = {\n' for type in sorted(self.types, key=lambda x: x.name): - s += " '%s': %s,\n" % (type.name, indent(type)) + s += " '{}': {},\n".format(type.name, indent(type)) s += '}' return s @@ -131,18 +131,18 @@ def get(self, value_name): for value in self.values: if value.name == value_name: return value - raise AssertionError("Invalid value name %s in type %s" % (value_name, self.name)) + raise AssertionError("Invalid value name {} in type {}".format(value_name, self.name)) def __str__(self): s = 'FieldType(%s\n' % render_comment(self.comment) s += " name='%s',\n" % (self.name) - s += " base_type=BASE_TYPES[%s], # %s\n" % ( + s += " base_type=BASE_TYPES[{}], # {}\n".format( BASE_TYPES[self.base_type], self.base_type, ) if self.values: s += " values={\n" for value in sorted(self.values, key=lambda x: x.value if isinstance(x.value, int) else int(x.value, 16)): - s += " %s\n" % (value,) + s += " {}\n".format(value) s += " },\n" s += ")" return s @@ -150,7 +150,7 @@ def __str__(self): class TypeValueInfo(namedtuple('TypeValueInfo', ('name', 'value', 'comment'))): def __str__(self): - return "%s: '%s',%s" % (self.value, self.name, render_comment(self.comment)) + return "{}: '{}',{}".format(self.value, self.name, render_comment(self.comment)) class MessageList(namedtuple('MessageList', ('messages'))): @@ -170,7 +170,7 @@ def __str__(self): s += '\n\n' s += "%s\n" % header(message.group_name, 4) last_group_name = message.group_name - s += " %s: %s,\n" % (message.num, indent(message)) + s += " {}: {},\n".format(message.num, indent(message)) s += '}' return s @@ -188,7 +188,7 @@ def get_field_by_name(self, mesg_name, field_name): if field.name == field_name: return mesg, field - raise ValueError('field "%s" not found in message "%s"' % (field_name, mesg_name)) + raise ValueError('field "{}" not found in message "{}"'.format(field_name, mesg_name)) class MessageInfo(namedtuple('MessageInfo', ('name', 'num', 'group_name', 'fields', 'comment'))): @@ -196,7 +196,7 @@ def get(self, field_name): for field in self.fields: if field.name == field_name: return field - raise AssertionError("Invalid field name %s in message %s" % (field_name, self.name)) + raise AssertionError("Invalid field name {} in message {}".format(field_name, self.name)) def __str__(self): s = "MessageType(%s\n" % render_comment(self.comment) @@ -391,7 +391,7 @@ def parse_types(types_rows): if value.name and value.value is not None: # Don't add ignore keyed types - if "%s:%s" % (type.name, value.name) not in IGNORE_TYPE_VALUES: + if "{}:{}".format(type.name, value.name) not in IGNORE_TYPE_VALUES: type.values.append(value) # Add missing boolean type if it's not there @@ -546,7 +546,7 @@ def get_xls_and_version_from_zip(path): def main(input_xls_or_zip, output_py_path=None): if output_py_path and os.path.exists(output_py_path): - if not open(output_py_path, 'r').read().strip().startswith(PROFILE_HEADER_FIRST_PART): + if not open(output_py_path).read().strip().startswith(PROFILE_HEADER_FIRST_PART): print("Python file doesn't begin with appropriate header. Exiting.") sys.exit(1) @@ -563,7 +563,7 @@ def main(input_xls_or_zip, output_py_path=None): for mesg_name in MESSAGE_NUM_DECLARATIONS: mesg_info = message_list.get_by_name(mesg_name) - mesg_num_declarations.append('MESG_NUM_%s = %s' % ( + mesg_num_declarations.append('MESG_NUM_{} = {}'.format( scrub_symbol_name(mesg_name).upper(), str(mesg_info.num) if mesg_info else 'None')) @@ -573,7 +573,7 @@ def main(input_xls_or_zip, output_py_path=None): mesg_name, field_name = field_fqn.split('.', maxsplit=1) mesg_info, field_info = message_list.get_field_by_name(mesg_name, field_name) - field_decl = 'FIELD_NUM_%s_%s = %s' % ( + field_decl = 'FIELD_NUM_{}_{} = {}'.format( scrub_symbol_name(mesg_name).upper(), scrub_symbol_name(field_name).upper(), str(field_info.num)) @@ -582,7 +582,7 @@ def main(input_xls_or_zip, output_py_path=None): output = '\n'.join([ "\n%s" % PROFILE_HEADER_FIRST_PART, - header('EXPORTED PROFILE FROM %s ON %s' % ( + header('EXPORTED PROFILE FROM {} ON {}'.format( ('SDK VERSION %s' % profile_version) if profile_version else 'SPREADSHEET', datetime.datetime.now().strftime('%Y-%m-%d'), )), @@ -609,7 +609,7 @@ def main(input_xls_or_zip, output_py_path=None): if output_py_path: open(output_py_path, 'w').write(output) - print('Profile version %s written to %s' % ( + print('Profile version {} written to {}'.format( profile_version if profile_version else '', output_py_path)) else: diff --git a/scripts/unit_tool.py b/scripts/unit_tool.py index ee1c876..0f66258 100755 --- a/scripts/unit_tool.py +++ b/scripts/unit_tool.py @@ -54,7 +54,7 @@ def do_fitparse_profile(): if __name__ == '__main__': if len(sys.argv) < 2: - print("Usage: {0} Profile.xls".format(os.path.basename(__file__))) + print(f"Usage: {os.path.basename(__file__)} Profile.xls") sys.exit(0) do_profile_xls() diff --git a/setup.py b/setup.py index 227293f..ad51427 100644 --- a/setup.py +++ b/setup.py @@ -5,8 +5,8 @@ requires = None -if sys.version_info < (2, 7): - requires = ['argparse'] +if sys.version_info < (3, 6): + sys.exit("Python 3.6+ is required.") setup( diff --git a/tests/files/coros-pace-2-cycling-misaligned-fields.fit b/tests/files/coros-pace-2-cycling-misaligned-fields.fit new file mode 100644 index 0000000..ae824ab Binary files /dev/null and b/tests/files/coros-pace-2-cycling-misaligned-fields.fit differ diff --git a/tests/files/nick.fit b/tests/files/nick.fit new file mode 100644 index 0000000..5c9d2e0 Binary files /dev/null and b/tests/files/nick.fit differ diff --git a/tests/files/strava-android-app-201.10-b1218918.fit b/tests/files/strava-android-app-201.10-b1218918.fit new file mode 100644 index 0000000..24977c7 Binary files /dev/null and b/tests/files/strava-android-app-201.10-b1218918.fit differ diff --git a/tests/test.py b/tests/test.py index 9a55f71..e4851d5 100755 --- a/tests/test.py +++ b/tests/test.py @@ -4,17 +4,14 @@ import datetime import os from struct import pack -import sys +import warnings from fitparse import FitFile from fitparse.processors import UTC_REFERENCE, StandardUnitsDataProcessor from fitparse.records import BASE_TYPES, Crc -from fitparse.utils import FitEOFError, FitCRCError, FitHeaderError +from fitparse.utils import FitEOFError, FitCRCError, FitHeaderError, FitParseError -if sys.version_info >= (2, 7): - import unittest -else: - import unittest2 as unittest +import unittest def generate_messages(mesg_num, local_mesg_num, field_defs, endian='<', data=None): @@ -38,7 +35,7 @@ def generate_messages(mesg_num, local_mesg_num, field_defs, endian='<', data=Non for mesg_data in data: s = pack('B', local_mesg_num) for value, base_type in zip(mesg_data, base_type_list): - s += pack("%s%s" % (endian, base_type.fmt), value) + s += pack("{}{}".format(endian, base_type.fmt), value) mesgs.append(s) return b''.join(mesgs) @@ -69,7 +66,7 @@ def generate_fitfile(data=None, endian='<'): def secs_to_dt(secs): - return datetime.datetime.utcfromtimestamp(secs + UTC_REFERENCE) + return datetime.datetime.fromtimestamp(timestamp=(secs + UTC_REFERENCE), tz=datetime.timezone.utc).replace(tzinfo=None) def testfile(filename): @@ -77,6 +74,7 @@ def testfile(filename): class FitFileTestCase(unittest.TestCase): + def test_basic_file_with_one_record(self, endian='<'): f = FitFile(generate_fitfile(endian=endian)) f.parse() @@ -109,7 +107,7 @@ def test_basic_file_big_endian(self): def test_component_field_accumulaters(self): # TODO: abstract CSV parsing - csv_fp = open(testfile('compressed-speed-distance-records.csv'), 'r') + csv_fp = open(testfile('compressed-speed-distance-records.csv')) csv_file = csv.reader(csv_fp) next(csv_file) # Consume header @@ -255,7 +253,7 @@ def test_parsing_edge_820_fit_file(self): 'garmin-edge-820-bike-records.csv') def _csv_test_helper(self, fit_file, csv_file): - csv_fp = open(testfile(csv_file), 'r') + csv_fp = open(testfile(csv_file)) csv_messages = csv.reader(csv_fp) field_names = next(csv_messages) # Consume header @@ -301,7 +299,7 @@ def _csv_test_helper(self, fit_file, csv_file): self.assertAlmostEqual(fit_value, float(csv_value)) else: self.assertEqual(fit_value, csv_value, - msg="For %s, FIT value '%s' did not match CSV value '%s'" % (field_name, fit_value, csv_value)) + msg="For {}, FIT value '{}' did not match CSV value '{}'".format(field_name, fit_value, csv_value)) try: next(messages) @@ -332,7 +330,8 @@ def test_invalid_crc(self): def test_unexpected_eof(self): try: - FitFile(testfile('activity-unexpected-eof.fit')).parse() + with warnings.catch_warnings(record=True): + FitFile(testfile('activity-unexpected-eof.fit')).parse() self.fail("Didn't detect an unexpected EOF") except FitEOFError: pass @@ -411,6 +410,48 @@ def test_speed(self): avg_speed = list(f.get_messages('session'))[0].get_values().get('avg_speed') self.assertEqual(avg_speed, 5.86) + def test_mismatched_field_size(self): + f = FitFile(testfile('coros-pace-2-cycling-misaligned-fields.fit')) + with warnings.catch_warnings(record=True) as w: + f.parse() + assert w + assert all( + "falling back to byte encoding" in str(x) + for x in w + if x.category == UserWarning + ) + self.assertEqual(len(f.messages), 11293) + + def test_unterminated_file(self): + f = FitFile(testfile('nick.fit'), check_crc=False) + with warnings.catch_warnings(record=True) as w: + f.parse() + + def test_developer_data_thread_safe(self): + """ + Test that a file with developer types in it can be parsed thread-safe. + This test opens 2 FIT files and tests whether the dev_types of one does not change the dev_types of the other. + """ + fit_file_1 = FitFile(testfile('developer-types-sample.fit')) + field_description_count = 0 + for message in fit_file_1.get_messages(): + if message.mesg_type.name == "field_description": + field_description_count += 1 + if field_description_count >= 4: + # Break after final field description message + break + + fit_file_2 = FitFile(testfile('developer-types-sample.fit')) + for message in fit_file_2.get_messages(): + if message.mesg_type.name == "developer_data_id": + break + + try: + fit_file_1.parse() + except FitParseError: + self.fail("parse() unexpectedly raised a FitParseError") + + # TODO: # * Test Processors: # - process_type_<>, process_field_<>, process_units_<>, process_message_<> diff --git a/tests/test_records.py b/tests/test_records.py index 5e3b823..fd401f9 100644 --- a/tests/test_records.py +++ b/tests/test_records.py @@ -1,14 +1,8 @@ #!/usr/bin/env python -import sys - from fitparse.records import Crc -if sys.version_info >= (2, 7): - import unittest -else: - import unittest2 as unittest - +import unittest class RecordsTestCase(unittest.TestCase): def test_crc(self): diff --git a/tests/test_utils.py b/tests/test_utils.py index 966548c..9d456aa 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,15 +2,13 @@ import io import os -import sys import tempfile +from pathlib import Path + from fitparse.utils import fileish_open, is_iterable -if sys.version_info >= (2, 7): - import unittest -else: - import unittest2 as unittest +import unittest def testfile(filename): @@ -38,6 +36,7 @@ def test_fopen(fileish): test_fopen(f.read()) with open(testfile("nametest.FIT"), 'rb') as f: test_fopen(io.BytesIO(f.read())) + test_fopen(Path(testfile('nametest.FIT'))) def test_fileish_open_write(self):