beancount-cli 0.2.13__tar.gz → 0.2.14__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.13 → beancount_cli-0.2.14}/.gitignore +1 -0
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/CHANGELOG.md +8 -0
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/PKG-INFO +5 -5
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/pyproject.toml +5 -5
- beancount_cli-0.2.14/src/beancount_cli/__init__.py +1 -0
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/adapters.py +28 -0
- beancount_cli-0.2.14/src/beancount_cli/commands/account.py +202 -0
- beancount_cli-0.2.14/src/beancount_cli/commands/commodity.py +193 -0
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/commands/common.py +24 -0
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/commands/price.py +62 -18
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/commands/report.py +4 -8
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/commands/transaction.py +3 -5
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/models.py +24 -1
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/services.py +105 -4
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/tests/test_advanced.py +2 -4
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/tests/test_cli.py +13 -9
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/tests/test_coverage_gap.py +19 -6
- beancount_cli-0.2.14/tests/test_price_cash_currency.py +121 -0
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/tests/test_services.py +1 -1
- beancount_cli-0.2.13/src/beancount_cli/__init__.py +0 -1
- beancount_cli-0.2.13/src/beancount_cli/commands/account.py +0 -112
- beancount_cli-0.2.13/src/beancount_cli/commands/commodity.py +0 -100
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/LICENSE +0 -0
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/README.md +0 -0
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/cli.py +0 -0
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/commands/__init__.py +0 -0
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/commands/root.py +0 -0
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/config.py +0 -0
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/formatting.py +0 -0
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/py.typed +0 -0
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/tests/conftest.py +0 -0
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/tests/smoke_test.py +0 -0
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/tests/test_bql.py +0 -0
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/tests/test_config.py +0 -0
- {beancount_cli-0.2.13 → beancount_cli-0.2.14}/tests/test_models.py +0 -0
|
@@ -5,6 +5,14 @@ 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.14] - 2026-06-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `account pad-balance`: insert a `pad` + `balance` directive pair directly into the ledger.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- `--stdin` flag renamed to `--input` across all commands that read from standard input.
|
|
15
|
+
|
|
8
16
|
## [0.2.13] - 2026-04-17
|
|
9
17
|
|
|
10
18
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: beancount-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.14
|
|
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,12 +20,12 @@ 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.
|
|
24
|
-
Requires-Dist: beancount
|
|
23
|
+
Requires-Dist: agentyper==0.1.13
|
|
24
|
+
Requires-Dist: beancount==3.2.3
|
|
25
25
|
Requires-Dist: beanprice2>=2.1.0
|
|
26
26
|
Requires-Dist: beanquery>=0.1.0
|
|
27
|
-
Requires-Dist: pydantic-settings
|
|
28
|
-
Requires-Dist: pydantic
|
|
27
|
+
Requires-Dist: pydantic-settings==2.14.1
|
|
28
|
+
Requires-Dist: pydantic==2.13.4
|
|
29
29
|
Requires-Dist: python-dateutil>=2.8.2
|
|
30
30
|
Description-Content-Type: text/markdown
|
|
31
31
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "beancount-cli"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.14"
|
|
4
4
|
description = "A CLI tool to manage Beancount ledgers"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -9,13 +9,13 @@ authors = [
|
|
|
9
9
|
license = { text = "MIT" }
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
dependencies = [
|
|
12
|
-
"beancount
|
|
12
|
+
"beancount==3.2.3",
|
|
13
13
|
"beanquery>=0.1.0",
|
|
14
|
-
"pydantic
|
|
14
|
+
"pydantic==2.13.4",
|
|
15
15
|
"python-dateutil>=2.8.2",
|
|
16
16
|
"beanprice2>=2.1.0",
|
|
17
|
-
"pydantic-settings
|
|
18
|
-
"agentyper==0.1.
|
|
17
|
+
"pydantic-settings==2.14.1",
|
|
18
|
+
"agentyper==0.1.13",
|
|
19
19
|
]
|
|
20
20
|
keywords = ["beancount", "cli", "accounting", "automation", "agent"]
|
|
21
21
|
classifiers = [
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.14"
|
|
@@ -8,6 +8,7 @@ from beancount_cli.models import (
|
|
|
8
8
|
BalanceModel,
|
|
9
9
|
CostModel,
|
|
10
10
|
CurrencyCode,
|
|
11
|
+
PadBalanceModel,
|
|
11
12
|
PostingModel,
|
|
12
13
|
TransactionModel,
|
|
13
14
|
)
|
|
@@ -110,3 +111,30 @@ def to_core_balance(model: BalanceModel) -> data.Balance:
|
|
|
110
111
|
diff_amount=None,
|
|
111
112
|
tolerance=None,
|
|
112
113
|
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def to_core_pad(model: PadBalanceModel) -> tuple[data.Pad, data.Balance]:
|
|
117
|
+
"""Convert a PadBalanceModel into a (Pad, Balance) directive pair.
|
|
118
|
+
|
|
119
|
+
The Pad directive is dated one day before the Balance assertion by default,
|
|
120
|
+
which is the standard Beancount pattern to avoid the 'Unused Pad entry' error.
|
|
121
|
+
"""
|
|
122
|
+
import datetime
|
|
123
|
+
|
|
124
|
+
pad_date = model.pad_date or (model.balance_date - datetime.timedelta(days=1))
|
|
125
|
+
|
|
126
|
+
pad = data.Pad(
|
|
127
|
+
meta=model.meta or {},
|
|
128
|
+
date=pad_date,
|
|
129
|
+
account=str(model.account),
|
|
130
|
+
source_account=str(model.pad_account),
|
|
131
|
+
)
|
|
132
|
+
balance = data.Balance(
|
|
133
|
+
meta={},
|
|
134
|
+
date=model.balance_date,
|
|
135
|
+
account=str(model.account),
|
|
136
|
+
amount=to_core_amount(model.amount),
|
|
137
|
+
diff_amount=None,
|
|
138
|
+
tolerance=None,
|
|
139
|
+
)
|
|
140
|
+
return pad, balance
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from datetime import date
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import agentyper as typer
|
|
7
|
+
from pydantic import TypeAdapter
|
|
8
|
+
|
|
9
|
+
from beancount_cli.commands.common import (
|
|
10
|
+
_is_table_format,
|
|
11
|
+
console,
|
|
12
|
+
get_ledger_file,
|
|
13
|
+
read_json_input,
|
|
14
|
+
)
|
|
15
|
+
from beancount_cli.models import AccountModel, BalanceModel, PadBalanceModel
|
|
16
|
+
from beancount_cli.services import AccountService
|
|
17
|
+
|
|
18
|
+
app = typer.Agentyper(help="Manage accounts.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command(name="list")
|
|
22
|
+
def account_list(
|
|
23
|
+
file: Path | None = typer.Option(
|
|
24
|
+
None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
|
|
25
|
+
),
|
|
26
|
+
):
|
|
27
|
+
"""List all accounts."""
|
|
28
|
+
actual_file = get_ledger_file(file)
|
|
29
|
+
service = AccountService(actual_file)
|
|
30
|
+
accounts = service.list_accounts()
|
|
31
|
+
|
|
32
|
+
if _is_table_format():
|
|
33
|
+
data = [
|
|
34
|
+
{
|
|
35
|
+
"Account": acc.name,
|
|
36
|
+
"Open Date": str(acc.open_date),
|
|
37
|
+
"Currencies": ", ".join(acc.currencies),
|
|
38
|
+
}
|
|
39
|
+
for acc in accounts
|
|
40
|
+
]
|
|
41
|
+
typer.output(data, title=f"Accounts ({len(accounts)})")
|
|
42
|
+
else:
|
|
43
|
+
typer.output(accounts, title=f"Accounts ({len(accounts)})")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command(name="create")
|
|
47
|
+
def account_create(
|
|
48
|
+
file: Path | None = typer.Option(
|
|
49
|
+
None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
|
|
50
|
+
),
|
|
51
|
+
name: str | None = typer.Option(None, "--name", "-n", help="Account name (e.g. Assets:Bank)"),
|
|
52
|
+
currency_opt: str | None = typer.Option(
|
|
53
|
+
None, "--currency", "-c", help="Currencies (comma-separated)"
|
|
54
|
+
),
|
|
55
|
+
open_date: str | None = typer.Option(None, "--date", "-d", help="Open date (YYYY-MM-DD)"),
|
|
56
|
+
json_data: str | None = typer.Option(
|
|
57
|
+
None, "--input", "-i", help="JSON string data (or '-' to read from STDIN)"
|
|
58
|
+
),
|
|
59
|
+
target: Path | None = typer.Option(None, "--target", help="Override target file to write to"),
|
|
60
|
+
):
|
|
61
|
+
"""Create a new account."""
|
|
62
|
+
actual_file = get_ledger_file(file)
|
|
63
|
+
service = AccountService(actual_file)
|
|
64
|
+
|
|
65
|
+
if json_data:
|
|
66
|
+
data_input = json.loads(read_json_input(json_data))
|
|
67
|
+
if isinstance(data_input, list):
|
|
68
|
+
ta = TypeAdapter(list[AccountModel])
|
|
69
|
+
models = ta.validate_python(data_input)
|
|
70
|
+
for m in models:
|
|
71
|
+
service.create_account(m, target_file=target)
|
|
72
|
+
console.print(f"[green]Account {m.name} created.[/green]")
|
|
73
|
+
else:
|
|
74
|
+
model = AccountModel(**data_input)
|
|
75
|
+
service.create_account(model, target_file=target)
|
|
76
|
+
console.print(f"[green]Account {model.name} created.[/green]")
|
|
77
|
+
else:
|
|
78
|
+
if not name:
|
|
79
|
+
console.print("[red]Error: --name is required if not using --input.[/red]")
|
|
80
|
+
sys.exit(typer.EXIT_VALIDATION)
|
|
81
|
+
|
|
82
|
+
d = date.today()
|
|
83
|
+
if open_date:
|
|
84
|
+
d = date.fromisoformat(open_date)
|
|
85
|
+
|
|
86
|
+
currencies = [c.strip() for c in currency_opt.split(",")] if currency_opt else []
|
|
87
|
+
model = AccountModel(name=name, open_date=d, currencies=currencies)
|
|
88
|
+
service.create_account(model, target_file=target)
|
|
89
|
+
console.print(f"[green]Account {name} created.[/green]")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command(name="balance")
|
|
93
|
+
def account_balance(
|
|
94
|
+
file: Path | None = typer.Option(
|
|
95
|
+
None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
|
|
96
|
+
),
|
|
97
|
+
json_data: str = typer.Option(
|
|
98
|
+
..., "--input", "-i", help="JSON string data (or '-' to read from STDIN)"
|
|
99
|
+
),
|
|
100
|
+
target: Path | None = typer.Option(None, "--target", help="Override target file to write to"),
|
|
101
|
+
):
|
|
102
|
+
"""Add a balance directive for an account."""
|
|
103
|
+
actual_file = get_ledger_file(file)
|
|
104
|
+
service = AccountService(actual_file)
|
|
105
|
+
|
|
106
|
+
data_input = json.loads(read_json_input(json_data))
|
|
107
|
+
model = BalanceModel(**data_input)
|
|
108
|
+
service.add_balance(model, target_file=target)
|
|
109
|
+
console.print(f"[green]Balance check for {model.account} added.[/green]")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.command(name="pad-balance")
|
|
113
|
+
def account_pad_balance(
|
|
114
|
+
file: Path | None = typer.Option(
|
|
115
|
+
None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
|
|
116
|
+
),
|
|
117
|
+
account: str | None = typer.Option(
|
|
118
|
+
None, "--account", help="Account to adjust (e.g. Assets:BE:Wise:EUR)"
|
|
119
|
+
),
|
|
120
|
+
amount: str | None = typer.Option(
|
|
121
|
+
None, "--amount", help="Target balance amount (e.g. 1777.00)"
|
|
122
|
+
),
|
|
123
|
+
currency: str | None = typer.Option(
|
|
124
|
+
None, "--currency", "-c", help="Currency of the target balance (e.g. EUR)"
|
|
125
|
+
),
|
|
126
|
+
pad_account: str | None = typer.Option(
|
|
127
|
+
None,
|
|
128
|
+
"--pad-account",
|
|
129
|
+
"-p",
|
|
130
|
+
help="Account to absorb the difference (e.g. Expenses:Other)",
|
|
131
|
+
),
|
|
132
|
+
balance_date: str | None = typer.Option(
|
|
133
|
+
None,
|
|
134
|
+
"--date",
|
|
135
|
+
"-d",
|
|
136
|
+
help="Date of the balance assertion (YYYY-MM-DD). Defaults to today.",
|
|
137
|
+
),
|
|
138
|
+
pad_date: str | None = typer.Option(
|
|
139
|
+
None,
|
|
140
|
+
"--pad-date",
|
|
141
|
+
help="Date of the pad directive (YYYY-MM-DD). Defaults to balance-date minus 1 day.",
|
|
142
|
+
),
|
|
143
|
+
json_data: str | None = typer.Option(
|
|
144
|
+
None, "--input", "-i", help="JSON string data (or '-' to read from STDIN)"
|
|
145
|
+
),
|
|
146
|
+
target: Path | None = typer.Option(None, "--target", help="Override target file to write to"),
|
|
147
|
+
):
|
|
148
|
+
"""Adjust an account balance using a Pad + Balance directive pair.
|
|
149
|
+
|
|
150
|
+
Beancount inserts a synthetic transaction to bring ACCOUNT to AMOUNT
|
|
151
|
+
on BALANCE-DATE. The adjustment is automatically booked to PAD-ACCOUNT.
|
|
152
|
+
|
|
153
|
+
Example:
|
|
154
|
+
uv run bean account pad-balance \\
|
|
155
|
+
--account Assets:BE:Wise:EUR \\
|
|
156
|
+
--amount 1777 --currency EUR \\
|
|
157
|
+
--pad-account Expenses:Other
|
|
158
|
+
|
|
159
|
+
JSON example (for agent pipelines):
|
|
160
|
+
echo '{"account": "Assets:BE:Wise:EUR", "amount": {"number": 1777, "currency": "EUR"}, \\
|
|
161
|
+
"pad_account": "Expenses:Other", "balance_date": "2026-06-02"}' \\
|
|
162
|
+
| uv run bean account pad-balance --input -
|
|
163
|
+
"""
|
|
164
|
+
actual_file = get_ledger_file(file)
|
|
165
|
+
service = AccountService(actual_file)
|
|
166
|
+
|
|
167
|
+
if json_data:
|
|
168
|
+
data_input = json.loads(read_json_input(json_data))
|
|
169
|
+
model = PadBalanceModel(**data_input)
|
|
170
|
+
else:
|
|
171
|
+
missing = [
|
|
172
|
+
f
|
|
173
|
+
for f, v in [
|
|
174
|
+
("--account", account),
|
|
175
|
+
("--amount", amount),
|
|
176
|
+
("--currency", currency),
|
|
177
|
+
("--pad-account", pad_account),
|
|
178
|
+
]
|
|
179
|
+
if not v
|
|
180
|
+
]
|
|
181
|
+
if missing:
|
|
182
|
+
console.print(
|
|
183
|
+
f"[red]Error: {', '.join(missing)} required when not using --input.[/red]"
|
|
184
|
+
)
|
|
185
|
+
sys.exit(typer.EXIT_VALIDATION)
|
|
186
|
+
|
|
187
|
+
b_date = date.fromisoformat(balance_date) if balance_date else date.today()
|
|
188
|
+
p_date = date.fromisoformat(pad_date) if pad_date else None
|
|
189
|
+
|
|
190
|
+
model = PadBalanceModel(
|
|
191
|
+
balance_date=b_date,
|
|
192
|
+
account=account, # type: ignore[arg-type]
|
|
193
|
+
amount={"number": amount, "currency": currency},
|
|
194
|
+
pad_account=pad_account, # type: ignore[arg-type]
|
|
195
|
+
pad_date=p_date,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
service.add_pad_balance(model, target_file=target)
|
|
199
|
+
console.print(
|
|
200
|
+
f"[green]Pad + Balance for {model.account} → {model.amount.number} "
|
|
201
|
+
f"{model.amount.currency} added.[/green]"
|
|
202
|
+
)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import agentyper as typer
|
|
7
|
+
from beancount.core import data
|
|
8
|
+
from beancount.parser import parser as bp_parser
|
|
9
|
+
|
|
10
|
+
from beancount_cli.commands.common import (
|
|
11
|
+
_is_table_format,
|
|
12
|
+
console,
|
|
13
|
+
error_console,
|
|
14
|
+
get_ledger_file,
|
|
15
|
+
read_json_input,
|
|
16
|
+
read_stdin,
|
|
17
|
+
)
|
|
18
|
+
from beancount_cli.models import CommodityModel
|
|
19
|
+
from beancount_cli.services import CommodityService
|
|
20
|
+
|
|
21
|
+
app = typer.Agentyper(help="Manage commodities.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command(name="list")
|
|
25
|
+
def commodity_list(
|
|
26
|
+
file: Path | None = typer.Option(
|
|
27
|
+
None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
|
|
28
|
+
),
|
|
29
|
+
asset_class: str | None = typer.Option(
|
|
30
|
+
None, "--asset-class", "-c", help="Filter by asset-class meta (e.g. stock, Cash)"
|
|
31
|
+
),
|
|
32
|
+
):
|
|
33
|
+
"""List all commodities."""
|
|
34
|
+
actual_file = get_ledger_file(file)
|
|
35
|
+
service = CommodityService(actual_file)
|
|
36
|
+
commodities = service.list_commodities(asset_class=asset_class)
|
|
37
|
+
|
|
38
|
+
if _is_table_format():
|
|
39
|
+
data = [
|
|
40
|
+
{
|
|
41
|
+
"Currency": c.currency,
|
|
42
|
+
"Date": str(c.date) if c.date else "",
|
|
43
|
+
"Name": c.meta.get("name", "") if c.meta else "",
|
|
44
|
+
}
|
|
45
|
+
for c in commodities
|
|
46
|
+
]
|
|
47
|
+
typer.output(data, title=f"Commodities ({len(commodities)})")
|
|
48
|
+
else:
|
|
49
|
+
typer.output(commodities, title=f"Commodities ({len(commodities)})")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command(name="check")
|
|
53
|
+
def commodity_check(
|
|
54
|
+
file: Path | None = typer.Option(
|
|
55
|
+
None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
|
|
56
|
+
),
|
|
57
|
+
):
|
|
58
|
+
"""Check for currencies used in transactions but missing a commodity directive."""
|
|
59
|
+
actual_file = get_ledger_file(file)
|
|
60
|
+
service = CommodityService(actual_file)
|
|
61
|
+
undeclared = service.get_undeclared_commodities()
|
|
62
|
+
|
|
63
|
+
if _is_table_format():
|
|
64
|
+
if not undeclared:
|
|
65
|
+
console.print("[green]All used currencies are declared.[/green]")
|
|
66
|
+
else:
|
|
67
|
+
typer.output(undeclared, title=f"Undeclared Commodities ({len(undeclared)})")
|
|
68
|
+
else:
|
|
69
|
+
typer.output(undeclared, title="Undeclared Commodities")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command(name="export")
|
|
73
|
+
def commodity_export(
|
|
74
|
+
file: Path | None = typer.Option(
|
|
75
|
+
None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
|
|
76
|
+
),
|
|
77
|
+
asset_class: str | None = typer.Option(
|
|
78
|
+
None, "--asset-class", "-c", help="Filter by asset-class meta (e.g. stock, Cash)"
|
|
79
|
+
),
|
|
80
|
+
output_file: Path | None = typer.Option(
|
|
81
|
+
None, "--output-file", help="Write output to file (default: commodities_file or stdout)"
|
|
82
|
+
),
|
|
83
|
+
):
|
|
84
|
+
"""Export commodities as beancount directives."""
|
|
85
|
+
actual_file = get_ledger_file(file)
|
|
86
|
+
service = CommodityService(actual_file)
|
|
87
|
+
commodities = service.list_commodities(asset_class=asset_class)
|
|
88
|
+
|
|
89
|
+
content = "\n".join(service._format_commodity_block(c) for c in commodities)
|
|
90
|
+
|
|
91
|
+
dest = output_file or service.ledger_service.get_commodities_file()
|
|
92
|
+
if dest:
|
|
93
|
+
dest.write_text(content)
|
|
94
|
+
console.print(f"[green]Exported {len(commodities)} commodities →[/green] {dest}")
|
|
95
|
+
else:
|
|
96
|
+
sys.stdout.write(content)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command(name="import", mutating=True)
|
|
100
|
+
def commodity_import(
|
|
101
|
+
file: Path | None = typer.Option(
|
|
102
|
+
None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
|
|
103
|
+
),
|
|
104
|
+
input_file: Path | None = typer.Option(
|
|
105
|
+
None, "--input-file", help="Read beancount directives from file instead of stdin"
|
|
106
|
+
),
|
|
107
|
+
output_file: Path | None = typer.Option(
|
|
108
|
+
None, "--output-file", help="Write to file (default: commodities_file from ledger config)"
|
|
109
|
+
),
|
|
110
|
+
overwrite: bool = typer.Option(False, "--overwrite", help="Overwrite existing commodities"),
|
|
111
|
+
dry_run: bool = False,
|
|
112
|
+
):
|
|
113
|
+
"""Import commodity directives from stdin (or --input-file) into the commodities_file."""
|
|
114
|
+
stdin_text = Path(input_file).read_text() if input_file else read_stdin()
|
|
115
|
+
entries, errors, _ = bp_parser.parse_string(stdin_text)
|
|
116
|
+
if errors:
|
|
117
|
+
for e in errors:
|
|
118
|
+
console.print(f"[red]Parse error: {e.message}[/red]")
|
|
119
|
+
sys.exit(typer.EXIT_VALIDATION)
|
|
120
|
+
|
|
121
|
+
commodities = [
|
|
122
|
+
CommodityModel(currency=e.currency, date=e.date, meta=e.meta)
|
|
123
|
+
for e in entries
|
|
124
|
+
if isinstance(e, data.Commodity)
|
|
125
|
+
]
|
|
126
|
+
if not commodities:
|
|
127
|
+
console.print("[yellow]No commodity directives found in stdin.[/yellow]")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
actual_file = get_ledger_file(file)
|
|
131
|
+
service = CommodityService(actual_file)
|
|
132
|
+
dest = output_file or service.ledger_service.get_commodities_file()
|
|
133
|
+
|
|
134
|
+
results, commodities_file = service.import_commodities(
|
|
135
|
+
commodities, output_file=dest, overwrite=overwrite, dry_run=dry_run
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
stdout_mode = not dry_run and commodities_file is None
|
|
139
|
+
status_console = error_console if stdout_mode else console
|
|
140
|
+
verbose = logging.getLogger().isEnabledFor(logging.INFO)
|
|
141
|
+
prefix = "[dim](dry-run)[/dim] " if dry_run else ""
|
|
142
|
+
for r in results:
|
|
143
|
+
if not verbose and not dry_run and r.action == "skipped":
|
|
144
|
+
continue
|
|
145
|
+
color = {"added": "green", "overwritten": "yellow", "skipped": "dim"}.get(r.action, "white")
|
|
146
|
+
status_console.print(f"{prefix}[{color}]{r.action:12}[/{color}] {r.currency}")
|
|
147
|
+
|
|
148
|
+
if dry_run:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
if commodities_file:
|
|
152
|
+
console.print(f"\n[green]Done →[/green] {commodities_file}")
|
|
153
|
+
else:
|
|
154
|
+
# No destination configured — stream added/overwritten entries to stdout
|
|
155
|
+
written = {r.currency for r in results if r.action != "skipped"}
|
|
156
|
+
for c in commodities:
|
|
157
|
+
if str(c.currency) in written:
|
|
158
|
+
sys.stdout.write(service._format_commodity_block(c))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command(name="create")
|
|
162
|
+
def commodity_create(
|
|
163
|
+
currency: str | None = typer.Argument(None, help="Currency code (e.g. USD)"),
|
|
164
|
+
file: Path | None = typer.Option(
|
|
165
|
+
None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
|
|
166
|
+
),
|
|
167
|
+
name: str | None = typer.Option(None, "--name", "-n", help="Full name"),
|
|
168
|
+
json_data: str | None = typer.Option(
|
|
169
|
+
None, "--input", "-i", help="JSON string data (or '-' to read from STDIN)"
|
|
170
|
+
),
|
|
171
|
+
):
|
|
172
|
+
"""Create a new commodity."""
|
|
173
|
+
actual_file = get_ledger_file(file)
|
|
174
|
+
service = CommodityService(actual_file)
|
|
175
|
+
|
|
176
|
+
if json_data:
|
|
177
|
+
data_input = json.loads(read_json_input(json_data))
|
|
178
|
+
items = data_input if isinstance(data_input, list) else [data_input]
|
|
179
|
+
for item in items:
|
|
180
|
+
curr = item.get("currency")
|
|
181
|
+
comm_name = item.get("name")
|
|
182
|
+
if not curr:
|
|
183
|
+
console.print(f"[yellow]Skipping invalid commodity entry: {item}[/yellow]")
|
|
184
|
+
continue
|
|
185
|
+
meta = {k: v for k, v in item.items() if k not in ("currency", "name")}
|
|
186
|
+
service.create_commodity(curr, name=comm_name, meta=meta)
|
|
187
|
+
console.print(f"[green]Commodity {curr} created.[/green]")
|
|
188
|
+
else:
|
|
189
|
+
if not currency:
|
|
190
|
+
console.print("[red]Error: currency argument is required if not using --input.[/red]")
|
|
191
|
+
sys.exit(typer.EXIT_VALIDATION)
|
|
192
|
+
service.create_commodity(currency, name=name)
|
|
193
|
+
console.print(f"[green]Commodity {currency} created.[/green]")
|
|
@@ -15,6 +15,30 @@ console = Console(width=_width)
|
|
|
15
15
|
error_console = Console(stderr=True, width=_width)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
_STDIN_MAX_BYTES = 65_536
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_stdin() -> str:
|
|
22
|
+
"""Read text from stdin, guarding against TTY deadlock and pipe-buffer overflow (§50, §61)."""
|
|
23
|
+
import agentyper as typer
|
|
24
|
+
|
|
25
|
+
if sys.stdin.isatty():
|
|
26
|
+
error_console.print(
|
|
27
|
+
"[red]Error: stdin is a TTY — pipe input or use --input-file PATH.[/red]"
|
|
28
|
+
)
|
|
29
|
+
sys.exit(typer.EXIT_VALIDATION)
|
|
30
|
+
|
|
31
|
+
chunk = sys.stdin.buffer.read(_STDIN_MAX_BYTES + 1)
|
|
32
|
+
if len(chunk) > _STDIN_MAX_BYTES:
|
|
33
|
+
error_console.print(
|
|
34
|
+
f"[red]Error: stdin exceeds {_STDIN_MAX_BYTES // 1024} KB limit."
|
|
35
|
+
" Use --input-file PATH for large payloads.[/red]"
|
|
36
|
+
)
|
|
37
|
+
sys.exit(typer.EXIT_VALIDATION)
|
|
38
|
+
|
|
39
|
+
return chunk.decode()
|
|
40
|
+
|
|
41
|
+
|
|
18
42
|
def read_json_input(json_data: str) -> str:
|
|
19
43
|
"""Read JSON from a string or from STDIN when json_data is '-'."""
|
|
20
44
|
if json_data == "-":
|
|
@@ -9,6 +9,7 @@ from tempfile import gettempdir
|
|
|
9
9
|
import agentyper as typer
|
|
10
10
|
from beancount.core import data
|
|
11
11
|
from beancount.core.data import sorted as bean_sorted
|
|
12
|
+
from beancount.ops import lifetimes as bean_lifetimes
|
|
12
13
|
from beancount.parser import printer
|
|
13
14
|
from beanprice import price as bp_price
|
|
14
15
|
|
|
@@ -20,7 +21,6 @@ app = typer.Agentyper(help="Manage prices.")
|
|
|
20
21
|
|
|
21
22
|
@app.command(name="check")
|
|
22
23
|
def price_check(
|
|
23
|
-
ledger_file: Path | None = typer.Argument(None, help="Path to ledger file"),
|
|
24
24
|
file: Path | None = typer.Option(
|
|
25
25
|
None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
|
|
26
26
|
),
|
|
@@ -32,7 +32,7 @@ def price_check(
|
|
|
32
32
|
),
|
|
33
33
|
):
|
|
34
34
|
"""Check for missing price data in the ledger."""
|
|
35
|
-
actual_file = get_ledger_file(
|
|
35
|
+
actual_file = get_ledger_file(file)
|
|
36
36
|
ledger_service = LedgerService(actual_file)
|
|
37
37
|
price_service = PriceService(ledger_service)
|
|
38
38
|
|
|
@@ -61,7 +61,6 @@ def price_check(
|
|
|
61
61
|
|
|
62
62
|
@app.command(name="check-anomalies")
|
|
63
63
|
def price_check_anomalies(
|
|
64
|
-
ledger_file: Path | None = typer.Argument(None, help="Path to ledger file"),
|
|
65
64
|
file: Path | None = typer.Option(
|
|
66
65
|
None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
|
|
67
66
|
),
|
|
@@ -73,7 +72,7 @@ def price_check_anomalies(
|
|
|
73
72
|
),
|
|
74
73
|
):
|
|
75
74
|
"""Check for sudden price jumps or drops in the ledger."""
|
|
76
|
-
actual_file = get_ledger_file(
|
|
75
|
+
actual_file = get_ledger_file(file)
|
|
77
76
|
ledger_service = LedgerService(actual_file)
|
|
78
77
|
price_service = PriceService(ledger_service)
|
|
79
78
|
|
|
@@ -162,14 +161,19 @@ def price_fetch(
|
|
|
162
161
|
|
|
163
162
|
assert primary_ledger_service is not None # always set — files_to_load is non-empty
|
|
164
163
|
|
|
165
|
-
_warn_missing_price_meta_entries(
|
|
164
|
+
_warn_missing_price_meta_entries(
|
|
165
|
+
entries, "Skipping fetch.", primary_ledger_service.get_operating_currencies()
|
|
166
|
+
)
|
|
166
167
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
168
|
+
date_last = datetime.now().date()
|
|
169
|
+
jobs = _resolve_price_jobs(entries, date_last, inactive, update, fill_gaps)
|
|
170
|
+
|
|
171
|
+
if not inactive:
|
|
172
|
+
inactive_jobs = _resolve_price_jobs(entries, date_last, True, update, fill_gaps)
|
|
173
|
+
cash_jobs = _get_cash_currency_jobs(entries, date_last, inactive_jobs)
|
|
174
|
+
if cash_jobs:
|
|
175
|
+
existing = {(j.base, j.quote, j.date) for j in jobs}
|
|
176
|
+
jobs.extend(j for j in cash_jobs if (j.base, j.quote, j.date) not in existing)
|
|
173
177
|
|
|
174
178
|
if not jobs:
|
|
175
179
|
error_console.print("[yellow]No price jobs to execute.[/yellow]")
|
|
@@ -283,6 +287,44 @@ def price_fetch(
|
|
|
283
287
|
sys.exit(typer.EXIT_SYSTEM)
|
|
284
288
|
|
|
285
289
|
|
|
290
|
+
def _resolve_price_jobs(
|
|
291
|
+
entries: list[data.Directive],
|
|
292
|
+
date_last: date,
|
|
293
|
+
inactive: bool,
|
|
294
|
+
update: bool,
|
|
295
|
+
fill_gaps: bool,
|
|
296
|
+
) -> list:
|
|
297
|
+
if update or fill_gaps:
|
|
298
|
+
return bp_price.get_price_jobs_up_to_date(entries, date_last=date_last, inactive=inactive)
|
|
299
|
+
return bp_price.get_price_jobs_at_date(entries, date=None, inactive=inactive)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _get_cash_currency_jobs(
|
|
303
|
+
entries: list[data.Directive],
|
|
304
|
+
date_last: date,
|
|
305
|
+
inactive_jobs: list,
|
|
306
|
+
) -> list:
|
|
307
|
+
"""Return price jobs for currencies held as cash that beanprice silently drops.
|
|
308
|
+
|
|
309
|
+
beanprice's lifetimes tracker keys holdings as (currency, cost_currency). Cash
|
|
310
|
+
currencies produce (base, None) keys, but declared price sources register as
|
|
311
|
+
(base, quote) — they never match, so the filter at price.py:478 drops them.
|
|
312
|
+
"""
|
|
313
|
+
raw_lifetimes = bean_lifetimes.get_commodity_lifetimes(entries)
|
|
314
|
+
cash_bases = {
|
|
315
|
+
base for (base, cost), intervals in raw_lifetimes.items() if cost is None and intervals
|
|
316
|
+
}
|
|
317
|
+
if not cash_bases:
|
|
318
|
+
return []
|
|
319
|
+
|
|
320
|
+
declared_triples = bp_price.find_currencies_declared(entries, date_last)
|
|
321
|
+
cash_pairs = {(base, quote) for base, quote, _ in declared_triples if base in cash_bases}
|
|
322
|
+
if not cash_pairs:
|
|
323
|
+
return []
|
|
324
|
+
|
|
325
|
+
return [job for job in inactive_jobs if (job.base, job.quote) in cash_pairs]
|
|
326
|
+
|
|
327
|
+
|
|
286
328
|
def _find_price_file(tree: dict) -> Path | None:
|
|
287
329
|
"""Search an include tree for a beancount file with 'price' in its name or parent directory."""
|
|
288
330
|
for k, v in tree.items():
|
|
@@ -298,10 +340,14 @@ def _find_price_file(tree: dict) -> Path | None:
|
|
|
298
340
|
|
|
299
341
|
def _warn_missing_price_meta(ledger_service: LedgerService, context: str) -> None:
|
|
300
342
|
"""Warn about held commodities that lack 'price' metadata and cannot be priced."""
|
|
301
|
-
_warn_missing_price_meta_entries(
|
|
343
|
+
_warn_missing_price_meta_entries(
|
|
344
|
+
ledger_service.entries, context, ledger_service.get_operating_currencies()
|
|
345
|
+
)
|
|
302
346
|
|
|
303
347
|
|
|
304
|
-
def _warn_missing_price_meta_entries(
|
|
348
|
+
def _warn_missing_price_meta_entries(
|
|
349
|
+
entries: list[data.Directive], context: str, operating_currencies: list[str] | None = None
|
|
350
|
+
) -> None:
|
|
305
351
|
"""Warn about held commodities (from a merged entry list) that lack 'price' metadata."""
|
|
306
352
|
from beancount.core import convert
|
|
307
353
|
from beancount.core.inventory import Inventory
|
|
@@ -322,13 +368,11 @@ def _warn_missing_price_meta_entries(entries: list[data.Directive], context: str
|
|
|
322
368
|
if not pos.units.number.is_zero()
|
|
323
369
|
}
|
|
324
370
|
|
|
325
|
-
|
|
326
|
-
# In a merged entry list, we might have 'option' directives as Custom entries or similar?
|
|
327
|
-
# Actually, Beancount usually loads them into an options map.
|
|
328
|
-
# For now, we'll just check commodity metadata.
|
|
329
|
-
|
|
371
|
+
excluded = set(operating_currencies or [])
|
|
330
372
|
commodity_meta = {e.currency: e.meta for e in entries if isinstance(e, data.Commodity)}
|
|
331
373
|
for curr in sorted(held):
|
|
374
|
+
if curr in excluded:
|
|
375
|
+
continue
|
|
332
376
|
meta = commodity_meta.get(curr, {})
|
|
333
377
|
if not meta or "price" not in meta:
|
|
334
378
|
error_console.print(
|