ledgerkit 1.0.0.dev1__py3-none-any.whl

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.
@@ -0,0 +1,274 @@
1
+ """Commodity display style inference and formatting for ledgerkit.
2
+
3
+ Captures how a commodity's amounts should be formatted: symbol position,
4
+ spacing, decimal mark, digit-group separator, and precision. Styles are
5
+ inferred from the first amount seen in the journal or from an explicit
6
+ `commodity` directive, then applied consistently throughout report output.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+ from decimal import Decimal
14
+ from typing import Optional
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Module-level helpers
19
+ # ---------------------------------------------------------------------------
20
+
21
+ def _group_digits(digits: str, sep: str) -> str:
22
+ """Insert sep every 3 digits from the right (e.g. "1234567" → "1,234,567")."""
23
+ result = []
24
+ for i, ch in enumerate(reversed(digits)):
25
+ if i > 0 and i % 3 == 0:
26
+ result.append(sep)
27
+ result.append(ch)
28
+ return "".join(reversed(result))
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Regexes
33
+ # ---------------------------------------------------------------------------
34
+
35
+ # Matches a trailing suffix commodity symbol in a style/amount string.
36
+ #
37
+ # Purpose: identify an alphabetic commodity code (e.g. "EUR", "USD", "AAPL")
38
+ # at the end of an amount string, optionally preceded by whitespace.
39
+ # Used by parse_style_override() to extract the commodity before
40
+ # delegating to infer().
41
+ #
42
+ # Group breakdown:
43
+ # (1) ([A-Za-z][A-Za-z0-9]*) — letter-started alphanumeric token;
44
+ # matches "EUR", "USD", "AAPL", "GBP" etc.
45
+ # \s*$ — optional trailing whitespace then end of string
46
+ #
47
+ # Edge cases:
48
+ # - "1,234.56 EUR" → matches "EUR"
49
+ # - "1.000,00 EUR" → matches "EUR"
50
+ # - "$1,234.56" → does NOT match (no trailing alphabetic token)
51
+ # - "bad input !!" → does NOT match ("!!" is not alphanumeric)
52
+ # - "EUR 1,234.56" → does NOT match (EUR is not at the end)
53
+ _STYLE_SUFFIX_COMMODITY = re.compile(r"([A-Za-z][A-Za-z0-9]*)\s*$")
54
+
55
+ # Matches a leading prefix commodity symbol in a style/amount string.
56
+ #
57
+ # Purpose: identify a non-numeric, non-separator commodity symbol (e.g. "$",
58
+ # "£", "€") at the start of an amount string, after an optional
59
+ # minus sign. Used by parse_style_override() when no suffix
60
+ # alphabetic commodity is found.
61
+ #
62
+ # Group breakdown:
63
+ # (1) (-?) — optional leading minus sign (consumed, not used here)
64
+ # (2) ([^\d,.\s-]+) — one or more characters that are not digits, commas,
65
+ # dots, whitespace, or minus; captures currency symbols
66
+ # like "$", "£", "€", "¥"
67
+ #
68
+ # Edge cases:
69
+ # - "$1,234.56" → group 2 = "$"
70
+ # - "£9,999.00" → group 2 = "£"
71
+ # - "1,234.56 EUR" → does NOT match (starts with a digit)
72
+ # - "-$1,234.56" → group 1 = "-", group 2 = "$"
73
+ # - "bad input !!" → group 2 = "bad" (but caller validates digits are present)
74
+ _STYLE_PREFIX_COMMODITY = re.compile(r"^(-?)([^\d,.\s-]+)")
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # CommodityStyle
79
+ # ---------------------------------------------------------------------------
80
+
81
+ @dataclass
82
+ class CommodityStyle:
83
+ """Display style for a single commodity, inferred from journal data.
84
+
85
+ Captures how amounts in this commodity should be formatted: whether the
86
+ symbol is a prefix or suffix, whether there is a space between symbol
87
+ and number, which character is the decimal mark, which (if any) is the
88
+ digit-group separator, and how many decimal places to show.
89
+ """
90
+
91
+ commodity: str
92
+ prefix: bool = True
93
+ space: bool = False
94
+ decimal_mark: str = "."
95
+ group_separator: str = ""
96
+ precision: int = 2
97
+
98
+ # ------------------------------------------------------------------
99
+ # Formatting
100
+ # ------------------------------------------------------------------
101
+
102
+ def format(self, quantity: Decimal) -> str:
103
+ """Return a formatted amount string using this commodity's display style."""
104
+ negative = quantity < 0
105
+ abs_qty = abs(quantity)
106
+
107
+ if self.precision > 0:
108
+ # Python always formats with '.' — we replace it with decimal_mark below.
109
+ formatted = f"{abs_qty:.{self.precision}f}"
110
+ int_str, frac_str = formatted.split(".")
111
+ else:
112
+ int_str = str(int(abs_qty))
113
+ frac_str = ""
114
+
115
+ if self.group_separator:
116
+ int_str = _group_digits(int_str, self.group_separator)
117
+
118
+ if frac_str:
119
+ number = int_str + self.decimal_mark + frac_str
120
+ else:
121
+ number = int_str
122
+
123
+ gap = " " if self.space else ""
124
+ if self.prefix:
125
+ # hledger convention: prefix-symbol negative → SYMBOL-NUMBER (e.g. £-5.00)
126
+ if negative:
127
+ return f"{self.commodity}-{number}"
128
+ return f"{self.commodity}{gap}{number}"
129
+ else:
130
+ # hledger convention: suffix-symbol negative → -NUMBER SYMBOL (e.g. -5.00 EUR)
131
+ if negative:
132
+ return f"-{number}{gap}{self.commodity}"
133
+ return f"{number}{gap}{self.commodity}"
134
+
135
+ # ------------------------------------------------------------------
136
+ # Inference
137
+ # ------------------------------------------------------------------
138
+
139
+ @classmethod
140
+ def infer(cls, commodity: str, raw_amount_str: str) -> "CommodityStyle":
141
+ """Infer a CommodityStyle by parsing the first seen raw amount string.
142
+
143
+ Detects prefix/suffix, spacing, decimal mark, group separator, and
144
+ precision from the raw text as it appeared in the journal source.
145
+ """
146
+ raw = raw_amount_str.strip()
147
+
148
+ # Strip leading minus sign for symbol detection purposes.
149
+ s = raw[1:] if raw.startswith("-") else raw
150
+
151
+ prefix = True
152
+ space = False
153
+ numeric = s
154
+
155
+ if commodity and s.startswith(commodity):
156
+ prefix = True
157
+ rest = s[len(commodity):]
158
+ space = rest.startswith(" ") or rest.startswith("\t")
159
+ numeric = rest.lstrip()
160
+ elif commodity and s.endswith(commodity):
161
+ prefix = False
162
+ rest = s[: -len(commodity)]
163
+ space = rest.endswith(" ") or rest.endswith("\t")
164
+ numeric = rest.rstrip()
165
+ # else: numeric = s (no recognisable commodity position; rare edge case)
166
+
167
+ decimal_mark, group_separator, precision = _infer_separators(numeric)
168
+
169
+ return cls(
170
+ commodity=commodity,
171
+ prefix=prefix,
172
+ space=space,
173
+ decimal_mark=decimal_mark,
174
+ group_separator=group_separator,
175
+ precision=precision,
176
+ )
177
+
178
+ # ------------------------------------------------------------------
179
+ # Override parsing
180
+ # ------------------------------------------------------------------
181
+
182
+ @classmethod
183
+ def parse_style_override(cls, style_string: str) -> "CommodityStyle":
184
+ """Parse a -c/--commodity-style override string in hledger format.
185
+
186
+ Examples::
187
+ "1,000.00 USD" → suffix, space, dot decimal, comma group, precision 2
188
+ "$1,000.00" → prefix, no space, dot decimal, comma group, precision 2
189
+ "1.000,00 EUR" → suffix, space, comma decimal, dot group, precision 2
190
+
191
+ Returns a CommodityStyle with the commodity extracted from the string.
192
+ Raises ValueError if the string cannot be parsed or contains no digits.
193
+ """
194
+ s = style_string.strip()
195
+ if not s:
196
+ raise ValueError(f"cannot parse commodity style: empty string")
197
+
198
+ # Must contain at least one digit to be a valid amount string.
199
+ if not any(ch.isdigit() for ch in s):
200
+ raise ValueError(
201
+ f"cannot parse commodity style (no digits): {style_string!r}"
202
+ )
203
+
204
+ # Try suffix alphabetic commodity first (e.g. "1,000.00 USD").
205
+ m_suffix = _STYLE_SUFFIX_COMMODITY.search(s)
206
+ if m_suffix:
207
+ commodity = m_suffix.group(1)
208
+ return cls.infer(commodity, s)
209
+
210
+ # Try prefix non-digit commodity symbol (e.g. "$1,000.00").
211
+ m_prefix = _STYLE_PREFIX_COMMODITY.match(s)
212
+ if m_prefix and m_prefix.group(2):
213
+ commodity = m_prefix.group(2)
214
+ return cls.infer(commodity, s)
215
+
216
+ raise ValueError(f"cannot parse commodity style: {style_string!r}")
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # Separator inference helper
221
+ # ---------------------------------------------------------------------------
222
+
223
+ def _infer_separators(numeric: str) -> tuple:
224
+ """Return (decimal_mark, group_separator, precision) from a numeric string."""
225
+ dots = numeric.count(".")
226
+ commas = numeric.count(",")
227
+
228
+ if dots > 0 and commas > 0:
229
+ # Both present: the rightmost separator is the decimal mark.
230
+ last_dot = numeric.rfind(".")
231
+ last_comma = numeric.rfind(",")
232
+ if last_dot > last_comma:
233
+ # e.g. "1,234.56" → decimal ".", group ","
234
+ decimal_mark = "."
235
+ group_separator = ","
236
+ precision = len(numeric) - last_dot - 1
237
+ else:
238
+ # e.g. "1.234,56" → decimal ",", group "."
239
+ decimal_mark = ","
240
+ group_separator = "."
241
+ precision = len(numeric) - last_comma - 1
242
+ elif dots > 0:
243
+ last_dot = numeric.rfind(".")
244
+ digits_after = len(numeric) - last_dot - 1
245
+ if digits_after == 3 and dots == 1 and last_dot > 0:
246
+ # e.g. "1.234" → group ".", decimal "," (European integer)
247
+ group_separator = "."
248
+ decimal_mark = ","
249
+ precision = 0
250
+ else:
251
+ # e.g. "1.5", "100.00" → decimal "."
252
+ decimal_mark = "."
253
+ group_separator = ""
254
+ precision = digits_after
255
+ elif commas > 0:
256
+ last_comma = numeric.rfind(",")
257
+ digits_after = len(numeric) - last_comma - 1
258
+ if digits_after == 3 and commas == 1 and last_comma > 0:
259
+ # e.g. "1,234" → group ",", decimal "." (US/UK integer)
260
+ group_separator = ","
261
+ decimal_mark = "."
262
+ precision = 0
263
+ else:
264
+ # e.g. "100,5", "1,50" → decimal ","
265
+ decimal_mark = ","
266
+ group_separator = ""
267
+ precision = digits_after
268
+ else:
269
+ # No separators at all (e.g. "100", "42")
270
+ decimal_mark = "."
271
+ group_separator = ""
272
+ precision = 0
273
+
274
+ return decimal_mark, group_separator, precision
@@ -0,0 +1,193 @@
1
+ """In-memory editable document model for ledgerkit.
2
+
3
+ Provides EditorDocument: load a journal file, mutate transactions in memory,
4
+ validate individual transactions, and write changes back to disk faithfully.
5
+ Intended for use by TUI/editor frontends. Not imported by any core module.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from ledgerkit.checks import CheckError, check_transaction_autobalanced
13
+ from ledgerkit.models import Journal, SourceSpan, Transaction
14
+ from ledgerkit.parser import parse_string
15
+ from ledgerkit.writer import transaction_to_text
16
+
17
+
18
+ class EditorDocument:
19
+ """An in-memory editable representation of a journal file.
20
+
21
+ Attributes:
22
+ path: Resolved absolute path to the journal file.
23
+ journal: Parsed Journal with all transactions (source_span populated).
24
+ lines: Current file content as a list of strings, one per line,
25
+ without newline characters.
26
+ dirty: True when lines differ from the last saved/loaded content.
27
+
28
+ V1 limitation: include directives in the file are not processed; the file
29
+ is parsed as-is with parse_string(). Use loader.load_journal() when
30
+ full include support is required.
31
+ """
32
+
33
+ path: str
34
+ journal: Journal
35
+ lines: list[str]
36
+ dirty: bool
37
+
38
+ def __init__(self, path: str) -> None:
39
+ """Load the journal at path; populate journal and lines."""
40
+ abs_path = str(Path(path).resolve())
41
+ self.path = abs_path
42
+ text = Path(abs_path).read_text(encoding="utf-8")
43
+ self.lines = text.splitlines()
44
+ self.journal = parse_string(text, source_file=abs_path)
45
+ self.dirty = False
46
+
47
+ # ------------------------------------------------------------------
48
+ # Span refresh helpers
49
+ # ------------------------------------------------------------------
50
+
51
+ def _shift_spans_after(self, after_line: int, delta: int) -> None:
52
+ """Shift source_span of every transaction that starts after after_line."""
53
+ for txn in self.journal.transactions:
54
+ if txn.source_span and txn.source_span.start_line > after_line:
55
+ txn.source_span = SourceSpan(
56
+ file=txn.source_span.file,
57
+ start_line=txn.source_span.start_line + delta,
58
+ end_line=txn.source_span.end_line + delta,
59
+ )
60
+
61
+ # ------------------------------------------------------------------
62
+ # Mutation methods
63
+ # ------------------------------------------------------------------
64
+
65
+ def add_transaction(self, txn: Transaction) -> None:
66
+ """Insert txn into self.lines in chronological order.
67
+
68
+ Inserts after the last transaction whose date <= txn.date. Updates
69
+ self.journal.transactions. Sets dirty=True. txn.source_span is
70
+ assigned after insertion.
71
+ """
72
+ new_lines = transaction_to_text(txn).splitlines()
73
+ n = len(new_lines)
74
+
75
+ # Find the last transaction with date <= txn.date (by start_line order)
76
+ sorted_txns = sorted(
77
+ [t for t in self.journal.transactions if t.source_span],
78
+ key=lambda t: (t.date, t.source_span.start_line), # type: ignore[union-attr]
79
+ )
80
+ insert_after: Transaction | None = None
81
+ for t in sorted_txns:
82
+ if t.date <= txn.date:
83
+ insert_after = t
84
+
85
+ if insert_after is None or insert_after.source_span is None:
86
+ # Insert at the very beginning (prepend)
87
+ insert_idx = 0 # 0-based index into self.lines
88
+ self.lines[0:0] = new_lines + [""]
89
+ txn.source_span = SourceSpan(
90
+ file=self.path,
91
+ start_line=1,
92
+ end_line=n,
93
+ )
94
+ self._shift_spans_after(n, n + 1)
95
+ else:
96
+ # Insert after the end of insert_after (insert_idx is 0-based)
97
+ insert_idx = insert_after.source_span.end_line # end_line is 1-based → index after block
98
+ self.lines[insert_idx:insert_idx] = [""] + new_lines
99
+ txn.source_span = SourceSpan(
100
+ file=self.path,
101
+ start_line=insert_idx + 2, # +1 for the blank line, +1 for 1-based
102
+ end_line=insert_idx + 1 + n,
103
+ )
104
+ self._shift_spans_after(insert_after.source_span.end_line, n + 1)
105
+
106
+ self.journal.transactions.append(txn)
107
+ self.journal.transactions.sort(
108
+ key=lambda t: (t.date, t.source_span.start_line if t.source_span else 0)
109
+ )
110
+ self.dirty = True
111
+
112
+ def update_transaction(self, original: Transaction, updated: Transaction) -> None:
113
+ """Replace the lines occupied by original with the serialised form of updated.
114
+
115
+ Refreshes all source_span values for transactions whose line numbers
116
+ shifted. Sets dirty=True.
117
+ """
118
+ span = original.source_span
119
+ if span is None:
120
+ raise ValueError("update_transaction: original has no source_span")
121
+
122
+ new_lines = transaction_to_text(updated).splitlines()
123
+ old_count = span.end_line - span.start_line + 1
124
+ delta = len(new_lines) - old_count
125
+
126
+ # Replace lines in-place (start_line is 1-based → index = start_line - 1)
127
+ self.lines[span.start_line - 1 : span.end_line] = new_lines
128
+
129
+ updated.source_span = SourceSpan(
130
+ file=span.file,
131
+ start_line=span.start_line,
132
+ end_line=span.start_line + len(new_lines) - 1,
133
+ )
134
+
135
+ # Replace in journal.transactions
136
+ idx = self.journal.transactions.index(original)
137
+ self.journal.transactions[idx] = updated
138
+
139
+ # Shift all transactions that come after the modified block
140
+ if delta != 0:
141
+ self._shift_spans_after(span.end_line, delta)
142
+
143
+ self.dirty = True
144
+
145
+ def delete_transaction(self, txn: Transaction) -> None:
146
+ """Remove the lines occupied by txn and any immediately following blank line.
147
+
148
+ Refreshes source_span values. Sets dirty=True.
149
+ """
150
+ span = txn.source_span
151
+ if span is None:
152
+ raise ValueError("delete_transaction: txn has no source_span")
153
+
154
+ # end is the 0-based exclusive end index for slicing
155
+ start_idx = span.start_line - 1 # inclusive, 0-based
156
+ end_idx = span.end_line # exclusive, 0-based (span.end_line is 1-based inclusive)
157
+
158
+ # Also consume a trailing blank separator line if present
159
+ if end_idx < len(self.lines) and self.lines[end_idx] == "":
160
+ end_idx += 1
161
+
162
+ removed = end_idx - start_idx
163
+ del self.lines[start_idx:end_idx]
164
+
165
+ self.journal.transactions.remove(txn)
166
+ self._shift_spans_after(span.start_line, -removed)
167
+
168
+ self.dirty = True
169
+
170
+ def save(self) -> None:
171
+ """Write self.lines to self.path; set dirty=False."""
172
+ content = "\n".join(self.lines)
173
+ if content and not content.endswith("\n"):
174
+ content += "\n"
175
+ Path(self.path).write_text(content, encoding="utf-8")
176
+ self.dirty = False
177
+
178
+ def reload(self) -> None:
179
+ """Re-read self.path from disk and re-parse.
180
+
181
+ Replaces self.journal and self.lines. Resets dirty=False.
182
+ """
183
+ text = Path(self.path).read_text(encoding="utf-8")
184
+ self.lines = text.splitlines()
185
+ self.journal = parse_string(text, source_file=self.path)
186
+ self.dirty = False
187
+
188
+ def validate_transaction(self, txn: Transaction) -> list[CheckError]:
189
+ """Run the autobalanced check on txn alone.
190
+
191
+ Returns a (possibly empty) list of CheckError. Does not raise.
192
+ """
193
+ return check_transaction_autobalanced(txn)