Skip to content

Commit aefb0ae

Browse files
feat: add uv support and modernize CLI (#18)
* feat: add uv support and modernize CLI - Add fit_tool/cli.py as the new CLI entry point - Update pyproject.toml: rename command from 'fittool' to 'fit-tool' - Remove legacy bin/fittool and setup.py - Update README.md with uv installation and usage instructions Amp-Thread-ID: https://ampcode.com/threads/T-019c16e3-e389-71f9-9c5e-8d7fa2bdd176 Co-authored-by: Amp <amp@ampcode.com> * fix: use filename string instead of FileType for binary FIT files argparse.FileType('r') opens files in text mode which can corrupt binary FIT files due to newline translation. Now passing the filename as a string and letting FitFile.from_file handle file operations. Amp-Thread-ID: https://ampcode.com/threads/T-019c16e3-e389-71f9-9c5e-8d7fa2bdd176 Co-authored-by: Amp <amp@ampcode.com> * fix: add consistent log formatter to StreamHandler Amp-Thread-ID: https://ampcode.com/threads/T-019c16e3-e389-71f9-9c5e-8d7fa2bdd176 Co-authored-by: Amp <amp@ampcode.com> * test: add CLI tests with 100% coverage Amp-Thread-ID: https://ampcode.com/threads/T-019c16e3-e389-71f9-9c5e-8d7fa2bdd176 Co-authored-by: Amp <amp@ampcode.com> * refactor: use existing logger from fit_tool.utils.logging Amp-Thread-ID: https://ampcode.com/threads/T-019c16e3-e389-71f9-9c5e-8d7fa2bdd176 Co-authored-by: Amp <amp@ampcode.com> --------- Co-authored-by: Amp <amp@ampcode.com>
1 parent 8b156f0 commit aefb0ae

6 files changed

Lines changed: 190 additions & 137 deletions

File tree

README.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,24 @@ A library for reading and writing Garmin FIT files.
99
Installation
1010
==================
1111

12+
### Using uv (recommended)
13+
14+
```bash
15+
uv add fit-tool
1216
```
17+
18+
### Using pip
19+
20+
```bash
1321
python3 -m pip install --upgrade pip
14-
python3 -m pip install --upgrade fit_tool
22+
python3 -m pip install --upgrade fit-tool
1523
```
1624

1725
Command line interface
1826
=======================
27+
1928
```console
20-
usage: fittool [-h] [-v] [-o OUTPUT] [-l LOG] [-t TYPE] FILE
29+
usage: fit-tool [-h] [-v] [-o OUTPUT] [-l LOG] [-t TYPE] FILE
2130

2231
Tool for managing FIT files.
2332

@@ -34,8 +43,13 @@ optional arguments:
3443
```
3544

3645
### Convert file to CSV
37-
```console
38-
fittool oldstage.fit
46+
47+
```bash
48+
# Using uv
49+
uv run fit-tool oldstage.fit
50+
51+
# Or after installation
52+
fit-tool oldstage.fit
3953
```
4054

4155
Library Usage

bin/fittool

Lines changed: 0 additions & 115 deletions
This file was deleted.

fit_tool/cli.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""
2+
Command line interface for fit_tool.
3+
4+
Copyright (c) 2017 Stages Cycling. All rights reserved.
5+
"""
6+
import argparse
7+
import logging
8+
import os
9+
10+
from fit_tool.fit_file import FitFile
11+
from fit_tool.utils.logging import logger
12+
13+
14+
def parse_args():
15+
"""Parse command line arguments."""
16+
parser = argparse.ArgumentParser(
17+
description="Tool for managing FIT files."
18+
)
19+
parser.add_argument(
20+
'fitfile',
21+
metavar='FILE',
22+
help='FIT file to process'
23+
)
24+
parser.add_argument(
25+
'-v', '--verbose',
26+
action='store_true',
27+
help='specify verbose output'
28+
)
29+
parser.add_argument("-o", "--output", help="Output filename.")
30+
parser.add_argument("-l", "--log", help="Log filename.")
31+
parser.add_argument(
32+
"-t", "--type",
33+
help="Output format type. Options: csv, fit."
34+
)
35+
36+
return parser.parse_args()
37+
38+
39+
def main():
40+
"""Main entry point."""
41+
args = parse_args()
42+
43+
formatter = logging.Formatter(fmt="%(asctime)s %(levelname)s %(message)s")
44+
45+
if args.log:
46+
handler = logging.FileHandler(args.log)
47+
handler.setFormatter(formatter)
48+
logger.addHandler(handler)
49+
50+
if args.verbose:
51+
handler = logging.StreamHandler()
52+
handler.setFormatter(formatter)
53+
logger.addHandler(handler)
54+
logger.setLevel(logging.DEBUG)
55+
logger.info(f'Loading fit file {args.fitfile}...')
56+
57+
fit_file = FitFile.from_file(args.fitfile)
58+
59+
if args.type:
60+
format_type = args.type
61+
elif args.output:
62+
_, out_ext = os.path.splitext(os.path.basename(args.output))
63+
format_type = out_ext.lstrip('.')
64+
else:
65+
format_type = 'csv'
66+
67+
basename_noext, _ = os.path.splitext(os.path.basename(args.fitfile))
68+
output_filename = args.output or f'{basename_noext}.{format_type}'
69+
70+
logger.info(f'Exporting fit file to {output_filename} as format {format_type}...')
71+
72+
if format_type == 'csv':
73+
fit_file.to_csv(output_filename)
74+
elif format_type == 'fit':
75+
fit_file.to_file(output_filename)
76+
77+
78+
if __name__ == "__main__":
79+
main()

fit_tool/tests/test_cli.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Tests for the command line interface."""
2+
3+
import os
4+
import tempfile
5+
import unittest
6+
from unittest.mock import patch
7+
8+
from fit_tool.cli import main, parse_args
9+
10+
11+
class TestParseArgs(unittest.TestCase):
12+
13+
def test_parse_args_minimal(self):
14+
with patch('sys.argv', ['fit-tool', 'test.fit']):
15+
args = parse_args()
16+
self.assertEqual(args.fitfile, 'test.fit')
17+
self.assertFalse(args.verbose)
18+
self.assertIsNone(args.output)
19+
self.assertIsNone(args.log)
20+
self.assertIsNone(args.type)
21+
22+
def test_parse_args_with_options(self):
23+
with patch('sys.argv', ['fit-tool', 'test.fit', '-v', '-o', 'out.csv', '-l', 'log.txt', '-t', 'csv']):
24+
args = parse_args()
25+
self.assertEqual(args.fitfile, 'test.fit')
26+
self.assertTrue(args.verbose)
27+
self.assertEqual(args.output, 'out.csv')
28+
self.assertEqual(args.log, 'log.txt')
29+
self.assertEqual(args.type, 'csv')
30+
31+
32+
class TestMain(unittest.TestCase):
33+
34+
def setUp(self):
35+
self.test_dir = tempfile.mkdtemp()
36+
self.test_fit_file = os.path.join(
37+
os.path.dirname(__file__),
38+
'data',
39+
'sdk',
40+
'Activity.fit'
41+
)
42+
43+
def tearDown(self):
44+
for f in os.listdir(self.test_dir):
45+
os.remove(os.path.join(self.test_dir, f))
46+
os.rmdir(self.test_dir)
47+
48+
def test_main_convert_to_csv(self):
49+
output_file = os.path.join(self.test_dir, 'output.csv')
50+
with patch('sys.argv', ['fit-tool', self.test_fit_file, '-o', output_file]):
51+
main()
52+
self.assertTrue(os.path.exists(output_file))
53+
54+
def test_main_convert_to_fit(self):
55+
output_file = os.path.join(self.test_dir, 'output.fit')
56+
with patch('sys.argv', ['fit-tool', self.test_fit_file, '-o', output_file, '-t', 'fit']):
57+
main()
58+
self.assertTrue(os.path.exists(output_file))
59+
60+
def test_main_with_verbose(self):
61+
output_file = os.path.join(self.test_dir, 'output.csv')
62+
with patch('sys.argv', ['fit-tool', self.test_fit_file, '-v', '-o', output_file]):
63+
main()
64+
self.assertTrue(os.path.exists(output_file))
65+
66+
def test_main_with_log_file(self):
67+
output_file = os.path.join(self.test_dir, 'output.csv')
68+
log_file = os.path.join(self.test_dir, 'test.log')
69+
with patch('sys.argv', ['fit-tool', self.test_fit_file, '-o', output_file, '-l', log_file]):
70+
main()
71+
self.assertTrue(os.path.exists(output_file))
72+
self.assertTrue(os.path.exists(log_file))
73+
74+
def test_main_default_output_filename(self):
75+
original_cwd = os.getcwd()
76+
try:
77+
os.chdir(self.test_dir)
78+
with patch('sys.argv', ['fit-tool', self.test_fit_file]):
79+
main()
80+
self.assertTrue(os.path.exists('Activity.csv'))
81+
finally:
82+
os.chdir(original_cwd)
83+
84+
def test_main_infer_format_from_output(self):
85+
output_file = os.path.join(self.test_dir, 'output.fit')
86+
with patch('sys.argv', ['fit-tool', self.test_fit_file, '-o', output_file]):
87+
main()
88+
self.assertTrue(os.path.exists(output_file))
89+
90+
91+
if __name__ == '__main__':
92+
unittest.main()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Homepage = "https://github.com/shaonianche/python_fit_tool"
4040
Repository = "https://github.com/shaonianche/python_fit_tool.git"
4141

4242
[project.scripts]
43-
fittool = "fit_tool.cli:main"
43+
fit-tool = "fit_tool.cli:main"
4444

4545
[tool.setuptools.packages.find]
4646
include = ["fit_tool*"]

setup.py

Lines changed: 0 additions & 17 deletions
This file was deleted.

0 commit comments

Comments
 (0)