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.
- ledgerkit/__init__.py +51 -0
- ledgerkit/__main__.py +12 -0
- ledgerkit/_pandas_compat.py +21 -0
- ledgerkit/checks.py +617 -0
- ledgerkit/cli.py +448 -0
- ledgerkit/commodity_style.py +274 -0
- ledgerkit/editor_model.py +193 -0
- ledgerkit/loader.py +311 -0
- ledgerkit/models.py +459 -0
- ledgerkit/parser.py +1547 -0
- ledgerkit/reports.py +573 -0
- ledgerkit/writer.py +97 -0
- ledgerkit-1.0.0.dev1.dist-info/METADATA +203 -0
- ledgerkit-1.0.0.dev1.dist-info/RECORD +18 -0
- ledgerkit-1.0.0.dev1.dist-info/WHEEL +5 -0
- ledgerkit-1.0.0.dev1.dist-info/entry_points.txt +2 -0
- ledgerkit-1.0.0.dev1.dist-info/licenses/LICENSE +21 -0
- ledgerkit-1.0.0.dev1.dist-info/top_level.txt +1 -0
|
@@ -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)
|