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.
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/CHANGELOG.md +19 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/PKG-INFO +4 -4
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/pyproject.toml +6 -3
- beancount_cli-0.3.0/src/beancount_cli/__init__.py +1 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/cli.py +1 -1
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/commands/price.py +74 -30
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/services.py +3 -1
- beancount_cli-0.3.0/tests/fake_price_source.py +33 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/smoke_test.py +1 -1
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/test_coverage_gap.py +5 -3
- beancount_cli-0.3.0/tests/test_price_fetch_errors.py +94 -0
- beancount_cli-0.3.0/tests/test_price_fill_gaps.py +61 -0
- beancount_cli-0.2.16/src/beancount_cli/__init__.py +0 -1
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/.gitignore +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/LICENSE +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/README.md +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/adapters.py +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/commands/__init__.py +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/commands/account.py +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/commands/commodity.py +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/commands/common.py +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/commands/report.py +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/commands/root.py +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/commands/transaction.py +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/config.py +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/formatting.py +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/models.py +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/src/beancount_cli/py.typed +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/conftest.py +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/test_advanced.py +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/test_bql.py +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/test_cli.py +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/test_config.py +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/test_models.py +0 -0
- {beancount_cli-0.2.16 → beancount_cli-0.3.0}/tests/test_price_cash_currency.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: beancount-cli
|
|
3
|
-
Version: 0.
|
|
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.
|
|
23
|
+
Requires-Dist: agentyper==0.1.21
|
|
24
24
|
Requires-Dist: beancount==3.2.3
|
|
25
|
-
Requires-Dist: beanprice2>=2.1.
|
|
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.
|
|
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.
|
|
16
|
+
"beanprice2>=2.1.2",
|
|
17
17
|
"pydantic-settings==2.14.1",
|
|
18
|
-
"agentyper==0.1.
|
|
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="
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
price_entry =
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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 =
|
|
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}")
|
|
@@ -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 ==
|
|
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 ==
|
|
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"] ==
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|