Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,24 @@ A library for reading and writing Garmin FIT files.
Installation
==================

### Using uv (recommended)

```bash
uv add fit-tool
```

### Using pip

```bash
python3 -m pip install --upgrade pip
python3 -m pip install --upgrade fit_tool
python3 -m pip install --upgrade fit-tool
```

Command line interface
=======================

```console
usage: fittool [-h] [-v] [-o OUTPUT] [-l LOG] [-t TYPE] FILE
usage: fit-tool [-h] [-v] [-o OUTPUT] [-l LOG] [-t TYPE] FILE

Tool for managing FIT files.

Expand All @@ -34,8 +43,13 @@ optional arguments:
```

### Convert file to CSV
```console
fittool oldstage.fit

```bash
# Using uv
uv run fit-tool oldstage.fit

# Or after installation
fit-tool oldstage.fit
```

Library Usage
Expand Down
115 changes: 0 additions & 115 deletions bin/fittool

This file was deleted.

79 changes: 79 additions & 0 deletions fit_tool/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""
Command line interface for fit_tool.

Copyright (c) 2017 Stages Cycling. All rights reserved.
"""
import argparse
import logging
import os

from fit_tool.fit_file import FitFile
from fit_tool.utils.logging import logger


def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description="Tool for managing FIT files."
)
parser.add_argument(
'fitfile',
metavar='FILE',
help='FIT file to process'
)
Comment on lines 19 to 23
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using argparse.FileType('r') opens the file in text mode, which can corrupt binary FIT files due to newline translation. It also causes the file to be opened twice: once by argparse and again by FitFile.from_file. It's better to pass the filename as a string and let FitFile.from_file handle all file operations in the correct mode.

    parser.add_argument(
        'fitfile',
        metavar='FILE',
        help='FIT file to process'
    )

parser.add_argument(
'-v', '--verbose',
action='store_true',
help='specify verbose output'
)
parser.add_argument("-o", "--output", help="Output filename.")
parser.add_argument("-l", "--log", help="Log filename.")
parser.add_argument(
"-t", "--type",
help="Output format type. Options: csv, fit."
)

return parser.parse_args()


def main():
"""Main entry point."""
args = parse_args()

formatter = logging.Formatter(fmt="%(asctime)s %(levelname)s %(message)s")

if args.log:
handler = logging.FileHandler(args.log)
handler.setFormatter(formatter)
logger.addHandler(handler)

if args.verbose:
handler = logging.StreamHandler()
handler.setFormatter(formatter)
logger.addHandler(handler)
Comment on lines 50 to 53
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The StreamHandler for verbose output currently uses the default logging format, which is inconsistent with the FileHandler. You should add a formatter to ensure log messages have the same structure, whether they are displayed on the console or written to a file.

    if args.verbose:
        handler = logging.StreamHandler()
        handler.setFormatter(logging.Formatter(fmt="%(asctime)s %(levelname)s %(message)s"))
        logger.addHandler(handler)

logger.setLevel(logging.DEBUG)
logger.info(f'Loading fit file {args.fitfile}...')

fit_file = FitFile.from_file(args.fitfile)

if args.type:
format_type = args.type
elif args.output:
_, out_ext = os.path.splitext(os.path.basename(args.output))
format_type = out_ext.lstrip('.')
else:
format_type = 'csv'

basename_noext, _ = os.path.splitext(os.path.basename(args.fitfile))
output_filename = args.output or f'{basename_noext}.{format_type}'

logger.info(f'Exporting fit file to {output_filename} as format {format_type}...')

if format_type == 'csv':
fit_file.to_csv(output_filename)
elif format_type == 'fit':
fit_file.to_file(output_filename)


if __name__ == "__main__":
main()
92 changes: 92 additions & 0 deletions fit_tool/tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Tests for the command line interface."""

import os
import tempfile
import unittest
from unittest.mock import patch

from fit_tool.cli import main, parse_args


class TestParseArgs(unittest.TestCase):

def test_parse_args_minimal(self):
with patch('sys.argv', ['fit-tool', 'test.fit']):
args = parse_args()
self.assertEqual(args.fitfile, 'test.fit')
self.assertFalse(args.verbose)
self.assertIsNone(args.output)
self.assertIsNone(args.log)
self.assertIsNone(args.type)

def test_parse_args_with_options(self):
with patch('sys.argv', ['fit-tool', 'test.fit', '-v', '-o', 'out.csv', '-l', 'log.txt', '-t', 'csv']):
args = parse_args()
self.assertEqual(args.fitfile, 'test.fit')
self.assertTrue(args.verbose)
self.assertEqual(args.output, 'out.csv')
self.assertEqual(args.log, 'log.txt')
self.assertEqual(args.type, 'csv')


class TestMain(unittest.TestCase):

def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.test_fit_file = os.path.join(
os.path.dirname(__file__),
'data',
'sdk',
'Activity.fit'
)

def tearDown(self):
for f in os.listdir(self.test_dir):
os.remove(os.path.join(self.test_dir, f))
os.rmdir(self.test_dir)

def test_main_convert_to_csv(self):
output_file = os.path.join(self.test_dir, 'output.csv')
with patch('sys.argv', ['fit-tool', self.test_fit_file, '-o', output_file]):
main()
self.assertTrue(os.path.exists(output_file))

def test_main_convert_to_fit(self):
output_file = os.path.join(self.test_dir, 'output.fit')
with patch('sys.argv', ['fit-tool', self.test_fit_file, '-o', output_file, '-t', 'fit']):
main()
self.assertTrue(os.path.exists(output_file))

def test_main_with_verbose(self):
output_file = os.path.join(self.test_dir, 'output.csv')
with patch('sys.argv', ['fit-tool', self.test_fit_file, '-v', '-o', output_file]):
main()
self.assertTrue(os.path.exists(output_file))

def test_main_with_log_file(self):
output_file = os.path.join(self.test_dir, 'output.csv')
log_file = os.path.join(self.test_dir, 'test.log')
with patch('sys.argv', ['fit-tool', self.test_fit_file, '-o', output_file, '-l', log_file]):
main()
self.assertTrue(os.path.exists(output_file))
self.assertTrue(os.path.exists(log_file))

def test_main_default_output_filename(self):
original_cwd = os.getcwd()
try:
os.chdir(self.test_dir)
with patch('sys.argv', ['fit-tool', self.test_fit_file]):
main()
self.assertTrue(os.path.exists('Activity.csv'))
finally:
os.chdir(original_cwd)

def test_main_infer_format_from_output(self):
output_file = os.path.join(self.test_dir, 'output.fit')
with patch('sys.argv', ['fit-tool', self.test_fit_file, '-o', output_file]):
main()
self.assertTrue(os.path.exists(output_file))


if __name__ == '__main__':
unittest.main()
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Homepage = "https://github.com/shaonianche/python_fit_tool"
Repository = "https://github.com/shaonianche/python_fit_tool.git"

[project.scripts]
fittool = "fit_tool.cli:main"
fit-tool = "fit_tool.cli:main"

[tool.setuptools.packages.find]
include = ["fit_tool*"]
Expand Down
17 changes: 0 additions & 17 deletions setup.py

This file was deleted.

Loading