diff --git a/config/settings.yaml.example b/config/settings.yaml.example index 8cd18eeb..a247ae2b 100644 --- a/config/settings.yaml.example +++ b/config/settings.yaml.example @@ -15,10 +15,15 @@ title: "Spending Analysis" # Data sources - list of transaction files to process # Paths are relative to the parent of the config directory +# Supports glob patterns (*, ?, []) to match multiple files data_sources: - name: AMEX - file: data/amex-2025.csv + file: data/amex-2025.csv # Single file type: amex + # Example with glob pattern to match multiple files: + # - name: AMEX + # file: data/amex*.csv # Matches amex-2024.csv, amex-2025.csv, etc. + # type: amex - name: BOA file: data/boa-checking.txt type: boa diff --git a/docs/formats.html b/docs/formats.html index 51807e9d..bee3c5cd 100644 --- a/docs/formats.html +++ b/docs/formats.html @@ -255,7 +255,7 @@

#Data Sour file Yes - Path to CSV file (relative or absolute) + Path to CSV file (relative or absolute), supports glob patterns (*, ?, []) to match multiple files (files are processed in sorted order) format diff --git a/tests/test_cli.py b/tests/test_cli.py index dd6ee625..6df796ee 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,6 +7,166 @@ from pathlib import Path +class TestGlobPatternSupport: + """Tests for wildcard/glob pattern support in data source file paths.""" + + def test_glob_pattern_matches_multiple_files(self): + """Glob pattern should match multiple CSV files.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_dir = os.path.join(tmpdir, 'config') + data_dir = os.path.join(tmpdir, 'data') + os.makedirs(config_dir) + os.makedirs(data_dir) + + # Create settings with glob pattern + with open(os.path.join(config_dir, 'settings.yaml'), 'w') as f: + f.write("""year: 2025 +data_sources: + - name: TestBank + file: data/test*.csv + format: "{date:%Y-%m-%d},{description},{amount}" +""") + + # Create multiple matching files + with open(os.path.join(data_dir, 'test-jan.csv'), 'w') as f: + f.write("date,description,amount\n") + f.write("2025-01-15,NETFLIX,-15.99\n") + + with open(os.path.join(data_dir, 'test-feb.csv'), 'w') as f: + f.write("date,description,amount\n") + f.write("2025-02-15,SPOTIFY,-9.99\n") + + result = subprocess.run( + ['uv', 'run', 'tally', 'run', '--format', 'summary', config_dir], + capture_output=True, + text=True + ) + assert result.returncode == 0 + # Should report transactions from 2 files + assert '2 files' in result.stdout + assert '2 transactions' in result.stdout + + def test_glob_pattern_single_file_fallback(self): + """Single file path (no wildcards) should still work.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_dir = os.path.join(tmpdir, 'config') + data_dir = os.path.join(tmpdir, 'data') + os.makedirs(config_dir) + os.makedirs(data_dir) + + with open(os.path.join(config_dir, 'settings.yaml'), 'w') as f: + f.write("""year: 2025 +data_sources: + - name: TestBank + file: data/transactions.csv + format: "{date:%Y-%m-%d},{description},{amount}" +""") + + with open(os.path.join(data_dir, 'transactions.csv'), 'w') as f: + f.write("date,description,amount\n") + f.write("2025-01-15,TEST,-10.00\n") + + result = subprocess.run( + ['uv', 'run', 'tally', 'run', '--format', 'summary', config_dir], + capture_output=True, + text=True + ) + assert result.returncode == 0 + assert 'TestBank: 1 transactions' in result.stdout + assert '1 transactions' in result.stdout + + def test_glob_pattern_no_matches_shows_error(self): + """Glob pattern with no matches should show helpful message.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_dir = os.path.join(tmpdir, 'config') + data_dir = os.path.join(tmpdir, 'data') + os.makedirs(config_dir) + os.makedirs(data_dir) + + with open(os.path.join(config_dir, 'settings.yaml'), 'w') as f: + f.write("""year: 2025 +data_sources: + - name: TestBank + file: data/nonexistent*.csv + format: "{date:%Y-%m-%d},{description},{amount}" +""") + + result = subprocess.run( + ['uv', 'run', 'tally', 'run', '--format', 'summary', config_dir], + capture_output=True, + text=True + ) + # Should report that no files matched the glob pattern + assert result.returncode == 1 + assert 'No files matched' in result.stdout + assert 'data/nonexistent*.csv' in result.stdout + + def test_glob_diag_shows_matched_files(self): + """Diag command should show matched files for glob patterns.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_dir = os.path.join(tmpdir, 'config') + data_dir = os.path.join(tmpdir, 'data') + os.makedirs(config_dir) + os.makedirs(data_dir) + + with open(os.path.join(config_dir, 'settings.yaml'), 'w') as f: + f.write("""year: 2025 +data_sources: + - name: TestBank + file: data/test*.csv + format: "{date:%Y-%m-%d},{description},{amount}" +""") + + # Create multiple matching files + with open(os.path.join(data_dir, 'test-a.csv'), 'w') as f: + f.write("date,description,amount\n") + + with open(os.path.join(data_dir, 'test-b.csv'), 'w') as f: + f.write("date,description,amount\n") + + result = subprocess.run( + ['uv', 'run', 'tally', 'diag', config_dir], + capture_output=True, + text=True + ) + assert result.returncode == 0 + # Should show the configured glob and resolved file count + assert 'data/test*.csv' in result.stdout + assert '(2 files)' in result.stdout + + def test_glob_files_processed_in_sorted_order(self): + """Files should be processed in sorted order for consistency.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_dir = os.path.join(tmpdir, 'config') + data_dir = os.path.join(tmpdir, 'data') + os.makedirs(config_dir) + os.makedirs(data_dir) + + with open(os.path.join(config_dir, 'settings.yaml'), 'w') as f: + f.write("""year: 2025 +data_sources: + - name: TestBank + file: data/*.csv + format: "{date:%Y-%m-%d},{description},{amount}" +""") + + # Create files in non-alphabetical order + with open(os.path.join(data_dir, 'z-last.csv'), 'w') as f: + f.write("date,description,amount\n") + f.write("2025-01-01,Z_FIRST,-1.00\n") + + with open(os.path.join(data_dir, 'a-first.csv'), 'w') as f: + f.write("date,description,amount\n") + f.write("2025-01-01,A_FIRST,-1.00\n") + + # Resolver should return files in sorted order regardless of creation order + from tally.path_utils import resolve_data_source_paths + + files, _ = resolve_data_source_paths(config_dir, 'data/*.csv') + basenames = [os.path.basename(f) for f in files] + assert basenames == ['a-first.csv', 'z-last.csv'] + + class TestCLIErrorHandling: """Tests for helpful error messages when CLI is misused."""