beancount-cli 0.2.16__tar.gz → 0.2.17__tar.gz

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.
Files changed (36) hide show
  1. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/CHANGELOG.md +12 -0
  2. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/PKG-INFO +3 -3
  3. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/pyproject.toml +5 -2
  4. beancount_cli-0.2.17/src/beancount_cli/__init__.py +1 -0
  5. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/src/beancount_cli/cli.py +1 -1
  6. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/src/beancount_cli/commands/price.py +74 -30
  7. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/src/beancount_cli/services.py +3 -1
  8. beancount_cli-0.2.17/tests/fake_price_source.py +33 -0
  9. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/tests/smoke_test.py +1 -1
  10. beancount_cli-0.2.17/tests/test_price_fetch_errors.py +93 -0
  11. beancount_cli-0.2.17/tests/test_price_fill_gaps.py +61 -0
  12. beancount_cli-0.2.16/src/beancount_cli/__init__.py +0 -1
  13. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/.gitignore +0 -0
  14. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/LICENSE +0 -0
  15. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/README.md +0 -0
  16. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/src/beancount_cli/adapters.py +0 -0
  17. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/src/beancount_cli/commands/__init__.py +0 -0
  18. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/src/beancount_cli/commands/account.py +0 -0
  19. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/src/beancount_cli/commands/commodity.py +0 -0
  20. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/src/beancount_cli/commands/common.py +0 -0
  21. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/src/beancount_cli/commands/report.py +0 -0
  22. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/src/beancount_cli/commands/root.py +0 -0
  23. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/src/beancount_cli/commands/transaction.py +0 -0
  24. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/src/beancount_cli/config.py +0 -0
  25. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/src/beancount_cli/formatting.py +0 -0
  26. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/src/beancount_cli/models.py +0 -0
  27. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/src/beancount_cli/py.typed +0 -0
  28. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/tests/conftest.py +0 -0
  29. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/tests/test_advanced.py +0 -0
  30. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/tests/test_bql.py +0 -0
  31. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/tests/test_cli.py +0 -0
  32. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/tests/test_config.py +0 -0
  33. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/tests/test_coverage_gap.py +0 -0
  34. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/tests/test_models.py +0 -0
  35. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/tests/test_price_cash_currency.py +0 -0
  36. {beancount_cli-0.2.16 → beancount_cli-0.2.17}/tests/test_services.py +0 -0
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.17] - 2026-09-28
9
+
10
+ ### Changed
11
+ - CLI program name is now `bean` (shown in `--help` and `--version`).
12
+
13
+ ### Fixed
14
+ - `price fetch --fill-gaps` now backfills interior gaps instead of behaving like `--update`; requires `beanprice2>=2.1.2`.
15
+ - `price fetch` keeps running when one price source raises: failed jobs are reported, fetched prices are still written, and the run exits with a partial-failure code.
16
+ - `price fetch` no longer crashes in the "Skipped N jobs" summary when a source returns no data.
17
+ - `commodity import --target` no longer crashes when the target is given as a path string.
18
+ - `price check-anomalies --help` no longer crashes on Python 3.14 (unescaped `%` in help text).
19
+
8
20
  ## [0.2.16] - 2026-06-12
9
21
 
10
22
  ### Changed
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: beancount-cli
3
- Version: 0.2.16
3
+ Version: 0.2.17
4
4
  Summary: A CLI tool to manage Beancount ledgers
5
5
  Project-URL: Homepage, https://github.com/romamo/beancount-cli
6
6
  Project-URL: Repository, https://github.com/romamo/beancount-cli
@@ -22,7 +22,7 @@ Classifier: Topic :: Office/Business :: Financial :: Accounting
22
22
  Requires-Python: >=3.10
23
23
  Requires-Dist: agentyper==0.1.17
24
24
  Requires-Dist: beancount==3.2.3
25
- Requires-Dist: beanprice2>=2.1.0
25
+ Requires-Dist: beanprice2>=2.1.2
26
26
  Requires-Dist: beanquery>=0.1.0
27
27
  Requires-Dist: pydantic-settings==2.14.1
28
28
  Requires-Dist: pydantic==2.13.4
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "beancount-cli"
3
- version = "0.2.16"
3
+ version = "0.2.17"
4
4
  description = "A CLI tool to manage Beancount ledgers"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -13,7 +13,7 @@ dependencies = [
13
13
  "beanquery>=0.1.0",
14
14
  "pydantic==2.13.4",
15
15
  "python-dateutil>=2.8.2",
16
- "beanprice2>=2.1.0",
16
+ "beanprice2>=2.1.2",
17
17
  "pydantic-settings==2.14.1",
18
18
  "agentyper==0.1.17",
19
19
  ]
