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.
Files changed (35) hide show
  1. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/.gitignore +1 -0
  2. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/CHANGELOG.md +8 -0
  3. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/PKG-INFO +5 -5
  4. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/pyproject.toml +5 -5
  5. beancount_cli-0.2.14/src/beancount_cli/__init__.py +1 -0
  6. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/adapters.py +28 -0
  7. beancount_cli-0.2.14/src/beancount_cli/commands/account.py +202 -0
  8. beancount_cli-0.2.14/src/beancount_cli/commands/commodity.py +193 -0
  9. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/commands/common.py +24 -0
  10. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/commands/price.py +62 -18
  11. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/commands/report.py +4 -8
  12. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/commands/transaction.py +3 -5
  13. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/models.py +24 -1
  14. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/services.py +105 -4
  15. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/tests/test_advanced.py +2 -4
  16. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/tests/test_cli.py +13 -9
  17. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/tests/test_coverage_gap.py +19 -6
  18. beancount_cli-0.2.14/tests/test_price_cash_currency.py +121 -0
  19. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/tests/test_services.py +1 -1
  20. beancount_cli-0.2.13/src/beancount_cli/__init__.py +0 -1
  21. beancount_cli-0.2.13/src/beancount_cli/commands/account.py +0 -112
  22. beancount_cli-0.2.13/src/beancount_cli/commands/commodity.py +0 -100
  23. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/LICENSE +0 -0
  24. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/README.md +0 -0
  25. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/cli.py +0 -0
  26. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/commands/__init__.py +0 -0
  27. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/commands/root.py +0 -0
  28. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/config.py +0 -0
  29. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/formatting.py +0 -0
  30. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/src/beancount_cli/py.typed +0 -0
  31. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/tests/conftest.py +0 -0
  32. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/tests/smoke_test.py +0 -0
  33. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/tests/test_bql.py +0 -0
  34. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/tests/test_config.py +0 -0
  35. {beancount_cli-0.2.13 → beancount_cli-0.2.14}/tests/test_models.py +0 -0
@@ -25,3 +25,4 @@ build/
25
25
  .vscode/
26
26
  *.swp
27
27
  .DS_Store
28
+ /Ledger/
@@ -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.13
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.12
24
- Requires-Dist: beancount>=3.0.0
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>=2.0.0
28
- Requires-Dist: pydantic>=2.0.0
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.13"
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>=3.0.0",
12
+ "beancount==3.2.3",
13
13
  "beanquery>=0.1.0",
14
- "pydantic>=2.0.0",
14
+ "pydantic==2.13.4",
15
15
  "python-dateutil>=2.8.2",
16
16
  "beanprice2>=2.1.0",
17
- "pydantic-settings>=2.0.0",
18
- "agentyper==0.1.12",
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(ledger_file or 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(ledger_file or 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(entries, "Skipping fetch.")
164
+ _warn_missing_price_meta_entries(
165
+ entries, "Skipping fetch.", primary_ledger_service.get_operating_currencies()
166
+ )
166
167
 
167
- if update or fill_gaps:
168
- jobs = bp_price.get_price_jobs_up_to_date(
169
- entries, date_last=datetime.now().date(), inactive=inactive
170
- )
171
- else:
172
- jobs = bp_price.get_price_jobs_at_date(entries, date=None, inactive=inactive)
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(ledger_service.entries, context)
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(entries: list[data.Directive], context: str) -> None:
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
- # Extract operating currencies from options if possible
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(