python-fitparse 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fitparse/__init__.py +10 -0
- fitparse/base.py +625 -0
- fitparse/processors.py +131 -0
- fitparse/profile.py +16646 -0
- fitparse/records.py +418 -0
- fitparse/utils.py +67 -0
- python_fitparse-2.0.0.dist-info/METADATA +191 -0
- python_fitparse-2.0.0.dist-info/RECORD +11 -0
- python_fitparse-2.0.0.dist-info/WHEEL +4 -0
- python_fitparse-2.0.0.dist-info/entry_points.txt +2 -0
- python_fitparse-2.0.0.dist-info/licenses/LICENSE +22 -0
fitparse/records.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import struct
|
|
3
|
+
|
|
4
|
+
from itertools import zip_longest
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RecordBase:
|
|
8
|
+
# namedtuple-like base class. Subclasses should must __slots__
|
|
9
|
+
__slots__ = ()
|
|
10
|
+
|
|
11
|
+
# TODO: switch back to namedtuple, and don't use default arguments as None
|
|
12
|
+
# and see if that gives us any performance improvements
|
|
13
|
+
|
|
14
|
+
def __init__(self, *args, **kwargs):
|
|
15
|
+
for slot_name, value in zip_longest(self.__slots__, args, fillvalue=None):
|
|
16
|
+
setattr(self, slot_name, value)
|
|
17
|
+
for slot_name, value in kwargs.items():
|
|
18
|
+
setattr(self, slot_name, value)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MessageHeader(RecordBase):
|
|
22
|
+
__slots__ = ('is_definition', 'is_developer_data', 'local_mesg_num', 'time_offset')
|
|
23
|
+
|
|
24
|
+
def __repr__(self):
|
|
25
|
+
return '<MessageHeader: %s%s -- local mesg: #%d%s>' % (
|
|
26
|
+
'definition' if self.is_definition else 'data',
|
|
27
|
+
'(developer)' if self.is_developer_data else '',
|
|
28
|
+
self.local_mesg_num,
|
|
29
|
+
', time offset: %d' % self.time_offset if self.time_offset else '',
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DefinitionMessage(RecordBase):
|
|
34
|
+
__slots__ = ('header', 'endian', 'mesg_type', 'mesg_num', 'field_defs', 'dev_field_defs')
|
|
35
|
+
type = 'definition'
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def name(self):
|
|
39
|
+
return self.mesg_type.name if self.mesg_type else 'unknown_%d' % self.mesg_num
|
|
40
|
+
|
|
41
|
+
def __repr__(self):
|
|
42
|
+
return '<DefinitionMessage: %s (#%d) -- local mesg: #%d, field defs: [%s], dev field defs: [%s]>' % (
|
|
43
|
+
self.name,
|
|
44
|
+
self.mesg_num,
|
|
45
|
+
self.header.local_mesg_num,
|
|
46
|
+
', '.join([fd.name for fd in self.field_defs]),
|
|
47
|
+
', '.join([fd.name for fd in self.dev_field_defs]),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FieldDefinition(RecordBase):
|
|
52
|
+
__slots__ = ('field', 'def_num', 'base_type', 'size')
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def name(self):
|
|
56
|
+
return self.field.name if self.field else 'unknown_%d' % self.def_num
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def type(self):
|
|
60
|
+
return self.field.type if self.field else self.base_type
|
|
61
|
+
|
|
62
|
+
def __repr__(self):
|
|
63
|
+
return '<FieldDefinition: %s (#%d) -- type: %s (%s), size: %d byte%s>' % (
|
|
64
|
+
self.name,
|
|
65
|
+
self.def_num,
|
|
66
|
+
self.type.name, self.base_type.name,
|
|
67
|
+
self.size, 's' if self.size != 1 else '',
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class DevFieldDefinition(RecordBase):
|
|
72
|
+
__slots__ = ('field', 'dev_data_index', 'base_type', 'def_num', 'size')
|
|
73
|
+
|
|
74
|
+
def __init__(self, **kwargs):
|
|
75
|
+
super().__init__(**kwargs)
|
|
76
|
+
# For dev fields, the base_type and type are always the same.
|
|
77
|
+
self.base_type = self.type
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def name(self):
|
|
81
|
+
return self.field.name if self.field else 'unknown_dev_%d_%d' % (self.dev_data_index, self.def_num)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def type(self):
|
|
85
|
+
return self.field.type
|
|
86
|
+
|
|
87
|
+
def __repr__(self):
|
|
88
|
+
return '<DevFieldDefinition: %s:%s (#%d) -- type: %s, size: %d byte%s>' % (
|
|
89
|
+
self.name,
|
|
90
|
+
self.dev_data_index,
|
|
91
|
+
self.def_num,
|
|
92
|
+
self.type.name,
|
|
93
|
+
self.size, 's' if self.size != 1 else '',
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class DataMessage(RecordBase):
|
|
98
|
+
__slots__ = ('header', 'def_mesg', 'fields')
|
|
99
|
+
type = 'data'
|
|
100
|
+
|
|
101
|
+
def get(self, field_name, as_dict=False):
|
|
102
|
+
# SIMPLIFY: get rid of as_dict
|
|
103
|
+
for field_data in self.fields:
|
|
104
|
+
if field_data.is_named(field_name):
|
|
105
|
+
return field_data.as_dict() if as_dict else field_data
|
|
106
|
+
|
|
107
|
+
def get_raw_value(self, field_name):
|
|
108
|
+
field_data = self.get(field_name)
|
|
109
|
+
if field_data:
|
|
110
|
+
return field_data.raw_value
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
def get_value(self, field_name):
|
|
114
|
+
# SIMPLIFY: get rid of this completely
|
|
115
|
+
field_data = self.get(field_name)
|
|
116
|
+
if field_data:
|
|
117
|
+
return field_data.value
|
|
118
|
+
|
|
119
|
+
def get_values(self):
|
|
120
|
+
# SIMPLIFY: get rid of this completely
|
|
121
|
+
return {f.name if f.name else f.def_num: f.value for f in self.fields}
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def name(self):
|
|
125
|
+
return self.def_mesg.name
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def mesg_num(self):
|
|
129
|
+
# SIMPLIFY: get rid of this
|
|
130
|
+
return self.def_mesg.mesg_num
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def mesg_type(self):
|
|
134
|
+
# SIMPLIFY: get rid of this
|
|
135
|
+
return self.def_mesg.mesg_type
|
|
136
|
+
|
|
137
|
+
def as_dict(self):
|
|
138
|
+
# TODO: rethink this format
|
|
139
|
+
return {
|
|
140
|
+
'name': self.name,
|
|
141
|
+
'fields': [f.as_dict() for f in self.fields],
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
def __iter__(self):
|
|
145
|
+
# Sort by whether this is a known field, then its name
|
|
146
|
+
return iter(sorted(self.fields, key=lambda fd: (int(fd.field is None), fd.name)))
|
|
147
|
+
|
|
148
|
+
def __repr__(self):
|
|
149
|
+
return '<DataMessage: %s (#%d) -- local mesg: #%d, fields: [%s]>' % (
|
|
150
|
+
self.name, self.mesg_num, self.header.local_mesg_num,
|
|
151
|
+
', '.join([f"{fd.name}: {fd.value}" for fd in self.fields]),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def __str__(self):
|
|
155
|
+
# SIMPLIFY: get rid of this
|
|
156
|
+
return '%s (#%d)' % (self.name, self.mesg_num)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class FieldData(RecordBase):
|
|
160
|
+
__slots__ = ('field_def', 'field', 'parent_field', 'value', 'raw_value', 'units')
|
|
161
|
+
|
|
162
|
+
def __init__(self, *args, **kwargs):
|
|
163
|
+
super().__init__(self, *args, **kwargs)
|
|
164
|
+
if not self.units and self.field:
|
|
165
|
+
# Default to units on field, otherwise None.
|
|
166
|
+
# NOTE:Not a property since you may want to override this in a data processor
|
|
167
|
+
self.units = self.field.units
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def name(self):
|
|
171
|
+
return self.field.name if self.field else 'unknown_%d' % self.def_num
|
|
172
|
+
|
|
173
|
+
# TODO: Some notion of flags
|
|
174
|
+
|
|
175
|
+
def is_named(self, name):
|
|
176
|
+
if self.field:
|
|
177
|
+
if name in (self.field.name, self.field.def_num):
|
|
178
|
+
return True
|
|
179
|
+
if self.parent_field:
|
|
180
|
+
if name in (self.parent_field.name, self.parent_field.def_num):
|
|
181
|
+
return True
|
|
182
|
+
if self.field_def:
|
|
183
|
+
if name == self.field_def.def_num:
|
|
184
|
+
return True
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def def_num(self):
|
|
189
|
+
# Prefer to return the def_num on the field
|
|
190
|
+
# since field_def may be None if this field is dynamic
|
|
191
|
+
return self.field.def_num if self.field else self.field_def.def_num
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def base_type(self):
|
|
195
|
+
# Try field_def's base type, if it doesn't exist, this is a
|
|
196
|
+
# dynamically added field, so field doesn't be None
|
|
197
|
+
return self.field_def.base_type if self.field_def else self.field.base_type
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def is_base_type(self):
|
|
201
|
+
return self.field.is_base_type if self.field else True
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def type(self):
|
|
205
|
+
return self.field.type if self.field else self.base_type
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def field_type(self):
|
|
209
|
+
return self.field.field_type if self.field else 'field'
|
|
210
|
+
|
|
211
|
+
def as_dict(self):
|
|
212
|
+
return {
|
|
213
|
+
'name': self.name, 'def_num': self.def_num, 'base_type': self.base_type.name,
|
|
214
|
+
'type': self.type.name, 'units': self.units, 'value': self.value,
|
|
215
|
+
'raw_value': self.raw_value,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
def __repr__(self):
|
|
219
|
+
return '<FieldData: %s: %s%s, def num: %d, type: %s (%s), raw value: %s>' % (
|
|
220
|
+
self.name, self.value, ' [%s]' % self.units if self.units else '',
|
|
221
|
+
self.def_num, self.type.name, self.base_type.name, self.raw_value,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def __str__(self):
|
|
225
|
+
return '{}: {}{}'.format(
|
|
226
|
+
self.name, self.value, ' [%s]' % self.units if self.units else '',
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class BaseType(RecordBase):
|
|
231
|
+
__slots__ = ('name', 'identifier', 'fmt', 'parse')
|
|
232
|
+
values = None # In case we're treated as a FieldType
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def size(self):
|
|
236
|
+
return struct.calcsize(self.fmt)
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def type_num(self):
|
|
240
|
+
return self.identifier & 0x1F
|
|
241
|
+
|
|
242
|
+
def __repr__(self):
|
|
243
|
+
return '<BaseType: %s (#%d [0x%X])>' % (
|
|
244
|
+
self.name, self.type_num, self.identifier,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class FieldType(RecordBase):
|
|
249
|
+
__slots__ = ('name', 'base_type', 'values')
|
|
250
|
+
|
|
251
|
+
def __repr__(self):
|
|
252
|
+
return f'<FieldType: {self.name} ({self.base_type})>'
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class MessageType(RecordBase):
|
|
256
|
+
__slots__ = ('name', 'mesg_num', 'fields')
|
|
257
|
+
|
|
258
|
+
def __repr__(self):
|
|
259
|
+
return '<MessageType: %s (#%d)>' % (self.name, self.mesg_num)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class FieldAndSubFieldBase(RecordBase):
|
|
263
|
+
__slots__ = ()
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def base_type(self):
|
|
267
|
+
return self.type if self.is_base_type else self.type.base_type
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def is_base_type(self):
|
|
271
|
+
return isinstance(self.type, BaseType)
|
|
272
|
+
|
|
273
|
+
def render(self, raw_value):
|
|
274
|
+
if self.type.values and (raw_value in self.type.values):
|
|
275
|
+
return self.type.values[raw_value]
|
|
276
|
+
return raw_value
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class Field(FieldAndSubFieldBase):
|
|
280
|
+
__slots__ = ('name', 'type', 'def_num', 'scale', 'offset', 'units', 'components', 'subfields')
|
|
281
|
+
field_type = 'field'
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class SubField(FieldAndSubFieldBase):
|
|
285
|
+
__slots__ = ('name', 'def_num', 'type', 'scale', 'offset', 'units', 'components', 'ref_fields')
|
|
286
|
+
field_type = 'subfield'
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class DevField(FieldAndSubFieldBase):
|
|
290
|
+
__slots__ = ('dev_data_index', 'def_num', 'type', 'name', 'units', 'native_field_num',
|
|
291
|
+
# The rest of these are just to be compatible with Field objects. They're always None
|
|
292
|
+
'scale', 'offset', 'components', 'subfields')
|
|
293
|
+
field_type = 'devfield'
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class ReferenceField(RecordBase):
|
|
297
|
+
__slots__ = ('name', 'def_num', 'value', 'raw_value')
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class ComponentField(RecordBase):
|
|
301
|
+
__slots__ = ('name', 'def_num', 'scale', 'offset', 'units', 'accumulate', 'bits', 'bit_offset')
|
|
302
|
+
field_type = 'component'
|
|
303
|
+
|
|
304
|
+
def render(self, raw_value):
|
|
305
|
+
if raw_value is None:
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
# If it's a tuple, then it's a byte array and unpack it as such
|
|
309
|
+
# (only type that uses this is compressed speed/distance)
|
|
310
|
+
if isinstance(raw_value, tuple):
|
|
311
|
+
# Profile.xls sometimes contains more components than the read raw
|
|
312
|
+
# value is able to hold (typically the *event_timestamp_12* field in
|
|
313
|
+
# *hr* messages).
|
|
314
|
+
# This test allows to ensure *unpacked_num* is not right-shifted
|
|
315
|
+
# more than necessary.
|
|
316
|
+
if self.bit_offset and self.bit_offset >= len(raw_value) << 3:
|
|
317
|
+
raise ValueError()
|
|
318
|
+
|
|
319
|
+
unpacked_num = 0
|
|
320
|
+
|
|
321
|
+
# Unpack byte array as little endian
|
|
322
|
+
for value in reversed(raw_value):
|
|
323
|
+
unpacked_num = (unpacked_num << 8) + value
|
|
324
|
+
|
|
325
|
+
raw_value = unpacked_num
|
|
326
|
+
|
|
327
|
+
# Mask and shift like a normal number
|
|
328
|
+
if isinstance(raw_value, int):
|
|
329
|
+
raw_value = (raw_value >> self.bit_offset) & ((1 << self.bits) - 1)
|
|
330
|
+
|
|
331
|
+
return raw_value
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class Crc:
|
|
335
|
+
"""FIT file CRC computation."""
|
|
336
|
+
|
|
337
|
+
CRC_TABLE = (
|
|
338
|
+
0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401,
|
|
339
|
+
0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
FMT = 'H'
|
|
343
|
+
|
|
344
|
+
def __init__(self, value=0, byte_arr=None):
|
|
345
|
+
self.value = value
|
|
346
|
+
if byte_arr:
|
|
347
|
+
self.update(byte_arr)
|
|
348
|
+
|
|
349
|
+
def __repr__(self):
|
|
350
|
+
return '<{} {}>'.format(self.__class__.__name__, self.value or "-")
|
|
351
|
+
|
|
352
|
+
def __str__(self):
|
|
353
|
+
return self.format(self.value)
|
|
354
|
+
|
|
355
|
+
def update(self, byte_arr):
|
|
356
|
+
"""Read bytes and update the CRC computed."""
|
|
357
|
+
if byte_arr:
|
|
358
|
+
self.value = self.calculate(byte_arr, self.value)
|
|
359
|
+
|
|
360
|
+
@staticmethod
|
|
361
|
+
def format(value):
|
|
362
|
+
"""Format CRC value to string."""
|
|
363
|
+
return '0x%04X' % value
|
|
364
|
+
|
|
365
|
+
@classmethod
|
|
366
|
+
def calculate(cls, byte_arr, crc=0):
|
|
367
|
+
"""Compute CRC for input bytes."""
|
|
368
|
+
for byte in byte_arr:
|
|
369
|
+
# Taken verbatim from FIT SDK docs
|
|
370
|
+
tmp = cls.CRC_TABLE[crc & 0xF]
|
|
371
|
+
crc = (crc >> 4) & 0x0FFF
|
|
372
|
+
crc = crc ^ tmp ^ cls.CRC_TABLE[byte & 0xF]
|
|
373
|
+
|
|
374
|
+
tmp = cls.CRC_TABLE[crc & 0xF]
|
|
375
|
+
crc = (crc >> 4) & 0x0FFF
|
|
376
|
+
crc = crc ^ tmp ^ cls.CRC_TABLE[(byte >> 4) & 0xF]
|
|
377
|
+
return crc
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def parse_string(string):
|
|
381
|
+
try:
|
|
382
|
+
s = string[:string.index(0x00)]
|
|
383
|
+
except ValueError:
|
|
384
|
+
# FIT specification defines the 'string' type as follows: "Null
|
|
385
|
+
# terminated string encoded in UTF-8 format".
|
|
386
|
+
#
|
|
387
|
+
# However 'string' values are not always null-terminated when encoded,
|
|
388
|
+
# according to FIT files created by Garmin devices (e.g. DEVICE.FIT file
|
|
389
|
+
# from a fenix3).
|
|
390
|
+
#
|
|
391
|
+
# So in order to be more flexible, in case index() could not find any
|
|
392
|
+
# null byte, we just decode the whole bytes-like object.
|
|
393
|
+
s = string
|
|
394
|
+
|
|
395
|
+
return s.decode(encoding='utf-8', errors='replace') or None
|
|
396
|
+
|
|
397
|
+
# The default base type
|
|
398
|
+
BASE_TYPE_BYTE = BaseType(name='byte', identifier=0x0D, fmt='B', parse=lambda x: None if all(b == 0xFF for b in x) else x)
|
|
399
|
+
|
|
400
|
+
BASE_TYPES = {
|
|
401
|
+
0x00: BaseType(name='enum', identifier=0x00, fmt='B', parse=lambda x: None if x == 0xFF else x),
|
|
402
|
+
0x01: BaseType(name='sint8', identifier=0x01, fmt='b', parse=lambda x: None if x == 0x7F else x),
|
|
403
|
+
0x02: BaseType(name='uint8', identifier=0x02, fmt='B', parse=lambda x: None if x == 0xFF else x),
|
|
404
|
+
0x83: BaseType(name='sint16', identifier=0x83, fmt='h', parse=lambda x: None if x == 0x7FFF else x),
|
|
405
|
+
0x84: BaseType(name='uint16', identifier=0x84, fmt='H', parse=lambda x: None if x == 0xFFFF else x),
|
|
406
|
+
0x85: BaseType(name='sint32', identifier=0x85, fmt='i', parse=lambda x: None if x == 0x7FFFFFFF else x),
|
|
407
|
+
0x86: BaseType(name='uint32', identifier=0x86, fmt='I', parse=lambda x: None if x == 0xFFFFFFFF else x),
|
|
408
|
+
0x07: BaseType(name='string', identifier=0x07, fmt='s', parse=parse_string),
|
|
409
|
+
0x88: BaseType(name='float32', identifier=0x88, fmt='f', parse=lambda x: None if math.isnan(x) else x),
|
|
410
|
+
0x89: BaseType(name='float64', identifier=0x89, fmt='d', parse=lambda x: None if math.isnan(x) else x),
|
|
411
|
+
0x0A: BaseType(name='uint8z', identifier=0x0A, fmt='B', parse=lambda x: None if x == 0x0 else x),
|
|
412
|
+
0x8B: BaseType(name='uint16z', identifier=0x8B, fmt='H', parse=lambda x: None if x == 0x0 else x),
|
|
413
|
+
0x8C: BaseType(name='uint32z', identifier=0x8C, fmt='I', parse=lambda x: None if x == 0x0 else x),
|
|
414
|
+
0x0D: BASE_TYPE_BYTE,
|
|
415
|
+
0x8E: BaseType(name='sint64', identifier=0x8E, fmt='q', parse=lambda x: None if x == 0x7FFFFFFFFFFFFFFF else x),
|
|
416
|
+
0x8F: BaseType(name='uint64', identifier=0x8F, fmt='Q', parse=lambda x: None if x == 0xFFFFFFFFFFFFFFFF else x),
|
|
417
|
+
0x90: BaseType(name='uint64z', identifier=0x90, fmt='Q', parse=lambda x: None if x == 0 else x),
|
|
418
|
+
}
|
fitparse/utils.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import re
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
from pathlib import PurePath
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FitParseError(ValueError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
class FitEOFError(FitParseError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
class FitCRCError(FitParseError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
class FitHeaderError(FitParseError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
METHOD_NAME_SCRUBBER = re.compile(r'\W|^(?=\d)')
|
|
22
|
+
UNIT_NAME_TO_FUNC_REPLACEMENTS = (
|
|
23
|
+
('/', ' per '),
|
|
24
|
+
('%', 'percent'),
|
|
25
|
+
('*', ' times '),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def scrub_method_name(method_name, convert_units=False):
|
|
29
|
+
if convert_units:
|
|
30
|
+
for replace_from, replace_to in UNIT_NAME_TO_FUNC_REPLACEMENTS:
|
|
31
|
+
method_name = method_name.replace(
|
|
32
|
+
replace_from, '%s' % replace_to,
|
|
33
|
+
)
|
|
34
|
+
return METHOD_NAME_SCRUBBER.sub('_', method_name)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fileish_open(fileish, mode):
|
|
38
|
+
"""
|
|
39
|
+
Convert file-ish object to BytesIO like object.
|
|
40
|
+
:param fileish: the file-ihs object (str, BytesIO, bytes, file contents)
|
|
41
|
+
:param str mode: mode for the open function.
|
|
42
|
+
:rtype: BytesIO
|
|
43
|
+
"""
|
|
44
|
+
if mode is not None and any(m in mode for m in ['+', 'w', 'a', 'x']):
|
|
45
|
+
attr = 'write'
|
|
46
|
+
else:
|
|
47
|
+
attr = 'read'
|
|
48
|
+
if hasattr(fileish, attr) and hasattr(fileish, 'seek'):
|
|
49
|
+
# BytesIO-like object
|
|
50
|
+
return fileish
|
|
51
|
+
elif isinstance(fileish, str):
|
|
52
|
+
# file path
|
|
53
|
+
return open(fileish, mode)
|
|
54
|
+
|
|
55
|
+
# pathlib obj
|
|
56
|
+
if isinstance(fileish, PurePath):
|
|
57
|
+
return fileish.open(mode)
|
|
58
|
+
|
|
59
|
+
# file contents
|
|
60
|
+
return io.BytesIO(fileish)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_iterable(obj):
|
|
64
|
+
"""Check, if the obj is iterable but not string or bytes.
|
|
65
|
+
:rtype bool"""
|
|
66
|
+
# Speed: do not use iter() although it's more robust, see also https://stackoverflow.com/questions/1952464/
|
|
67
|
+
return isinstance(obj, Iterable) and not isinstance(obj, (str, bytes))
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-fitparse
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Python library to parse ANT/Garmin .FIT files
|
|
5
|
+
Project-URL: Homepage, https://www.github.com/nbr23/python-fitparse
|
|
6
|
+
Project-URL: Repository, https://www.github.com/nbr23/python-fitparse
|
|
7
|
+
Project-URL: Issues, https://github.com/nbr23/python-fitparse/issues
|
|
8
|
+
Author-email: David Cooper <dave@kupesoft.com>, nbr23 <max@23.tf>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2011-2025, David Cooper <david@dtcooper.com>
|
|
12
|
+
Copyright (c) 2017-2025, Carey Metcalfe <carey@cmetcalfe.ca>
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: ant,files,fit,garmin,parse
|
|
33
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
42
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
43
|
+
Requires-Python: >=3.8
|
|
44
|
+
Provides-Extra: generate
|
|
45
|
+
Requires-Dist: openpyxl==3.1.5; extra == 'generate'
|
|
46
|
+
Requires-Dist: requests; extra == 'generate'
|
|
47
|
+
Description-Content-Type: text/markdown
|
|
48
|
+
|
|
49
|
+
python-fitparse
|
|
50
|
+
===============
|
|
51
|
+
|
|
52
|
+
> :warning: **NOTE:** *I have **limited to no time** to work on this package
|
|
53
|
+
> these days!*
|
|
54
|
+
>
|
|
55
|
+
> I am looking for a maintainer to help with issues and updating/releasing the package.
|
|
56
|
+
> Please reach out via email at <david@dtcooper.com> if you have interest in helping.
|
|
57
|
+
>
|
|
58
|
+
> If you're having trouble using this package for whatever reason, might we suggest using
|
|
59
|
+
> an alternative library: [fitdecode](https://github.com/polyvertex/fitdecode) by
|
|
60
|
+
> [polyvertex](https://github.com/polyvertex).
|
|
61
|
+
>
|
|
62
|
+
> Cheers,
|
|
63
|
+
>
|
|
64
|
+
> David
|
|
65
|
+
|
|
66
|
+
Here's a Python library to parse ANT/Garmin `.FIT` files.
|
|
67
|
+
[](https://github.com/dtcooper/python-fitparse/actions?query=workflow%3Atest)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
Install from [](https://pypi.python.org/pypi/fitparse/):
|
|
71
|
+
```
|
|
72
|
+
pip install fitparse
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
FIT files
|
|
76
|
+
------------
|
|
77
|
+
- FIT files contain data stored in a binary file format.
|
|
78
|
+
- The FIT (Flexible and Interoperable Data Transfer) file protocol is specified
|
|
79
|
+
by [ANT](http://www.thisisant.com/).
|
|
80
|
+
- The SDK, code examples, and detailed documentation can be found in the
|
|
81
|
+
[ANT FIT SDK](http://www.thisisant.com/resources/fit).
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
Usage
|
|
85
|
+
-----
|
|
86
|
+
A simple example of printing records from a fit file:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
import fitparse
|
|
90
|
+
|
|
91
|
+
# Load the FIT file
|
|
92
|
+
fitfile = fitparse.FitFile("my_activity.fit")
|
|
93
|
+
|
|
94
|
+
# Iterate over all messages of type "record"
|
|
95
|
+
# (other types include "device_info", "file_creator", "event", etc)
|
|
96
|
+
for record in fitfile.get_messages("record"):
|
|
97
|
+
|
|
98
|
+
# Records can contain multiple pieces of data (ex: timestamp, latitude, longitude, etc)
|
|
99
|
+
for data in record:
|
|
100
|
+
|
|
101
|
+
# Print the name and value of the data (and the units if it has any)
|
|
102
|
+
if data.units:
|
|
103
|
+
print(" * {}: {} ({})".format(data.name, data.value, data.units))
|
|
104
|
+
else:
|
|
105
|
+
print(" * {}: {}".format(data.name, data.value))
|
|
106
|
+
|
|
107
|
+
print("---")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The library also provides a `fitdump` script for command line usage:
|
|
111
|
+
```
|
|
112
|
+
$ fitdump --help
|
|
113
|
+
usage: fitdump [-h] [-v] [-o OUTPUT] [-t {readable,json}] [-n NAME] [--ignore-crc] FITFILE
|
|
114
|
+
|
|
115
|
+
Dump .FIT files to various formats
|
|
116
|
+
|
|
117
|
+
positional arguments:
|
|
118
|
+
FITFILE Input .FIT file (Use - for stdin)
|
|
119
|
+
|
|
120
|
+
optional arguments:
|
|
121
|
+
-h, --help show this help message and exit
|
|
122
|
+
-v, --verbose
|
|
123
|
+
-o OUTPUT, --output OUTPUT
|
|
124
|
+
File to output data into (defaults to stdout)
|
|
125
|
+
-t {readable,json}, --type {readable,json}
|
|
126
|
+
File type to output. (DEFAULT: readable)
|
|
127
|
+
-n NAME, --name NAME Message name (or number) to filter
|
|
128
|
+
--ignore-crc Some devices can write invalid crc's, ignore these.
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
See the documentation for more: http://dtcooper.github.io/python-fitparse
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
Major Changes From Original Version
|
|
135
|
+
-----------------------------------
|
|
136
|
+
|
|
137
|
+
After a few years of laying dormant we are back to active development!
|
|
138
|
+
The old version is archived as
|
|
139
|
+
[`v1-archive`](https://github.com/dtcooper/python-fitparse/releases/tag/v1-archive).
|
|
140
|
+
|
|
141
|
+
* New, hopefully cleaner public API with a clear division between accessible
|
|
142
|
+
and internal parts. (Still unstable and partially complete.)
|
|
143
|
+
|
|
144
|
+
* Proper documentation!
|
|
145
|
+
[Available here](https://dtcooper.github.io/python-fitparse/).
|
|
146
|
+
|
|
147
|
+
* Unit tests and example programs.
|
|
148
|
+
|
|
149
|
+
* **(WIP)** Command line tools (eg a `.FIT` to `.CSV` converter).
|
|
150
|
+
|
|
151
|
+
* Component fields and compressed timestamp headers now supported and not
|
|
152
|
+
just an afterthought. Closes issues #6 and #7.
|
|
153
|
+
|
|
154
|
+
* FIT file parsing is generic enough to support all types. Going to have
|
|
155
|
+
specific `FitFile` subclasses for more popular file types like activities.
|
|
156
|
+
|
|
157
|
+
* **(WIP)** Converting field types to normalized values (for example,
|
|
158
|
+
`bool`, `date_time`, etc) done in a consistent way, that's easy to
|
|
159
|
+
customize by subclassing the converter class. I'm going to use something
|
|
160
|
+
like the Django form-style `convert_<field name>` idiom on this class.
|
|
161
|
+
|
|
162
|
+
* The FIT profile is its own complete python module, rather than using
|
|
163
|
+
`profile.def`.
|
|
164
|
+
|
|
165
|
+
* Bonus! The profile generation script is _less_ ugly (but still an
|
|
166
|
+
atrocity) and supports every
|
|
167
|
+
[ANT FIT SDK](http://www.thisisant.com/resources/fit) from version 1.00
|
|
168
|
+
up to 5.10.
|
|
169
|
+
|
|
170
|
+
* A working `setup.py` module. Closes issue #2, finally! I'll upload the
|
|
171
|
+
package to [PyPI](http://pypi.python.org/) when it's done.
|
|
172
|
+
|
|
173
|
+
* Support for parsing one record at a time. This can be done using
|
|
174
|
+
`<FitFile>.parse_one()` for now, but I'm not sure of the exact
|
|
175
|
+
implementation yet.
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
Updating to new FIT SDK versions
|
|
179
|
+
--------------------------------
|
|
180
|
+
- Download the latest [ANT FIT SDK](http://www.thisisant.com/resources/fit).
|
|
181
|
+
- Update the profile:
|
|
182
|
+
```
|
|
183
|
+
python3 scripts/generate_profile.py /path/to/fit_sdk.zip fitparse/profile.py
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
License
|
|
188
|
+
-------
|
|
189
|
+
|
|
190
|
+
This project is licensed under the MIT License - see the [`LICENSE`](LICENSE)
|
|
191
|
+
file for details.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
fitparse/__init__.py,sha256=XigqA8TTG1j9B3PyV7PfAfJhVL2NQVGQrMtfmElEuNM,336
|
|
2
|
+
fitparse/base.py,sha256=9Prisb5xfPGM79kszvfATVIgYMC3gWnHWaX1kqoHmAQ,24202
|
|
3
|
+
fitparse/processors.py,sha256=gQNdFiT5xVn3R9uX-cjijL2JQ3cKtuygjAPKIZ7J53Q,5357
|
|
4
|
+
fitparse/profile.py,sha256=WVsU060VBVSQfJzMHojHG6GSmPK88OT4VGgRVAs0910,582980
|
|
5
|
+
fitparse/records.py,sha256=bjiuG6P2SrRHxmGWjcNbj29nnM6UAl9sw-toR2rFudk,14396
|
|
6
|
+
fitparse/utils.py,sha256=cYmAKRktsiTKpxAAvp9XwjfN2BKzEP9-0fAKoTTu_Sw,1750
|
|
7
|
+
python_fitparse-2.0.0.dist-info/METADATA,sha256=avMAmoxsvuC60-1pD8xXAbiPIHl9n6ugGReVW04ZbXE,7408
|
|
8
|
+
python_fitparse-2.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
9
|
+
python_fitparse-2.0.0.dist-info/entry_points.txt,sha256=cM-FrZMgY175Od7pYZX7yu48XDynph8o2w-FBOBIyRU,49
|
|
10
|
+
python_fitparse-2.0.0.dist-info/licenses/LICENSE,sha256=RK-rKfCYWql9q58dgvYwpDuo-N4g0L4iXxr8D5ubMIo,1157
|
|
11
|
+
python_fitparse-2.0.0.dist-info/RECORD,,
|