diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a535b93..d176d94 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['2.7', '3.3', '3.4', '3.5', '3.6', '3.x', 'pypy2', 'pypy3'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.x'] steps: - uses: actions/checkout@v2 diff --git a/LICENSE b/LICENSE index 387b0b0..901fe39 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ MIT License -Copyright (c) 2011-2022, David Cooper -Copyright (c) 2017-2022, 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 50c3cad..d526522 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,20 @@ 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) 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 434a376..4a4fc30 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -56,7 +56,7 @@ Requirements The following are required to install :mod:`fitparse`, -* `Python `_ 2.7 and above +* `Python `_ 3.6 and above API Documentation diff --git a/fitparse/base.py b/fitparse/base.py index 6af6392..b0ebf87 100644 --- a/fitparse/base.py +++ b/fitparse/base.py @@ -5,12 +5,6 @@ import struct import warnings -# Python 2 compat -try: - num_types = (int, float, long) -except NameError: - num_types = (int, float) - from fitparse.processors import FitFileDataProcessor from fitparse.profile import FIELD_TYPE_TIMESTAMP, MESSAGE_TYPES from fitparse.records import ( @@ -20,12 +14,12 @@ from fitparse.utils import fileish_open, is_iterable, FitParseError, FitEOFError, FitCRCError, FitHeaderError -class DeveloperDataMixin(object): +class DeveloperDataMixin: def __init__(self, *args, check_developer_data=True, **kwargs): self.check_developer_data = check_developer_data self.dev_types = {} - super(DeveloperDataMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def _append_dev_data_id(self, dev_data_index, application_id=None, fields=None): if fields is None: @@ -97,7 +91,7 @@ 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( - "No such dev_data_index=%s found when looking up field %s" % (dev_data_index, field_def_num) + f"No such dev_data_index={dev_data_index} found when looking up field {field_def_num}" ) warnings.warn( @@ -110,11 +104,11 @@ def get_dev_type(self, dev_data_index, field_def_num): if field_def_num not in dev_type['fields']: if self.check_developer_data: raise FitParseError( - "No such field %s for dev_data_index %s" % (field_def_num, dev_data_index) + f"No such field {field_def_num} for dev_data_index {dev_data_index}" ) warnings.warn( - "Field %s for dev_data_index %s missing. Adding dummy field." % (field_def_num, dev_data_index) + 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, @@ -141,7 +135,7 @@ def __init__(self, fileish, *args, check_crc=True, data_processor=None, **kwargs # Start off by parsing the file header (sets initial attribute values) self._parse_file_header() - super(FitFileDecoder, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def __del__(self): self.close() @@ -193,7 +187,7 @@ def _read_and_assert_crc(self, allow_zero=False): 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))) ########## @@ -396,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: @@ -530,7 +524,7 @@ def _make_set(obj): if is_iterable(obj): return set(obj) else: - return set((obj,)) + return {obj} ########## # Public API @@ -550,15 +544,15 @@ def __iter__(self): return self.get_messages() -class CacheMixin(object): +class CacheMixin: """Add message caching to the FitFileDecoder""" def __init__(self, *args, **kwargs): - super(CacheMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._messages = [] def _parse_message(self): - self._messages.append(super(CacheMixin, self)._parse_message()) + self._messages.append(super()._parse_message()) return self._messages[-1] def get_messages(self, name=None, with_definitions=False, as_dict=False): @@ -572,7 +566,7 @@ def get_messages(self, name=None, with_definitions=False, as_dict=False): if self._should_yield(message, with_definitions, names): yield message.as_dict() if as_dict else message - for message in super(CacheMixin, self).get_messages(names, with_definitions, as_dict): + for message in super().get_messages(names, with_definitions, as_dict): yield message @property @@ -584,12 +578,12 @@ def parse(self): pass -class DataProcessorMixin(object): +class DataProcessorMixin: """Add data processing to the FitFileDecoder""" def __init__(self, *args, **kwargs): self._processor = kwargs.pop("data_processor", None) or FitFileDataProcessor() - super(DataProcessorMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def _parse_data_message(self, header): header, def_mesg, field_datas = self._parse_data_message_components(header) @@ -612,7 +606,7 @@ class UncachedFitFile(DataProcessorMixin, FitFileDecoder): def __init__(self, fileish, *args, check_crc=True, data_processor=None, **kwargs): # Ensure all optional params are passed as kwargs - super(UncachedFitFile, self).__init__( + super().__init__( fileish, *args, check_crc=check_crc, diff --git a/fitparse/processors.py b/fitparse/processors.py index 493f166..34b36ba 100644 --- a/fitparse/processors.py +++ b/fitparse/processors.py @@ -5,7 +5,7 @@ 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: 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 f9149e4..e9fcd6a 100644 --- a/fitparse/records.py +++ b/fitparse/records.py @@ -1,21 +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 - -class RecordBase(object): +class RecordBase: # namedtuple-like base class. Subclasses should must __slots__ __slots__ = () @@ -83,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 @@ -129,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): @@ -159,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): @@ -171,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 @@ -233,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 '', ) @@ -260,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): @@ -336,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 = ( @@ -358,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) @@ -376,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 @@ -390,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". diff --git a/fitparse/utils.py b/fitparse/utils.py index aa70a90..65f424a 100644 --- a/fitparse/utils.py +++ b/fitparse/utils.py @@ -1,15 +1,8 @@ import io import re -try: - from collections.abc import Iterable -except ImportError: - from collections import Iterable +from collections.abc import Iterable -try: - # Python 3.4+ - from pathlib import PurePath -except ImportError: - PurePath = None +from pathlib import PurePath class FitParseError(ValueError): @@ -56,18 +49,14 @@ 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) + # file path + return open(fileish, mode) - # Python 3 - pathlib obj - if PurePath and isinstance(fileish, PurePath): + # pathlib obj + if isinstance(fileish, PurePath): return fileish.open(mode) - # Python 3 - file contents + # file contents return io.BytesIO(fileish) diff --git a/scripts/fitdump b/scripts/fitdump index 5e4a4b4..629f61b 100755 --- a/scripts/fitdump +++ b/scripts/fitdump @@ -1,5 +1,4 @@ #!/usr/bin/env python -from __future__ import print_function import argparse import codecs @@ -7,30 +6,22 @@ import datetime import itertools import json import os.path -import sys 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') @@ -44,7 +35,8 @@ 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( @@ -65,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) @@ -92,7 +78,7 @@ 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): @@ -113,7 +99,7 @@ def generate_gpx(records, filename=None): if message.name == "file_id": for field_data in message: if field_data.name == "time_created" and type(field_data.value) == datetime.datetime: - yield ' \n'.format(field_data.value.strftime(GPX_TIME_FMT)) + yield f' \n' break else: # No time found in the fields, check next record @@ -124,13 +110,13 @@ def generate_gpx(records, filename=None): break if filename: - yield ' {}\n'.format(filename) + yield f' {filename}\n' yield ' \n' yield ' \n' if filename: - yield ' {}\n'.format(filename) + yield f' {filename}\n' yield ' \n' @@ -204,7 +190,7 @@ def main(args=None): finally: try: options.output.close() - except IOError: + except OSError: pass if __name__ == '__main__': 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 7c8dc13..ad51427 100644 --- a/setup.py +++ b/setup.py @@ -5,8 +5,8 @@ requires = None -if sys.version_info < (2, 7) or (3, 0) <= sys.version_info < (3, 3): - sys.exit("Python 2.7 or Python 3.3+ are required.") +if sys.version_info < (3, 6): + sys.exit("Python 3.6+ is required.") setup( 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 eea5dc2..e4851d5 100755 --- a/tests/test.py +++ b/tests/test.py @@ -4,7 +4,6 @@ import datetime import os from struct import pack -import sys import warnings from fitparse import FitFile @@ -12,10 +11,7 @@ from fitparse.records import BASE_TYPES, Crc 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): @@ -39,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) @@ -70,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): @@ -78,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() @@ -110,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 @@ -256,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 @@ -302,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) @@ -418,7 +415,11 @@ def test_mismatched_field_size(self): 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) + 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): 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 01d3409..9d456aa 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,21 +2,13 @@ import io import os -import sys import tempfile -try: - # Python 3.4+ - from pathlib import Path -except ImportError: - Path = None +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): @@ -44,8 +36,7 @@ def test_fopen(fileish): test_fopen(f.read()) with open(testfile("nametest.FIT"), 'rb') as f: test_fopen(io.BytesIO(f.read())) - if Path: - test_fopen(Path(testfile('nametest.FIT'))) + test_fopen(Path(testfile('nametest.FIT'))) def test_fileish_open_write(self):