beancount-cli 0.2.16__tar.gz → 0.3.0__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.3.0}/CHANGELOG.md +19 -0
  2. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/PKG-INFO +4 -4
  3. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/pyproject.toml +6 -3
  4. beancount_cli-0.3.0/src/beancount_cli/__init__.py +1 -0
  5. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/cli.py +1 -1
  6. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/commands/price.py +74 -30
  7. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/services.py +3 -1
  8. beancount_cli-0.3.0/tests/fake_price_source.py +33 -0
  9. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/smoke_test.py +1 -1
  10. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/test_coverage_gap.py +5 -3
  11. beancount_cli-0.3.0/tests/test_price_fetch_errors.py +94 -0
  12. beancount_cli-0.3.0/tests/test_price_fill_gaps.py +61 -0
  13. beancount_cli-0.2.16/src/beancount_cli/__init__.py +0 -1
  14. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/.gitignore +0 -0
  15. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/LICENSE +0 -0
  16. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/README.md +0 -0
  17. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/adapters.py +0 -0
  18. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/commands/__init__.py +0 -0
  19. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/commands/account.py +0 -0
  20. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/commands/commodity.py +0 -0
  21. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/commands/common.py +0 -0
  22. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/commands/report.py +0 -0
  23. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/commands/root.py +0 -0
  24. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/commands/transaction.py +0 -0
  25. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/config.py +0 -0
  26. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/formatting.py +0 -0
  27. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/models.py +0 -0
  28. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/py.typed +0 -0
  29. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/conftest.py +0 -0
  30. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/test_advanced.py +0 -0
  31. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/test_bql.py +0 -0
  32. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/test_cli.py +0 -0
  33. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/test_config.py +0 -0
  34. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/test_models.py +0 -0
  35. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/test_price_cash_currency.py +0 -0
  36. {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/test_services.py +0 -0
@@ -5,6 +5,25 @@ 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.3.0] - 2026-09-29
9
+
10
+ ### Changed
11
+ - Upgraded `agentyper` to `0.1.21`.
12
+ - Exit codes follow the CLI Agent Spec: validation errors (e.g. `check` finding ledger errors) now exit `2` instead of `3`, and a partial `price fetch` failure exits `3` instead of `2`.
13
+ - `--format json` keeps list results as arrays: a query matching one item returns `"data": [{...}]` instead of a bare object.
14
+
15
+ ## [0.2.17] - 2026-09-28
16
+
17
+ ### Changed
18
+ - CLI program name is now `bean` (shown in `--help` and `--version`).
19
+
20
+ ### Fixed
21
+ - `price fetch --fill-gaps` now backfills interior gaps instead of behaving like `--update`; requires `beanprice2>=2.1.2`.
22
+ - `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.
23
+ - `price fetch` no longer crashes in the "Skipped N jobs" summary when a source returns no data.
24
+ - `commodity import --target` no longer crashes when the target is given as a path string.
25
+ - `price check-anomalies --help` no longer crashes on Python 3.14 (unescaped `%` in help text).
26
+
8
27
  ## [0.2.16] - 2026-06-12
9
28
 
10
29
  ### 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.3.0
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
@@ -20,9 +20,9 @@ Classifier: Programming Language :: Python :: 3.12
20
20
  Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Office/Business :: Financial :: Accounting
22
22
  Requires-Python: >=3.10
23
- Requires-Dist: agentyper==0.1.17
23
+ Requires-Dist: agentyper==0.1.21
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.3.0"
4
4
  description = "A CLI tool to manage Beancount ledgers"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -13,9 +13,9 @@ 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
- "agentyper==0.1.17",
18
+ "agentyper==0.1.21",
19
19
  ]
20
20
  keywords = ["beancount", "cli", "accounting", "automation", "agent"]
21
21
  classifiers = [
@@ -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.3.0"
@@ -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(
@@ -1,5 +1,7 @@
1
1
  import json as jsonlib
2
2
 
3
+ import agentyper
4
+
3
5
  from beancount_cli.cli import main
4
6
 
5
7
 
@@ -63,7 +65,7 @@ def test_check_cmd_with_errors(temp_beancount_file):
63
65
  with open(temp_beancount_file, "a") as f:
64
66
  f.write("\n2022-01-01 INVALID_STATEMENT\n")
65
67
  code, out, err = _run_cli("check", str(temp_beancount_file))
66
- assert code == 3 # EXIT_VALIDATION, not system error
68
+ assert code == agentyper.EXIT_VALIDATION # not a system error
67
69
  assert "Traceback" not in err
68
70
 
69
71
 
@@ -86,11 +88,11 @@ def test_check_validation_errors_json(temp_beancount_file):
86
88
  with open(temp_beancount_file, "a") as f:
87
89
  f.write("\n2022-01-01 INVALID_STATEMENT\n")
88
90
  code, out, err = _run_cli("check", "--format", "json", str(temp_beancount_file))
89
- assert code == 3
91
+ assert code == agentyper.EXIT_VALIDATION
90
92
  payload = jsonlib.loads(err)
91
93
  assert payload["error"] is True
92
94
  assert payload["error_type"] == "BeancountValidationError"
93
- assert payload["exit_code"] == 3
95
+ assert payload["exit_code"] == agentyper.EXIT_VALIDATION
94
96
  assert isinstance(payload["errors"], list)
95
97
  assert len(payload["errors"]) > 0
96
98
 
@@ -0,0 +1,94 @@
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 agentyper
15
+ import fake_price_source
16
+ import pytest
17
+ from beanprice import price as bp_price
18
+
19
+ from beancount_cli.commands.price import _fetch_price_job
20
+
21
+ TESTS_DIR = Path(__file__).parent
22
+
23
+ LEDGER = """
24
+ option "operating_currency" "USD"
25
+
26
+ 2026-01-01 commodity GOOD
27
+ price: "USD:fake_price_source/OK"
28
+ 2026-01-01 commodity FAIL
29
+ price: "USD:fake_price_source/BROKEN"
30
+ 2026-01-01 commodity NONE
31
+ price: "USD:fake_price_source/NODATA"
32
+
33
+ 2026-01-01 open Assets:Broker
34
+ 2026-01-01 open Equity:Opening
35
+
36
+ 2026-01-02 * "Buy"
37
+ Assets:Broker 1 GOOD {1 USD}
38
+ Assets:Broker 1 FAIL {1 USD}
39
+ Assets:Broker 1 NONE {1 USD}
40
+ Equity:Opening -3 USD
41
+ """
42
+
43
+
44
+ def _run_fetch(tmp_path: Path, *args: str) -> subprocess.CompletedProcess:
45
+ ledger = tmp_path / "main.beancount"
46
+ ledger.write_text(textwrap.dedent(LEDGER))
47
+ env = {
48
+ **os.environ,
49
+ "PYTHONPATH": os.pathsep.join([str(TESTS_DIR), os.environ.get("PYTHONPATH", "")]),
50
+ "TMPDIR": str(tmp_path), # isolate the bean-price cache
51
+ }
52
+ env.pop("BEANCOUNT_FILE", None)
53
+ return subprocess.run(
54
+ [sys.executable, "-m", "beancount_cli.cli", "price", "fetch", str(ledger), *args],
55
+ capture_output=True,
56
+ text=True,
57
+ env=env,
58
+ check=False,
59
+ )
60
+
61
+
62
+ def test_source_error_does_not_abort_other_jobs(tmp_path):
63
+ result = _run_fetch(tmp_path)
64
+
65
+ assert result.returncode == agentyper.ExitCode.PARTIAL_FAILURE, result.stderr
66
+ assert "price GOOD" in result.stdout
67
+ assert "Error fetching prices" not in result.stderr
68
+ assert "Skipped 1 jobs with source errors: FAIL" in result.stderr
69
+ assert "connection reset by peer" in result.stderr
70
+ assert "Skipped 1 jobs with no data from source: NONE" in result.stderr
71
+
72
+
73
+ def test_update_writes_prices_despite_source_error(tmp_path):
74
+ result = _run_fetch(tmp_path, "--update")
75
+
76
+ assert result.returncode == agentyper.ExitCode.PARTIAL_FAILURE, result.stderr
77
+ ledger_text = (tmp_path / "main.beancount").read_text()
78
+ assert "price GOOD" in ledger_text
79
+ assert "price FAIL" not in ledger_text
80
+
81
+
82
+ def _job(ticker: str) -> bp_price.DatedPrice:
83
+ return bp_price.DatedPrice(
84
+ "X", "USD", None, [bp_price.PriceSource(fake_price_source, ticker, False)]
85
+ )
86
+
87
+
88
+ def test_fetch_price_job_records_transport_error():
89
+ assert _fetch_price_job(_job("BROKEN")) == (None, "connection reset by peer")
90
+
91
+
92
+ def test_fetch_price_job_propagates_unexpected_errors():
93
+ with pytest.raises(RuntimeError, match="source bug"):
94
+ _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
File without changes