beancount-cli 0.2.12__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.12 → beancount_cli-0.2.14}/.gitignore +1 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/CHANGELOG.md +21 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/PKG-INFO +6 -6
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/pyproject.toml +6 -9
- beancount_cli-0.2.14/src/beancount_cli/__init__.py +1 -0
- {beancount_cli-0.2.12 → 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.12 → beancount_cli-0.2.14}/src/beancount_cli/commands/common.py +24 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/commands/price.py +103 -18
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/commands/report.py +6 -10
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/commands/transaction.py +11 -6
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/models.py +39 -2
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/services.py +146 -5
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/tests/test_advanced.py +2 -4
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/tests/test_cli.py +73 -9
- {beancount_cli-0.2.12 → 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.12 → beancount_cli-0.2.14}/tests/test_services.py +17 -1
- beancount_cli-0.2.12/src/beancount_cli/__init__.py +0 -1
- beancount_cli-0.2.12/src/beancount_cli/commands/account.py +0 -112
- beancount_cli-0.2.12/src/beancount_cli/commands/commodity.py +0 -100
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/LICENSE +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/README.md +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/cli.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/commands/__init__.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/commands/root.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/config.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/formatting.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/py.typed +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/tests/conftest.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/tests/smoke_test.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/tests/test_bql.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/tests/test_config.py +0 -0
- {beancount_cli-0.2.12 → beancount_cli-0.2.14}/tests/test_models.py +0 -0
|
@@ -5,6 +5,27 @@ 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
|
+
|
|
16
|
+
## [0.2.13] - 2026-04-17
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- `price check`: detect anomalous pricing situations in addition to missing price gaps.
|
|
20
|
+
- `transaction list --fields`: limit JSON output to a selected comma-separated subset of fields.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- Upgraded `agentyper` to `0.1.12`.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- `report audit`: sort entries chronologically from oldest to newest.
|
|
27
|
+
- `price fetch`: remove the non-functional `--held` behavior.
|
|
28
|
+
|
|
8
29
|
## [0.2.12] - 2026-04-08
|
|
9
30
|
|
|
10
31
|
### Fixed
|
|
@@ -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
|
|
25
|
-
Requires-Dist:
|
|
23
|
+
Requires-Dist: agentyper==0.1.13
|
|
24
|
+
Requires-Dist: beancount==3.2.3
|
|
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
|
-
"
|
|
17
|
-
"pydantic-settings
|
|
18
|
-
"agentyper==0.1.
|
|
16
|
+
"beanprice2>=2.1.0",
|
|
17
|
+
"pydantic-settings==2.14.1",
|
|
18
|
+
"agentyper==0.1.13",
|
|
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.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 == "-":
|