beancount-cli 0.2.12__tar.gz → 0.2.13__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.12 → beancount_cli-0.2.13}/CHANGELOG.md +13 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/PKG-INFO +3 -3
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/pyproject.toml +3 -6
- beancount_cli-0.2.13/src/beancount_cli/__init__.py +1 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/src/beancount_cli/commands/price.py +44 -3
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/src/beancount_cli/commands/report.py +2 -2
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/src/beancount_cli/commands/transaction.py +8 -1
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/src/beancount_cli/models.py +15 -1
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/src/beancount_cli/services.py +41 -1
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/tests/test_cli.py +62 -2
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/tests/test_services.py +16 -0
- beancount_cli-0.2.12/src/beancount_cli/__init__.py +0 -1
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/.gitignore +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/LICENSE +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/README.md +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/src/beancount_cli/adapters.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/src/beancount_cli/cli.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/src/beancount_cli/commands/__init__.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/src/beancount_cli/commands/account.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/src/beancount_cli/commands/commodity.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/src/beancount_cli/commands/common.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/src/beancount_cli/commands/root.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/src/beancount_cli/config.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/src/beancount_cli/formatting.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/src/beancount_cli/py.typed +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/tests/conftest.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/tests/smoke_test.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/tests/test_advanced.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/tests/test_bql.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/tests/test_config.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/tests/test_coverage_gap.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.13}/tests/test_models.py +0 -0
|
@@ -5,6 +5,19 @@ 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.13] - 2026-04-17
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `price check`: detect anomalous pricing situations in addition to missing price gaps.
|
|
12
|
+
- `transaction list --fields`: limit JSON output to a selected comma-separated subset of fields.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Upgraded `agentyper` to `0.1.12`.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- `report audit`: sort entries chronologically from oldest to newest.
|
|
19
|
+
- `price fetch`: remove the non-functional `--held` behavior.
|
|
20
|
+
|
|
8
21
|
## [0.2.12] - 2026-04-08
|
|
9
22
|
|
|
10
23
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: beancount-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.13
|
|
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.12
|
|
24
24
|
Requires-Dist: beancount>=3.0.0
|
|
25
|
-
Requires-Dist:
|
|
25
|
+
Requires-Dist: beanprice2>=2.1.0
|
|
26
26
|
Requires-Dist: beanquery>=0.1.0
|
|
27
27
|
Requires-Dist: pydantic-settings>=2.0.0
|
|
28
28
|
Requires-Dist: pydantic>=2.0.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "beancount-cli"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.13"
|
|
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.0.0",
|
|
15
15
|
"python-dateutil>=2.8.2",
|
|
16
|
-
"
|
|
16
|
+
"beanprice2>=2.1.0",
|
|
17
17
|
"pydantic-settings>=2.0.0",
|
|
18
|
-
"agentyper==0.1.
|
|
18
|
+
"agentyper==0.1.12",
|
|
19
19
|
]
|
|
20
20
|
keywords = ["beancount", "cli", "accounting", "automation", "agent"]
|
|
21
21
|
classifiers = [
|
|
@@ -62,8 +62,6 @@ packages = ["src/beancount_cli"]
|
|
|
62
62
|
requires = ["hatchling"]
|
|
63
63
|
build-backend = "hatchling.build"
|
|
64
64
|
|
|
65
|
-
[tool.uv.sources]
|
|
66
|
-
beanprice = { git = "https://github.com/romamo/beanprice" }
|
|
67
65
|
|
|
68
66
|
[dependency-groups]
|
|
69
67
|
dev = [
|
|
@@ -88,4 +86,3 @@ extend-immutable-calls = ["agentyper.Argument", "agentyper.Option", "typer.Argum
|
|
|
88
86
|
[tool.ruff.lint.per-file-ignores]
|
|
89
87
|
"tests/*" = ["E501"]
|
|
90
88
|
|
|
91
|
-
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.13"
|
|
@@ -59,6 +59,49 @@ def price_check(
|
|
|
59
59
|
typer.output(gaps, title="Price Gaps")
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
@app.command(name="check-anomalies")
|
|
63
|
+
def price_check_anomalies(
|
|
64
|
+
ledger_file: Path | None = typer.Argument(None, help="Path to ledger file"),
|
|
65
|
+
file: Path | None = typer.Option(
|
|
66
|
+
None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
|
|
67
|
+
),
|
|
68
|
+
threshold: float = typer.Option(
|
|
69
|
+
1.0, "--threshold", "-t", help="Minimum change percentage as decimal (1.0 = 100%)"
|
|
70
|
+
),
|
|
71
|
+
max_days: int = typer.Option(
|
|
72
|
+
7, "--max-days", "-d", help="Maximum days between consecutive prices"
|
|
73
|
+
),
|
|
74
|
+
):
|
|
75
|
+
"""Check for sudden price jumps or drops in the ledger."""
|
|
76
|
+
actual_file = get_ledger_file(ledger_file or file)
|
|
77
|
+
ledger_service = LedgerService(actual_file)
|
|
78
|
+
price_service = PriceService(ledger_service)
|
|
79
|
+
|
|
80
|
+
anomalies = price_service.get_price_anomalies(
|
|
81
|
+
threshold=Decimal(str(threshold)), max_days=max_days
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if _is_table_format():
|
|
85
|
+
if not anomalies:
|
|
86
|
+
typer.echo("No price anomalies found.")
|
|
87
|
+
else:
|
|
88
|
+
anomalies_data = [
|
|
89
|
+
{
|
|
90
|
+
"Currency": a.currency,
|
|
91
|
+
"Target": a.target_currency,
|
|
92
|
+
"Date": str(a.date),
|
|
93
|
+
"Next Date": str(a.next_date),
|
|
94
|
+
"Price": f"{a.price:,.4f}",
|
|
95
|
+
"Next Price": f"{a.next_price:,.4f}",
|
|
96
|
+
"Change %": f"{a.change_pct:,.1f}%",
|
|
97
|
+
}
|
|
98
|
+
for a in anomalies
|
|
99
|
+
]
|
|
100
|
+
typer.output(anomalies_data, title=f"Price Anomalies ({len(anomalies)})")
|
|
101
|
+
else:
|
|
102
|
+
typer.output(anomalies, title="Price Anomalies")
|
|
103
|
+
|
|
104
|
+
|
|
62
105
|
@app.command(name="fetch", mutating=True)
|
|
63
106
|
def price_fetch(
|
|
64
107
|
ledger_files: list[Path] = typer.Argument(
|
|
@@ -123,9 +166,7 @@ def price_fetch(
|
|
|
123
166
|
|
|
124
167
|
if update or fill_gaps:
|
|
125
168
|
jobs = bp_price.get_price_jobs_up_to_date(
|
|
126
|
-
entries,
|
|
127
|
-
date_last=datetime.now().date(),
|
|
128
|
-
inactive=inactive,
|
|
169
|
+
entries, date_last=datetime.now().date(), inactive=inactive
|
|
129
170
|
)
|
|
130
171
|
else:
|
|
131
172
|
jobs = bp_price.get_price_jobs_at_date(entries, date=None, inactive=inactive)
|
|
@@ -175,10 +175,10 @@ def report_audit(
|
|
|
175
175
|
sys.exit(typer.EXIT_VALIDATION)
|
|
176
176
|
|
|
177
177
|
txs = tx_service.list_transactions(currency=audit_currency)
|
|
178
|
-
txs.sort(key=lambda x: x.date,
|
|
178
|
+
txs.sort(key=lambda x: (x.date, x.payee or "", x.narration))
|
|
179
179
|
|
|
180
180
|
if not all_:
|
|
181
|
-
txs = txs[:
|
|
181
|
+
txs = txs[-limit:]
|
|
182
182
|
|
|
183
183
|
if _is_table_format():
|
|
184
184
|
from rich.table import Table
|
|
@@ -21,6 +21,9 @@ def tx_list(
|
|
|
21
21
|
payee: str | None = typer.Option(None, "--payee", "-p", help="Filter by payee regex"),
|
|
22
22
|
tag: str | None = typer.Option(None, "--tag", "-t", help="Filter by tag"),
|
|
23
23
|
where: str | None = typer.Option(None, "--where", "-w", help="Custom BQL where clause"),
|
|
24
|
+
fields: str | None = typer.Option(
|
|
25
|
+
None, "--fields", help="Comma-separated fields to include in JSON output"
|
|
26
|
+
),
|
|
24
27
|
):
|
|
25
28
|
"""List transactions matching filters."""
|
|
26
29
|
actual_file = get_ledger_file(ledger_file or file)
|
|
@@ -36,7 +39,11 @@ def tx_list(
|
|
|
36
39
|
typer.output(data, title=f"Transactions ({len(txs)})")
|
|
37
40
|
else:
|
|
38
41
|
# Let agentyper format direct Pydantic models to JSON/CSV naturally!
|
|
39
|
-
|
|
42
|
+
results = [tx.model_dump(mode="json") for tx in txs]
|
|
43
|
+
if fields:
|
|
44
|
+
fields_set = set(fields.split(","))
|
|
45
|
+
results = [{k: v for k, v in r.items() if k in fields_set} for r in results]
|
|
46
|
+
typer.output(results, title=f"Transactions ({len(txs)})")
|
|
40
47
|
|
|
41
48
|
|
|
42
49
|
@app.command(name="add")
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import datetime
|
|
2
4
|
import re
|
|
3
5
|
from decimal import Decimal
|
|
4
6
|
from typing import TYPE_CHECKING, Annotated, Any
|
|
5
7
|
|
|
6
|
-
from pydantic import AfterValidator, BaseModel, Field
|
|
8
|
+
from pydantic import AfterValidator, BaseModel, ConfigDict, Field
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
def validate_account_name(v: Any) -> str:
|
|
@@ -127,3 +129,15 @@ class PriceGapModel(BaseModel):
|
|
|
127
129
|
last_available_date: datetime.date | None = None
|
|
128
130
|
days_missing: int
|
|
129
131
|
fetch_command: str | None = None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class PriceAnomalyModel(BaseModel):
|
|
135
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
136
|
+
|
|
137
|
+
currency: CurrencyCode.Input
|
|
138
|
+
target_currency: CurrencyCode.Input | None = None
|
|
139
|
+
date: datetime.date
|
|
140
|
+
next_date: datetime.date
|
|
141
|
+
price: Decimal
|
|
142
|
+
next_price: Decimal
|
|
143
|
+
change_pct: Decimal
|
|
@@ -14,6 +14,7 @@ from beancount_cli.models import (
|
|
|
14
14
|
BalanceModel,
|
|
15
15
|
CommodityModel,
|
|
16
16
|
CurrencyCode,
|
|
17
|
+
PriceAnomalyModel,
|
|
17
18
|
PriceGapModel,
|
|
18
19
|
TransactionModel,
|
|
19
20
|
UndeclaredCommodityModel,
|
|
@@ -790,7 +791,6 @@ class PriceService:
|
|
|
790
791
|
jobs = bp_price.get_price_jobs_up_to_date(
|
|
791
792
|
entries,
|
|
792
793
|
date_last=date.today(),
|
|
793
|
-
fill_gaps=True,
|
|
794
794
|
update_rate=bp_rate,
|
|
795
795
|
)
|
|
796
796
|
|
|
@@ -829,3 +829,43 @@ class PriceService:
|
|
|
829
829
|
)
|
|
830
830
|
|
|
831
831
|
return gaps
|
|
832
|
+
|
|
833
|
+
def get_price_anomalies(
|
|
834
|
+
self, threshold: Decimal = Decimal("1.0"), max_days: int = 7
|
|
835
|
+
) -> list[PriceAnomalyModel]:
|
|
836
|
+
"""
|
|
837
|
+
Identify sudden price jumps or drops.
|
|
838
|
+
threshold: 1.0 means 100% change (price doubled or dropped to zero).
|
|
839
|
+
max_days: Only compare points that are at most this many days apart.
|
|
840
|
+
"""
|
|
841
|
+
self.ledger_service.load()
|
|
842
|
+
price_map = self.ledger_service.get_price_map()
|
|
843
|
+
|
|
844
|
+
anomalies = []
|
|
845
|
+
for (base, quote), points in price_map.items():
|
|
846
|
+
# points is a list of (date, price) sorted by date
|
|
847
|
+
for i in range(len(points) - 1):
|
|
848
|
+
curr_date, curr_price = points[i]
|
|
849
|
+
next_date, next_price = points[i + 1]
|
|
850
|
+
|
|
851
|
+
if (next_date - curr_date).days > max_days:
|
|
852
|
+
continue
|
|
853
|
+
|
|
854
|
+
if curr_price == 0:
|
|
855
|
+
continue
|
|
856
|
+
|
|
857
|
+
change = abs(next_price - curr_price) / curr_price
|
|
858
|
+
if change >= threshold:
|
|
859
|
+
anomalies.append(
|
|
860
|
+
PriceAnomalyModel(
|
|
861
|
+
currency=base,
|
|
862
|
+
target_currency=quote,
|
|
863
|
+
date=curr_date,
|
|
864
|
+
next_date=next_date,
|
|
865
|
+
price=curr_price,
|
|
866
|
+
next_price=next_price,
|
|
867
|
+
change_pct=change * 100,
|
|
868
|
+
)
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
return sorted(anomalies, key=lambda x: (x.currency, x.date))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import io
|
|
2
2
|
import json
|
|
3
|
+
import textwrap
|
|
3
4
|
from unittest.mock import patch
|
|
4
5
|
|
|
5
6
|
from beancount_cli.cli import main
|
|
@@ -28,6 +29,28 @@ def test_transaction_list(temp_beancount_file):
|
|
|
28
29
|
assert "Employer" in out
|
|
29
30
|
|
|
30
31
|
|
|
32
|
+
def test_transaction_list_fields(temp_beancount_file):
|
|
33
|
+
code, out, err = run_cli(
|
|
34
|
+
"transaction",
|
|
35
|
+
"list",
|
|
36
|
+
str(temp_beancount_file),
|
|
37
|
+
"--format",
|
|
38
|
+
"json",
|
|
39
|
+
"--fields",
|
|
40
|
+
"date,payee",
|
|
41
|
+
)
|
|
42
|
+
assert code in (0, None)
|
|
43
|
+
data = json.loads(out)
|
|
44
|
+
# Check that only date and payee are present (plus any standard agentyper wrapper)
|
|
45
|
+
# If agentyper wraps it in {"ok": true, "data": [...]}:
|
|
46
|
+
txs = data["data"]
|
|
47
|
+
if isinstance(txs, list):
|
|
48
|
+
for tx in txs:
|
|
49
|
+
assert set(tx.keys()) == {"date", "payee"}
|
|
50
|
+
else:
|
|
51
|
+
assert set(txs.keys()) == {"date", "payee"}
|
|
52
|
+
|
|
53
|
+
|
|
31
54
|
def test_transaction_add_json(temp_beancount_file):
|
|
32
55
|
payload = {
|
|
33
56
|
"date": "2023-12-01",
|
|
@@ -94,10 +117,47 @@ def test_report_holdings(temp_beancount_file):
|
|
|
94
117
|
assert "Holdings" in out
|
|
95
118
|
|
|
96
119
|
|
|
97
|
-
def test_report_audit(
|
|
98
|
-
|
|
120
|
+
def test_report_audit(tmp_path):
|
|
121
|
+
path = tmp_path / "audit.beancount"
|
|
122
|
+
path.write_text(
|
|
123
|
+
textwrap.dedent(
|
|
124
|
+
"""
|
|
125
|
+
option "operating_currency" "USD"
|
|
126
|
+
2020-01-01 open Assets:Cash USD
|
|
127
|
+
2020-01-01 open Expenses:Food USD
|
|
128
|
+
|
|
129
|
+
2020-02-01 * "Store A" "Oldest"
|
|
130
|
+
Expenses:Food 100 USD
|
|
131
|
+
Assets:Cash -100 USD
|
|
132
|
+
|
|
133
|
+
2020-02-15 * "Store B" "Middle"
|
|
134
|
+
Expenses:Food 200 USD
|
|
135
|
+
Assets:Cash -200 USD
|
|
136
|
+
|
|
137
|
+
2020-03-01 * "Store C" "Newest"
|
|
138
|
+
Expenses:Food 300 USD
|
|
139
|
+
Assets:Cash -300 USD
|
|
140
|
+
"""
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
# Test all transactions (older to newest)
|
|
144
|
+
code, out, err = run_cli("report", "audit", str(path), "--currency", "USD", "--all")
|
|
99
145
|
assert code in (0, None)
|
|
100
146
|
assert "Audit Report: USD" in out
|
|
147
|
+
lines = [line for line in out.splitlines() if "Store" in line]
|
|
148
|
+
assert len(lines) == 6
|
|
149
|
+
assert "Store A" in lines[0]
|
|
150
|
+
assert "Store B" in lines[2]
|
|
151
|
+
assert "Store C" in lines[4]
|
|
152
|
+
|
|
153
|
+
# Test limit (should show LAST 2 transactions in chronological order: Middle -> Newest)
|
|
154
|
+
code, out, err = run_cli("report", "audit", str(path), "--currency", "USD", "--limit", "2")
|
|
155
|
+
assert code in (0, None)
|
|
156
|
+
lines = [line for line in out.splitlines() if "Store" in line]
|
|
157
|
+
assert len(lines) == 4
|
|
158
|
+
assert "Store B" in lines[0]
|
|
159
|
+
assert "Store C" in lines[2]
|
|
160
|
+
assert "Showing last 2 transactions" in out
|
|
101
161
|
|
|
102
162
|
|
|
103
163
|
def test_tx_schema():
|
|
@@ -174,3 +174,19 @@ def test_holdings_in_target_currency_equal_position_amount(tmp_path):
|
|
|
174
174
|
)
|
|
175
175
|
assert holdings["totals"]["EUR"]["market"] == expected
|
|
176
176
|
assert holdings["totals"]["EUR"]["cost"] == expected
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_price_service_get_price_gaps(temp_beancount_file):
|
|
180
|
+
from unittest.mock import patch
|
|
181
|
+
|
|
182
|
+
from beancount_cli.services import LedgerService, PriceService
|
|
183
|
+
|
|
184
|
+
with patch("beanprice.price.get_price_jobs_up_to_date") as mock_get_jobs:
|
|
185
|
+
mock_get_jobs.return_value = []
|
|
186
|
+
ledger = LedgerService(temp_beancount_file)
|
|
187
|
+
svc = PriceService(ledger)
|
|
188
|
+
# Using a default call to get_price_gaps to see if it defaults correctly
|
|
189
|
+
svc.get_price_gaps()
|
|
190
|
+
mock_get_jobs.assert_called_once()
|
|
191
|
+
# Verify that fill_gaps is not among the kwargs anymore (since it was removed)
|
|
192
|
+
assert "fill_gaps" not in mock_get_jobs.call_args[1]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.12"
|
|
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
|