@@ -86,3 +86,6 @@ extend-immutable-calls = ["agentyper.Argument", "agentyper.Option", "typer.Argum
86
86
  [tool.ruff.lint.per-file-ignores]
87
87
  "tests/*" = ["E501"]
88
88
 
89
+
90
+ [tool.pytest.ini_options]
91
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ __version__ = "0.2.17"
@@ -11,7 +11,7 @@ from beancount_cli.commands.root import check, format_cmd, tree
11
11
  from beancount_cli.commands.transaction import app as tx_app
12
12
 
13
13
  app = typer.Agentyper(
14
- name="beancount-cli",
14
+ name="bean",
15
15
  version=__version__,
16
16
  help="Beancount CLI tool for managing ledgers.",
17
17
  )
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  import sys
3
+ from collections.abc import Iterable, Iterator
3
4
  from concurrent.futures import ThreadPoolExecutor, as_completed
4
5
  from datetime import date, datetime
5
6
  from decimal import Decimal
@@ -65,7 +66,7 @@ def price_check_anomalies(
65
66
  None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
66
67
  ),
67
68
  threshold: float = typer.Option(
68
- 1.0, "--threshold", "-t", help="Minimum change percentage as decimal (1.0 = 100%)"
69
+ 1.0, "--threshold", "-t", help="Minimum change percentage as decimal (1.0 = 100%%)"
69
70
  ),
70
71
  max_days: int = typer.Option(
71
72
  7, "--max-days", "-d", help="Maximum days between consecutive prices"
@@ -139,6 +140,7 @@ def price_fetch(
139
140
  label = ", ".join(str(f) for f in files_to_load)
140
141
  error_console.print(f"Fetching prices for {label}...", highlight=False)
141
142
 
143
+ errored_jobs: list[tuple[bp_price.DatedPrice, str]] = []
142
144
  try:
143
145
  cache_path = Path(gettempdir()) / "bean-price.cache"
144
146
  bp_price.setup_cache(str(cache_path), clear_cache=False)
@@ -193,43 +195,39 @@ def price_fetch(
193
195
  failed_jobs = []
194
196
  redundant_count = 0
195
197
  try:
196
- with ThreadPoolExecutor(max_workers=min(8, len(jobs))) as executor:
197
- future_to_job = {executor.submit(bp_price.fetch_price, job): job for job in jobs}
198
-
199
- for future in as_completed(future_to_job):
200
- job = future_to_job[future]
201
- price_entry = future.result()
202
- if price_entry:
203
- price_entry = price_entry._replace(
204
- amount=price_entry.amount._replace(
205
- number=price_entry.amount.number.quantize(Decimal("1.000000"))
206
- )
198
+ for job, price_entry, error in _fetch_price_jobs(jobs):
199
+ if error is not None:
200
+ errored_jobs.append((job, error))
201
+ elif price_entry:
202
+ assert price_entry.amount.number is not None # beanprice always sets it
203
+ price_entry = price_entry._replace(
204
+ amount=price_entry.amount._replace(
205
+ number=price_entry.amount.number.quantize(Decimal("1.000000"))
207
206
  )
207
+ )
208
208
 
209
- if (price_entry.date, price_entry.currency) not in existing_prices:
210
- new_price_entries.append(price_entry)
211
- existing_prices.add((price_entry.date, price_entry.currency))
212
- print(printer.format_entry(price_entry), end="")
213
- sys.stdout.flush()
214
- else:
215
- redundant_count += 1
216
- logging.debug(
217
- "Redundant: %s", printer.format_entry(price_entry).strip()
218
- )
209
+ if (price_entry.date, price_entry.currency) not in existing_prices:
210
+ new_price_entries.append(price_entry)
211
+ existing_prices.add((price_entry.date, price_entry.currency))
212
+ print(printer.format_entry(price_entry), end="")
213
+ sys.stdout.flush()
219
214
  else:
220
- failed_jobs.append(job)
215
+ redundant_count += 1
216
+ logging.debug("Redundant: %s", printer.format_entry(price_entry).strip())
217
+ else:
218
+ failed_jobs.append(job)
221
219
  except KeyboardInterrupt:
222
220
  error_console.print("\n[yellow]Interrupt received. Stopping fetch...[/yellow]")
223
221
 
222
+ filtered_prices = []
224
223
  if update and not dry_run and new_price_entries:
225
224
  filtered_prices, _ = bp_price.filter_redundant_prices(new_price_entries, entries)
226
-
227
225
  if not filtered_prices:
228
226
  error_console.print(
229
227
  "[yellow]All fetched prices are already in the ledger.[/yellow]"
230
228
  )
231
- return
232
229
 
230
+ if filtered_prices:
233
231
  prices_output = "".join(printer.format_entry(p) for p in filtered_prices)
234
232
 
235
233
  # Resolve target price file.
@@ -272,20 +270,64 @@ def price_fetch(
272
270
  )
273
271
 
274
272
  if failed_jobs:
275
- failed_info = ", ".join(f"{j.currency} on {j.date}" for j in failed_jobs[:5])
276
- if len(failed_jobs) > 5:
277
- failed_info += f" (+{len(failed_jobs) - 5} more)"
273
+ failed_info = _summarize(_describe_job(j) for j in failed_jobs)
278
274
  error_console.print(
279
275
  f"[yellow]Skipped {len(failed_jobs)} jobs with no data from source: {failed_info}[/yellow]"
280
276
  )
281
277
 
282
- if not new_price_entries and not failed_jobs and not redundant_count:
278
+ if errored_jobs:
279
+ errored_info = _summarize(f"{_describe_job(j)} ({e})" for j, e in errored_jobs)
280
+ error_console.print(
281
+ f"[yellow]Skipped {len(errored_jobs)} jobs with source errors: {errored_info}[/yellow]",
282
+ highlight=False,
283
+ )
284
+
285
+ if not new_price_entries and not failed_jobs and not errored_jobs and not redundant_count:
283
286
  error_console.print("[yellow]No new prices found.[/yellow]")
284
287
 
285
288
  except Exception as e:
286
289
  error_console.print(f"[red]Error fetching prices: {e}[/red]")
287
290
  sys.exit(typer.EXIT_SYSTEM)
288
291
 
292
+ if errored_jobs:
293
+ sys.exit(typer.ExitCode.PARTIAL_FAILURE)
294
+
295
+
296
+ def _fetch_price_job(job: bp_price.DatedPrice) -> tuple[data.Price | None, str | None]:
297
+ """Fetch one job, returning (price, error) instead of raising on a source failure.
298
+
299
+ Price sources signal fetch errors with ValueError (the beanprice source
300
+ convention) and transport failures with OSError (urllib, requests and
301
+ curl_cffi errors all derive from it). Anything else is a bug and propagates.
302
+ """
303
+ try:
304
+ return bp_price.fetch_price(job), None
305
+ except (ValueError, OSError) as exc:
306
+ logging.error("Error fetching %s: %s", bp_price.format_dated_price_str(job), exc)
307
+ return None, str(exc)
308
+
309
+
310
+ def _fetch_price_jobs(
311
+ jobs: list[bp_price.DatedPrice],
312
+ ) -> Iterator[tuple[bp_price.DatedPrice, data.Price | None, str | None]]:
313
+ """Fetch jobs concurrently, yielding (job, price, error) as each completes."""
314
+ with ThreadPoolExecutor(max_workers=min(8, len(jobs))) as executor:
315
+ future_to_job = {executor.submit(_fetch_price_job, job): job for job in jobs}
316
+ for future in as_completed(future_to_job):
317
+ yield future_to_job[future], *future.result()
318
+
319
+
320
+ def _describe_job(job: bp_price.DatedPrice) -> str:
321
+ return f"{job.base} on {job.date or 'latest'}"
322
+
323
+
324
+ def _summarize(items: Iterable[str], limit: int = 5) -> str:
325
+ items = list(items)
326
+ info = ", ".join(items[:limit])
327
+ if len(items) > limit:
328
+ info += f" (+{len(items) - limit} more)"
329
+ return info
330
+
289
331
 
290
332
  def _resolve_price_jobs(
291
333
  entries: list[data.Directive],
@@ -295,7 +337,9 @@ def _resolve_price_jobs(
295
337
  fill_gaps: bool,
296
338
  ) -> list:
297
339
  if update or fill_gaps:
298
- return bp_price.get_price_jobs_up_to_date(entries, date_last=date_last, inactive=inactive)
340
+ return bp_price.get_price_jobs_up_to_date(
341
+ entries, date_last=date_last, inactive=inactive, fill_gaps=fill_gaps
342
+ )
299
343
  return bp_price.get_price_jobs_at_date(entries, date=None, inactive=inactive)
300
344
 
301
345
 
@@ -816,7 +816,9 @@ class CommodityService:
816
816
  dry_run: bool = False,
817
817
  ) -> tuple[list[CommodityImportResult], Path | None]:
818
818
  self.ledger_service.load()
819
- commodities_file = output_file or self.ledger_service.get_commodities_file()
819
+ commodities_file = (
820
+ Path(output_file) if output_file else self.ledger_service.get_commodities_file()
821
+ )
820
822
  if commodities_file is not None and not commodities_file.exists():
821
823
  raise FileNotFoundError(f"commodities_file not found: {commodities_file}")
822
824
 
@@ -0,0 +1,33 @@
1
+ """A beanprice source whose behaviour is chosen by the ticker, for price fetch tests.
2
+
3
+ Tickers: OK returns a price, NODATA returns None, BROKEN raises OSError (a
4
+ transport failure), BUG raises RuntimeError (a programming error).
5
+ """
6
+
7
+ import datetime
8
+ from decimal import Decimal
9
+
10
+ from beanprice import source
11
+
12
+
13
+ class Source(source.Source):
14
+ def get_latest_price(self, ticker: str) -> source.SourcePrice | None:
15
+ return self._price(ticker)
16
+
17
+ def get_historical_price(
18
+ self, ticker: str, time: datetime.datetime
19
+ ) -> source.SourcePrice | None:
20
+ return self._price(ticker)
21
+
22
+ def _price(self, ticker: str) -> source.SourcePrice | None:
23
+ match ticker:
24
+ case "OK":
25
+ now = datetime.datetime.now(datetime.timezone.utc)
26
+ return source.SourcePrice(Decimal("1.5"), now, "USD")
27
+ case "NODATA":
28
+ return None
29
+ case "BROKEN":
30
+ raise OSError("connection reset by peer")
31
+ case "BUG":
32
+ raise RuntimeError("source bug")
33
+ raise AssertionError(f"unknown test ticker {ticker}")
@@ -29,7 +29,7 @@ def test_smoke():
29
29
  check=False,
30
30
  )
31
31
  assert result.returncode == 0
32
- assert "beancount-cli" in result.stdout
32
+ assert result.stdout.startswith("bean ")
33
33
 
34
34
  # 3. Check transaction schema command
35
35
  result = subprocess.run(
@@ -0,0 +1,93 @@
1
+ """Regression tests for `price fetch` source errors (issue #10).
2
+
3
+ A source raising for one job must not abort the run: the error is recorded,
4
+ the remaining jobs are fetched and written, and the run exits with
5
+ PARTIAL_FAILURE.
6
+ """
7
+
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ import textwrap
12
+ from pathlib import Path
13
+
14
+ import fake_price_source
15
+ import pytest
16
+ from beanprice import price as bp_price
17
+
18
+ from beancount_cli.commands.price import _fetch_price_job
19
+
20
+ TESTS_DIR = Path(__file__).parent
21
+
22
+ LEDGER = """
23
+ option "operating_currency" "USD"
24
+
25
+ 2026-01-01 commodity GOOD
26
+ price: "USD:fake_price_source/OK"
27
+ 2026-01-01 commodity FAIL
28
+ price: "USD:fake_price_source/BROKEN"
29
+ 2026-01-01 commodity NONE
30
+ price: "USD:fake_price_source/NODATA"
31
+
32
+ 2026-01-01 open Assets:Broker
33
+ 2026-01-01 open Equity:Opening
34
+
35
+ 2026-01-02 * "Buy"
36
+ Assets:Broker 1 GOOD {1 USD}
37
+ Assets:Broker 1 FAIL {1 USD}
38
+ Assets:Broker 1 NONE {1 USD}
39
+ Equity:Opening -3 USD
40
+ """
41
+
42
+
43
+ def _run_fetch(tmp_path: Path, *args: str) -> subprocess.CompletedProcess:
44
+ ledger = tmp_path / "main.beancount"
45
+ ledger.write_text(textwrap.dedent(LEDGER))
46
+ env = {
47
+ **os.environ,
48
+ "PYTHONPATH": os.pathsep.join([str(TESTS_DIR), os.environ.get("PYTHONPATH", "")]),
49
+ "TMPDIR": str(tmp_path), # isolate the bean-price cache
50
+ }
51
+ env.pop("BEANCOUNT_FILE", None)
52
+ return subprocess.run(
53
+ [sys.executable, "-m", "beancount_cli.cli", "price", "fetch", str(ledger), *args],
54
+ capture_output=True,
55
+ text=True,
56
+ env=env,
57
+ check=False,
58
+ )
59
+
60
+
61
+ def test_source_error_does_not_abort_other_jobs(tmp_path):
62
+ result = _run_fetch(tmp_path)
63
+
64
+ assert result.returncode == 2, result.stderr # PARTIAL_FAILURE
65
+ assert "price GOOD" in result.stdout
66
+ assert "Error fetching prices" not in result.stderr
67
+ assert "Skipped 1 jobs with source errors: FAIL" in result.stderr
68
+ assert "connection reset by peer" in result.stderr
69
+ assert "Skipped 1 jobs with no data from source: NONE" in result.stderr
70
+
71
+
72
+ def test_update_writes_prices_despite_source_error(tmp_path):
73
+ result = _run_fetch(tmp_path, "--update")
74
+
75
+ assert result.returncode == 2, result.stderr
76
+ ledger_text = (tmp_path / "main.beancount").read_text()
77
+ assert "price GOOD" in ledger_text
78
+ assert "price FAIL" not in ledger_text
79
+
80
+
81
+ def _job(ticker: str) -> bp_price.DatedPrice:
82
+ return bp_price.DatedPrice(
83
+ "X", "USD", None, [bp_price.PriceSource(fake_price_source, ticker, False)]
84
+ )
85
+
86
+
87
+ def test_fetch_price_job_records_transport_error():
88
+ assert _fetch_price_job(_job("BROKEN")) == (None, "connection reset by peer")
89
+
90
+
91
+ def test_fetch_price_job_propagates_unexpected_errors():
92
+ with pytest.raises(RuntimeError, match="source bug"):
93
+ _fetch_price_job(_job("BUG"))
@@ -0,0 +1,61 @@
1
+ """Regression tests for `price fetch --fill-gaps` (issue #9).
2
+
3
+ --fill-gaps must be forwarded to beanprice so interior gaps in a commodity's
4
+ price history are backfilled even when its latest price is already current.
5
+ """
6
+
7
+ import textwrap
8
+ from datetime import date
9
+
10
+ import pytest
11
+ from beancount import loader
12
+
13
+ from beancount_cli.commands.price import _resolve_price_jobs
14
+
15
+
16
+ @pytest.fixture
17
+ def gapped_entries():
18
+ entries, errors, _ = loader.load_string(
19
+ textwrap.dedent("""
20
+ option "operating_currency" "USD"
21
+
22
+ 2026-07-01 commodity ACME
23
+ price: "USD:beanprice.sources.yahoo/ACME"
24
+
25
+ 2026-07-01 open Assets:Broker:ACME ACME
26
+ 2026-07-01 open Assets:Cash USD
27
+
28
+ 2026-07-01 * "Buy"
29
+ Assets:Broker:ACME 10 ACME {100 USD}
30
+ Assets:Cash -1000 USD
31
+
32
+ 2026-07-15 price ACME 100 USD
33
+ 2026-09-28 price ACME 110 USD
34
+ """)
35
+ )
36
+ assert not errors, errors
37
+ return entries
38
+
39
+
40
+ def _acme_dates(jobs) -> set[date]:
41
+ return {j.date for j in jobs if (j.base, j.quote) == ("ACME", "USD")}
42
+
43
+
44
+ def test_update_without_fill_gaps_skips_interior_gap(gapped_entries):
45
+ jobs = _resolve_price_jobs(
46
+ gapped_entries, date(2026, 9, 29), False, update=True, fill_gaps=False
47
+ )
48
+ assert _acme_dates(jobs) == set()
49
+
50
+
51
+ def test_fill_gaps_backfills_interior_gap(gapped_entries):
52
+ jobs = _resolve_price_jobs(
53
+ gapped_entries, date(2026, 9, 29), False, update=True, fill_gaps=True
54
+ )
55
+ dates = _acme_dates(jobs)
56
+ assert date(2026, 7, 16) in dates
57
+ assert date(2026, 9, 25) in dates
58
+ # Existing prices and weekends are not refetched
59
+ assert date(2026, 7, 15) not in dates
60
+ assert date(2026, 9, 28) not in dates
61
+ assert date(2026, 7, 18) not in dates
@@ -1 +0,0 @@
1
- __version__ = "0.2.16"
File without changes
File without changes