beancount-format 0.0.1__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.
@@ -0,0 +1,116 @@
1
+ node_modules/
2
+ .task/
3
+
4
+ # Created by .ignore support plugin (hsz.mobi)
5
+ ### Python template
6
+ # Byte-compiled / optimized / DLL files
7
+ __pycache__/
8
+ *.py[cod]
9
+ *$py.class
10
+ # C extensions
11
+ *.so
12
+
13
+ # Distribution / packaging
14
+ .Python
15
+ docs/source/_build/
16
+ docs/build/
17
+ build/
18
+ develop-eggs/
19
+ dist/
20
+ downloads/
21
+ eggs/
22
+ .eggs/
23
+ lib/
24
+ lib64/
25
+ parts/
26
+ sdist/
27
+ var/
28
+ wheels/
29
+ *.egg-info/
30
+ .installed.cfg
31
+ *.egg
32
+ MANIFEST
33
+
34
+ # PyInstaller
35
+ # Usually these files are written by a python script from a template
36
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
37
+ *.manifest
38
+ *.spec
39
+
40
+ # Installer logs
41
+ pip-log.txt
42
+ pip-delete-this-directory.txt
43
+ pip-wheel-metadata
44
+
45
+ # Unit test / coverage reports
46
+ htmlcov/
47
+ .tox/
48
+ .coverage
49
+ .coverage.*
50
+ .cache
51
+ nosetests.xml
52
+ coverage.xml
53
+ *.cover
54
+ .hypothesis/
55
+ .pytest_cache/
56
+
57
+ # Translations
58
+ *.mo
59
+ *.pot
60
+
61
+ # Django stuff:
62
+ *.log
63
+ local_settings.py
64
+ db.sqlite3
65
+
66
+ # Flask stuff:
67
+ instance/
68
+ .webassets-cache
69
+
70
+ # Scrapy stuff:
71
+ .scrapy
72
+
73
+ # Sphinx documentation
74
+ docs/_build/
75
+
76
+ # PyBuilder
77
+ target/
78
+
79
+ # Jupyter Notebook
80
+ .ipynb_checkpoints
81
+
82
+ # pyenv
83
+ .python-version
84
+
85
+ # celery beat schedule file
86
+ celerybeat-schedule
87
+
88
+ # SageMath parsed files
89
+ *.sage.py
90
+
91
+ # Environments
92
+ .env
93
+ .venv
94
+ env/
95
+ venv/
96
+ ENV/
97
+ env.bak/
98
+ venv.bak/
99
+
100
+ # Spyder project settings
101
+ .spyderproject
102
+ .spyproject
103
+
104
+ # Rope project settings
105
+ .ropeproject
106
+
107
+ # mkdocs documentation
108
+ /site
109
+
110
+ # mypy
111
+ .mypy_cache/
112
+
113
+ # IDE
114
+ .vscode/
115
+ .idea/
116
+ main.py
@@ -0,0 +1,41 @@
1
+ default_stages: [commit]
2
+
3
+ repos:
4
+ - repo: https://github.com/abravalheri/validate-pyproject
5
+ rev: v0.19
6
+ hooks:
7
+ - id: validate-pyproject
8
+ # Optional extra validations from SchemaStore:
9
+ additional_dependencies: [ "validate-pyproject-schema-store[all]" ]
10
+
11
+ - repo: https://github.com/pre-commit/pre-commit-hooks
12
+ rev: v4.6.0
13
+ hooks:
14
+ - id: check-case-conflict
15
+ - id: check-ast
16
+ - id: check-builtin-literals
17
+ - id: check-toml
18
+ - id: check-yaml
19
+ - id: check-json
20
+ - id: check-docstring-first
21
+ - id: check-merge-conflict
22
+ - id: check-added-large-files # check for file bigger than 500kb
23
+ - id: debug-statements
24
+ - id: trailing-whitespace
25
+ - id: mixed-line-ending
26
+ args: [--fix=lf]
27
+ - id: end-of-file-fixer
28
+ - id: fix-byte-order-marker
29
+ - id: fix-encoding-pragma
30
+ args: [--remove]
31
+
32
+ - repo: https://github.com/astral-sh/ruff-pre-commit
33
+ rev: v0.6.9
34
+ hooks:
35
+ - id: ruff
36
+ args: [ --fix ]
37
+
38
+ - repo: https://github.com/psf/black
39
+ rev: 24.8.0
40
+ hooks:
41
+ - id: black
@@ -0,0 +1,7 @@
1
+ - id: beancount-format
2
+ name: beancount-format
3
+ description: format beancount code
4
+ entry: beancount-format
5
+ language: python
6
+ pass_filenames: true
7
+ files: ^.*.bean$
@@ -0,0 +1,24 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Trim21 <trim21me@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person
6
+ obtaining a copy of this software and associated documentation
7
+ files (the "Software"), to deal in the Software without
8
+ restriction, including without limitation the rights to use,
9
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the
11
+ Software is furnished to do so, subject to the following
12
+ conditions:
13
+
14
+ The above copyright notice and this permission notice shall be
15
+ included in all copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
19
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
21
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
22
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
24
+ OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.1
2
+ Name: beancount-format
3
+ Version: 0.0.1
4
+ Summary: Typed rtorrent rpc client
5
+ Keywords: rtorrent,rpc
6
+ Author-email: trim21 <trim21me@gmail.com>
7
+ Requires-Python: ~=3.9
8
+ Description-Content-Type: text/markdown
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Requires-Dist: beancount-parser==1.2.3
14
+ Requires-Dist: click~=8.0
15
+ Requires-Dist: pytest==8.3.2 ; extra == "dev"
16
+ Requires-Dist: pytest-github-actions-annotate-failures==0.2.0 ; extra == "dev"
17
+ Requires-Dist: coverage==7.6.1 ; extra == "dev"
18
+ Requires-Dist: pre-commit==3.8.0 ; extra == "dev" and ( python_version >= "3.9")
19
+ Requires-Dist: mypy==1.13.0 ; extra == "dev" and ( python_version >= "3.9")
20
+ Project-URL: Homepage, https://github.com/trim21/beancount-format
21
+ Provides-Extra: dev
22
+
23
+ format beancount
24
+
25
+ as pre-commit hooks
26
+
27
+ ```yaml
28
+ repos:
29
+ - repo: https://github.com/trim21/beancount-format
30
+ rev: 801ab26
31
+ hooks:
32
+ - id: beancount-format
33
+ ```
File without changes
@@ -0,0 +1,4 @@
1
+ from beancount_format.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,48 @@
1
+ import io
2
+ import pathlib
3
+ import sys
4
+ from collections.abc import Generator, Sequence
5
+
6
+ import click
7
+ from beancount_parser.parser import make_parser
8
+
9
+ from beancount_format.format import Formatter
10
+
11
+
12
+ def __input_files(paths: Sequence[pathlib.Path]) -> Generator[pathlib.Path, None, None]:
13
+ for p in paths:
14
+ if p.is_file():
15
+ yield p
16
+ else:
17
+ yield from __input_files(p.iterdir())
18
+
19
+
20
+ @click.command
21
+ @click.argument("path", nargs=-1, type=click.Path(path_type=pathlib.Path))
22
+ @click.option("--indent", default=4, type=int)
23
+ def main(path, indent: int):
24
+ parser = make_parser()
25
+ formatter = Formatter(indent_width=indent)
26
+
27
+ exit_code = 0
28
+
29
+ for file in __input_files(path):
30
+ if file.suffix.lower() not in {".bean"}:
31
+ continue
32
+ try:
33
+ input_content = file.read_text(encoding="utf-8")
34
+ tree = parser.parse(input_content)
35
+ output_file = io.StringIO()
36
+ with output_file:
37
+ formatter.format(tree, output_file)
38
+ formatted = output_file.getvalue()
39
+ if input_content == formatted:
40
+ continue
41
+ print("formatting", file)
42
+ file.write_bytes(formatted.encode("utf-8"))
43
+ exit_code = 1
44
+ except Exception as e:
45
+ print("failed to format file", file, e)
46
+ return 1
47
+
48
+ return sys.exit(exit_code)
@@ -0,0 +1,664 @@
1
+ import datetime
2
+ import decimal
3
+ import enum
4
+ import io
5
+ import logging
6
+ import re
7
+ import typing
8
+
9
+ from lark import ParseTree, Token, Tree
10
+
11
+ VERBOSE_LOG_LEVEL = logging.NOTSET + 1
12
+
13
+ COMMENT_PREFIX = re.compile("[;*]+")
14
+ DEFAULT_INDENT_WIDTH = 2
15
+ DEFAULT_ACCOUNT_WIDTH = 30
16
+ DEFAULT_NUMBER_WIDTH = 12
17
+ # the difference of column width we need to make up for balance account field,
18
+ # so a balance statement starts with
19
+ #
20
+ # "2022-01-01 balance "
21
+ #
22
+ BALANCE_PREFIX_WIDTH = 19
23
+
24
+
25
+ @enum.unique
26
+ class EntryType(enum.Enum):
27
+ # Date directives
28
+ OPEN = "OPEN"
29
+ CLOSE = "CLOSE"
30
+ BALANCE = "BALANCE"
31
+ EVENT = "EVENT"
32
+ COMMODITY = "COMMODITY"
33
+ DOCUMENT = "DOCUMENT"
34
+ PRICE = "PRICE"
35
+ NOTE = "NOTE"
36
+ PAD = "PAD"
37
+ CUSTOM = "CUSTOM"
38
+ TXN = "TXN"
39
+ # Simple directives
40
+ OPTION = "OPTION"
41
+ INCLUDE = "INCLUDE"
42
+ PLUGIN = "PLUGIN"
43
+ # Other
44
+ COMMENTS = "COMMENTS"
45
+
46
+
47
+ # The entries which are going to be listed in groups before all other entries
48
+ LEADING_ENTRY_TYPES: typing.List[EntryType] = [
49
+ EntryType.COMMENTS,
50
+ EntryType.INCLUDE,
51
+ EntryType.PLUGIN,
52
+ EntryType.OPTION,
53
+ EntryType.COMMODITY,
54
+ EntryType.OPEN,
55
+ EntryType.CLOSE,
56
+ ]
57
+
58
+ DATE_DIRECTIVE_ENTRY_TYPES = {
59
+ "open": EntryType.OPEN,
60
+ "close": EntryType.CLOSE,
61
+ "balance": EntryType.BALANCE,
62
+ "event": EntryType.EVENT,
63
+ "commodity": EntryType.COMMODITY,
64
+ "document": EntryType.DOCUMENT,
65
+ "price": EntryType.PRICE,
66
+ "note": EntryType.NOTE,
67
+ "pad": EntryType.PAD,
68
+ "custom": EntryType.CUSTOM,
69
+ "txn": EntryType.TXN,
70
+ }
71
+
72
+ SIMPLE_DIRECTIVE_ENTRY_TYPES = {
73
+ "option": EntryType.OPTION,
74
+ "include": EntryType.INCLUDE,
75
+ "plugin": EntryType.PLUGIN,
76
+ }
77
+
78
+
79
+ def get_entry_type(statement: Tree) -> EntryType:
80
+ first_child: Tree = statement.children[0]
81
+ if first_child.data == "date_directive":
82
+ return DATE_DIRECTIVE_ENTRY_TYPES[first_child.children[0].data.value]
83
+ if first_child.data == "simple_directive":
84
+ return SIMPLE_DIRECTIVE_ENTRY_TYPES[first_child.children[0].data.value]
85
+ raise ValueError(f"Unexpected first child type {first_child.data}")
86
+
87
+
88
+ class StatementGroup(typing.NamedTuple):
89
+ section_header: typing.Optional[Token]
90
+ statements: typing.List[Tree]
91
+
92
+
93
+ class Metadata(typing.NamedTuple):
94
+ comments: typing.List[Token]
95
+ statement: Tree
96
+
97
+
98
+ class Posting(typing.NamedTuple):
99
+ comments: typing.List[Token]
100
+ statement: Tree
101
+ metadata: typing.List[Metadata]
102
+
103
+
104
+ class Entry(typing.NamedTuple):
105
+ type: EntryType
106
+ comments: typing.List[Token]
107
+ statement: Tree
108
+ metadata: typing.List[Metadata]
109
+ postings: typing.List[Posting]
110
+
111
+
112
+ def parse_date(date_str: str) -> datetime.date:
113
+ parts = date_str.split("-")
114
+ return datetime.date(*(map(int, parts)))
115
+
116
+
117
+ class Collector:
118
+ def __init__(self, logger: typing.Optional[logging.Logger] = None):
119
+ super().__init__()
120
+ self.logger = logger or logging.getLogger(__name__)
121
+ # Collection of the header comments
122
+ self.header_comments: typing.List[Token] = []
123
+ self.statement_groups: typing.List[StatementGroup] = []
124
+
125
+ def collect(self, tree: Tree):
126
+ self.logger.info("Collecting")
127
+ if tree.data != "start":
128
+ raise ValueError("Expected start")
129
+ for child in tree.children:
130
+ if child is None:
131
+ continue
132
+ self.statement(child)
133
+
134
+ def statement(self, tree: Tree):
135
+ if tree.data != "statement":
136
+ raise ValueError("Expected statement")
137
+ self.logger.debug("Collecting statement at line %s", tree.meta.line)
138
+ first_child = tree.children[0]
139
+ if isinstance(first_child, Token):
140
+ # Comment only line
141
+ if first_child.type == "COMMENT":
142
+ if self.comment_token(first_child):
143
+ # already added as part of the header comments, just return
144
+ return
145
+ elif first_child.type == "SECTION_HEADER":
146
+ self.section_header_token(first_child)
147
+ return
148
+ else:
149
+ raise ValueError("Unexpected token type %s", first_child.type)
150
+ if not self.statement_groups:
151
+ self.statement_groups.append(
152
+ StatementGroup(section_header=None, statements=[])
153
+ )
154
+ self.statement_groups[-1].statements.append(tree)
155
+
156
+ def section_header_token(self, token: Token):
157
+ self.logger.debug(
158
+ "New statement group for %r at line %s", token.value, token.line
159
+ )
160
+ self.statement_groups.append(
161
+ StatementGroup(section_header=token, statements=[])
162
+ )
163
+
164
+ def comment_token(self, token: Token) -> bool:
165
+ if token.line != len(self.header_comments) + 1:
166
+ return False
167
+ if self.statement_groups:
168
+ return False
169
+ self.logger.debug("Collect header comment %s at line %s", token, token.line)
170
+ self.header_comments.append(token)
171
+ return True
172
+
173
+
174
+ def chunks(lst, n):
175
+ """Yield successive n-sized chunks from lst."""
176
+ for i in range(0, len(lst), n):
177
+ yield lst[i : i + n]
178
+
179
+
180
+ def format_number(d: decimal.Decimal, n: int = 3):
181
+ ss = str(d)
182
+
183
+ a, _, b = ss.partition(".")
184
+
185
+ parts = []
186
+
187
+ for part in chunks(a[::-1], n):
188
+ parts.append("".join(reversed(part)))
189
+
190
+ parts.reverse()
191
+
192
+ a = ",".join(parts)
193
+
194
+ if b:
195
+ return a + "." + b
196
+
197
+ return a
198
+
199
+
200
+ class Formatter:
201
+ def __init__(
202
+ self,
203
+ indent_width: int = DEFAULT_INDENT_WIDTH,
204
+ min_account_width: int = DEFAULT_ACCOUNT_WIDTH,
205
+ min_number_width: int = DEFAULT_NUMBER_WIDTH,
206
+ # num_sep_width: int = 3,
207
+ logger: typing.Optional[logging.Logger] = None,
208
+ ):
209
+ self.indent_width: int = indent_width
210
+ self.account_width: int = min_account_width
211
+ self.number_width: int = min_number_width
212
+ # self.num_sep_width: int = num_sep_width
213
+ self.logger = logger or logging.getLogger(__name__)
214
+
215
+ def format_comment(self, token: Token) -> str:
216
+ value = token.value.strip()
217
+ match = COMMENT_PREFIX.match(value)
218
+ prefix = match.group(0)
219
+ remain = value[len(prefix) :].strip()
220
+ if not remain:
221
+ return prefix
222
+ return f"{prefix} {remain}"
223
+
224
+ def format_number(self, token: Token) -> str:
225
+ if token.type != "NUMBER":
226
+ raise ValueError("Expected a NUMBER")
227
+ value = token.value.replace(",", "")
228
+
229
+ return str(decimal.Decimal(value))
230
+
231
+ def format_number_atom(self, tree_or_token: typing.Union[Tree, Token]) -> str:
232
+ if isinstance(tree_or_token, Token):
233
+ token: Token = tree_or_token
234
+ if token.type == "NUMBER":
235
+ return self.format_number(token)
236
+ raise ValueError(f"Unknown token type {token.type}")
237
+ if isinstance(tree_or_token, Tree):
238
+ tree: Tree = tree_or_token
239
+ if tree.data == "number_atom":
240
+ unary_op, number_atom = tree.children
241
+ if unary_op.type != "UNARY_OP":
242
+ raise ValueError(f"Expected to be UNARY_OP but got {unary_op.data}")
243
+ return unary_op.value + self.format_number_atom(number_atom)
244
+ if tree.data == "number_mul_expr":
245
+ return self.format_number_mul_expr(tree)
246
+ if tree.data == "number_add_expr":
247
+ return self.format_number_add_expr(tree)
248
+ raise ValueError(f"Unknown tree {tree.data}")
249
+ raise ValueError(f"Unexpected type {type(tree_or_token)}")
250
+
251
+ def format_number_mul_expr(self, tree: Tree) -> str:
252
+ if tree.data != "number_mul_expr":
253
+ raise ValueError("Expected a number_mul_expr")
254
+ items: typing.List[str] = []
255
+ for child in tree.children:
256
+ if isinstance(child, Token):
257
+ if child.type == "MUL_OP":
258
+ items.append(f" {child.value} ")
259
+ else:
260
+ items.append(self.format_number_atom(child))
261
+ else:
262
+ items.append(self.format_number_atom(child))
263
+ return f'({"".join(items)})'
264
+
265
+ def format_number_add_expr(self, tree: Tree) -> str:
266
+ if tree.data != "number_add_expr":
267
+ raise ValueError("Expected a number_add_expr")
268
+ items: typing.List[str] = []
269
+ for child in tree.children:
270
+ if isinstance(child, Token):
271
+ if child.type == "ADD_OP":
272
+ items.append(f" {child.value} ")
273
+ else:
274
+ items.append(self.format_number_atom(child))
275
+ elif isinstance(child, Tree) and child.data == "number_atom":
276
+ items.append(self.format_number_atom(child))
277
+ else:
278
+ items.append(self.format_number_mul_expr(child))
279
+ return f'({"".join(items)})'
280
+
281
+ def format_number_expr(self, tree: Tree) -> str:
282
+ if tree.data != "number_expr":
283
+ raise ValueError("Expected a number_expr")
284
+ first_child: typing.Union[Tree, Token] = tree.children[0]
285
+ if isinstance(first_child, Tree) and first_child.data == "number_add_expr":
286
+ return self.format_number_add_expr(first_child)
287
+ return self.format_number_atom(first_child)
288
+
289
+ def get_amount_columns(self, tree: Tree) -> typing.List[str]:
290
+ if tree.data != "amount":
291
+ raise ValueError("Expected a amount")
292
+ number, currency = tree.children
293
+ return [self.format_number_expr(number), currency.value]
294
+
295
+ def get_amount_tolerance_columns(self, tree: Tree) -> typing.List[str]:
296
+ if tree.data != "amount_tolerance":
297
+ raise ValueError("Expected a amount")
298
+ number, tolerance, currency = tree.children
299
+ return [
300
+ self.format_number_expr(number),
301
+ "~",
302
+ self.format_number_expr(tolerance),
303
+ currency.value,
304
+ ]
305
+
306
+ def format_price(self, tree: Tree) -> str:
307
+ if tree.data not in {"per_unit_price", "total_price"}:
308
+ raise ValueError("Expected a per_unit_price or total_price")
309
+ amount = tree.children[0]
310
+ amount_value = " ".join(self.get_amount_columns(amount))
311
+ if tree.data == "per_unit_price":
312
+ prefix = "@"
313
+ elif tree.data == "total_price":
314
+ prefix = "@@"
315
+ else:
316
+ raise ValueError
317
+ return " ".join([prefix, amount_value])
318
+
319
+ def format_cost_item(self, tree: Tree) -> str:
320
+ if tree.data != "cost_item":
321
+ raise ValueError("Expected a cost_item")
322
+ child = tree.children[0]
323
+ if isinstance(child, Token) and child.type in {
324
+ "DATE",
325
+ "ESCAPED_STRING",
326
+ "ASTERISK",
327
+ }:
328
+ return child.value
329
+ if child.data == "amount":
330
+ return " ".join(self.get_amount_columns(child))
331
+ raise ValueError(f"Unexpected cost item {tree}")
332
+
333
+ def format_cost(self, tree: Tree) -> str:
334
+ if tree.data in {"per_unit_cost", "dated_cost"}:
335
+ raise RuntimeError(
336
+ "You are using an out-dated beancount-parser, version >= 1.0.0 is required"
337
+ )
338
+ if tree.data not in {"total_cost", "both_cost", "cost_spec"}:
339
+ raise ValueError("Expected a total_cost, both_cost or cost_spec")
340
+ if tree.data != "total_cost":
341
+ bracket_start = "{"
342
+ bracket_end = "}"
343
+ else:
344
+ bracket_start = "{{"
345
+ bracket_end = "}}"
346
+ separator = " "
347
+ items: typing.List[str] = []
348
+ if tree.data == "total_cost":
349
+ amount = tree.children[0]
350
+ amount_value = " ".join(self.get_amount_columns(amount))
351
+ items.append(amount_value)
352
+ if tree.data == "both_cost":
353
+ number, amount = tree.children
354
+ number_value = self.format_number_expr(number)
355
+ amount_value = " ".join(self.get_amount_columns(amount))
356
+ items.append(number_value)
357
+ items.append("#")
358
+ items.append(amount_value)
359
+ elif tree.data == "cost_spec":
360
+ separator = ", "
361
+ items.extend(map(self.format_cost_item, tree.children))
362
+ return bracket_start + separator.join(items) + bracket_end
363
+
364
+ def get_directive_child_columns(
365
+ self, child: typing.Union[Token, Tree]
366
+ ) -> typing.List[str]:
367
+ if isinstance(child, Token):
368
+ # TODO: some token may need reformat?
369
+ return [child.value]
370
+ tree: Tree = child
371
+ if tree.data == "currencies":
372
+ return [",".join(currency.value for currency in tree.children)]
373
+ if tree.data == "amount":
374
+ return self.get_amount_columns(tree)
375
+ if tree.data == "amount_tolerance":
376
+ return self.get_amount_tolerance_columns(tree)
377
+ if tree.data == "number_expr":
378
+ return [self.format_number_expr(tree)]
379
+ raise ValueError(f"Unknown tree type {tree.data}")
380
+
381
+ def format_metadata_item_value(
382
+ self, tree_or_token: typing.Union[Tree, Token]
383
+ ) -> str:
384
+ if isinstance(tree_or_token, Token):
385
+ token: Token = tree_or_token
386
+ if token.type in {"ESCAPED_STRING", "ACCOUNT", "CURRENCY", "DATE", "TAGS"}:
387
+ return token.value
388
+ raise ValueError(f"Unknown token type {token.type}")
389
+ if isinstance(tree_or_token, Tree):
390
+ tree: Tree = tree_or_token
391
+ if tree.data == "number_expr":
392
+ return self.format_number_expr(tree)
393
+ if tree.data == "amount":
394
+ return " ".join(self.get_amount_columns(tree))
395
+ raise ValueError(f"Unknown tree {tree.data}")
396
+ raise ValueError(f"Unexpected type {type(tree_or_token)}")
397
+
398
+ def format_metadata_item(self, tree: Tree) -> str:
399
+ if tree.data != "metadata_item":
400
+ raise ValueError("Expected a metadata item")
401
+ key_token, value_tree_or_token = tree.children
402
+ return (
403
+ f"{key_token.value}: {self.format_metadata_item_value(value_tree_or_token)}"
404
+ )
405
+
406
+ def format_simple_directive(self, tree: Tree) -> str:
407
+ if tree.data != "simple_directive":
408
+ raise ValueError("Expected a simple directive")
409
+ first_child = tree.children[0]
410
+ items: typing.List[str] = [first_child.data.value] + [
411
+ child.value for child in first_child.children if child is not None
412
+ ]
413
+ return " ".join(items)
414
+
415
+ def format_date_directive(self, tree: Tree) -> str:
416
+ if tree.data != "date_directive":
417
+ raise ValueError("Expected a date directive")
418
+ first_child = tree.children[0]
419
+ date = first_child.children[0].value
420
+ directive_type = first_child.data.value
421
+ if directive_type == "txn":
422
+ columns: typing.List[str] = [date]
423
+ flag, payee, narration, annotations = first_child.children[1:]
424
+ if flag is not None:
425
+ columns.append(flag.value)
426
+ if payee is not None:
427
+ columns.append(payee.value)
428
+ if narration is not None:
429
+ columns.append(narration.value)
430
+ if annotations is not None:
431
+ annotation_values = [
432
+ annotation.value for annotation in annotations.children
433
+ ]
434
+ links = list(filter(lambda v: v.startswith("^"), annotation_values))
435
+ links.sort()
436
+ hashes = list(filter(lambda v: v.startswith("#"), annotation_values))
437
+ hashes.sort()
438
+ columns.extend(links)
439
+ columns.extend(hashes)
440
+ return " ".join(columns)
441
+ columns: typing.List[str] = [date, directive_type]
442
+ for child in first_child.children[1:]:
443
+ if child is None:
444
+ continue
445
+ columns.extend(self.get_directive_child_columns(child))
446
+ if directive_type == "balance":
447
+ for index, column in enumerate(columns):
448
+ prefix = ""
449
+ # account
450
+ if index == 2:
451
+ width = self.account_width
452
+ # number
453
+ elif index == 3:
454
+ width = self.number_width
455
+ prefix = ">"
456
+ else:
457
+ continue
458
+ new_value = f"{column:{prefix}{width}}"
459
+ columns[index] = new_value
460
+ return " ".join(columns)
461
+
462
+ def format_posting(self, tree: Tree) -> str:
463
+ if tree.data != "posting":
464
+ raise ValueError("Expected a posting")
465
+ # Simple posting
466
+ flag: Token
467
+ account: Token
468
+ amount: typing.Optional[Tree] = None
469
+ cost: typing.Optional[Tree] = None
470
+ price: typing.Optional[Tree] = None
471
+ if tree.children[0].data == "detailed_posting":
472
+ flag, account, amount, cost, price = tree.children[0].children
473
+ else:
474
+ flag, account = tree.children[0].children
475
+ items: typing.List[str] = []
476
+ if flag is not None:
477
+ items.append(flag.value)
478
+ account_value = account.value
479
+ # only need to apply width when it's not short posting format
480
+ if amount is not None:
481
+ # Need to add the difference for balance prefix width
482
+ width = self.account_width + (BALANCE_PREFIX_WIDTH - self.indent_width)
483
+ account_value = f"{account_value:{width}}"
484
+ items.append(account_value)
485
+ if amount is not None:
486
+ number, currency = self.get_amount_columns(amount)
487
+ items.append(f"{number:>{self.number_width}}")
488
+ items.append(currency)
489
+ if cost is not None:
490
+ items.append(self.format_cost(cost))
491
+ if price is not None:
492
+ items.append(self.format_price(price))
493
+ return " ".join(items)
494
+
495
+ def format_metadata_lines(
496
+ self, metadata_list: typing.List[Metadata]
497
+ ) -> typing.List[str]:
498
+ lines: typing.List[str] = []
499
+ for metadata in metadata_list:
500
+ for comment in metadata.comments:
501
+ lines.append(self.format_comment(comment))
502
+ line = self.format_metadata_item(metadata.statement.children[0])
503
+ tail_comment = metadata.statement.children[1]
504
+ if tail_comment is not None:
505
+ line += " " + self.format_comment(tail_comment)
506
+ lines.append(line)
507
+ return lines
508
+
509
+ def format_posting_lines(
510
+ self,
511
+ postings: typing.List[Posting],
512
+ ) -> typing.List[str]:
513
+ lines: typing.List[str] = []
514
+ for posting in postings:
515
+ for comment in posting.comments:
516
+ lines.append(self.format_comment(comment))
517
+ line = self.format_posting(posting.statement.children[0])
518
+ tail_comment = posting.statement.children[1]
519
+ if tail_comment is not None:
520
+ line += " " + self.format_comment(tail_comment)
521
+ lines.append(line)
522
+ metadata_lines = self.format_metadata_lines(posting.metadata)
523
+ for metadata_line in metadata_lines:
524
+ lines.append(" " * self.indent_width + metadata_line)
525
+ return lines
526
+
527
+ def format_entry(self, entry: Entry) -> str:
528
+ self.logger.debug(
529
+ "Format entry type %s at line %s",
530
+ entry.type.value,
531
+ entry.statement.meta.line,
532
+ )
533
+ self.logger.log(VERBOSE_LOG_LEVEL, "Entry value %s", entry)
534
+ lines = []
535
+ for comment in entry.comments:
536
+ lines.append(self.format_comment(comment))
537
+
538
+ if entry.type != EntryType.COMMENTS:
539
+ first_child = entry.statement.children[0]
540
+ if first_child.data == "date_directive":
541
+ line = self.format_date_directive(first_child)
542
+ tail_comment = entry.statement.children[1]
543
+ if tail_comment is not None:
544
+ line += " " + self.format_comment(tail_comment)
545
+ lines.append(line)
546
+ metadata_lines = self.format_metadata_lines(entry.metadata)
547
+ for metadata_line in metadata_lines:
548
+ lines.append(" " * self.indent_width + metadata_line)
549
+ posting_lines = self.format_posting_lines(entry.postings)
550
+ for posting_line in posting_lines:
551
+ lines.append(" " * self.indent_width + posting_line)
552
+ else:
553
+ line = self.format_simple_directive(first_child)
554
+ tail_comment = entry.statement.children[1]
555
+ if tail_comment is not None:
556
+ line += " " + self.format_comment(tail_comment)
557
+ lines.append(line)
558
+ return "\n".join(lines)
559
+
560
+ def format_statement_group(self, group: StatementGroup) -> str:
561
+ sections: typing.List[str] = []
562
+ if group.section_header is not None:
563
+ sections.append(self.format_comment(group.section_header))
564
+
565
+ entries: typing.List[Entry] = []
566
+ comments: typing.List[Token] = []
567
+ for statement in group.statements:
568
+ first_child = statement.children[0]
569
+ if isinstance(first_child, Token):
570
+ if first_child.type == "COMMENT":
571
+ comments.append(first_child)
572
+ else:
573
+ raise ValueError(f"Unexpected token {first_child.type}")
574
+ else:
575
+ if first_child.data == "posting":
576
+ last_entry = entries[-1]
577
+ if last_entry.type != EntryType.TXN:
578
+ raise ValueError("Transaction expected")
579
+ last_entry.postings.append(
580
+ Posting(comments=comments, statement=statement, metadata=[])
581
+ )
582
+ comments = []
583
+ continue
584
+ if first_child.data == "metadata_item":
585
+ last_entry = entries[-1]
586
+ metadata = Metadata(comments=comments, statement=statement)
587
+ if last_entry.postings:
588
+ last_posting: Posting = last_entry.postings[-1]
589
+ last_posting.metadata.append(metadata)
590
+ else:
591
+ last_entry.metadata.append(metadata)
592
+ comments = []
593
+ continue
594
+ entry = Entry(
595
+ type=get_entry_type(statement),
596
+ comments=comments,
597
+ statement=statement,
598
+ metadata=[],
599
+ postings=[],
600
+ )
601
+ entries.append(entry)
602
+ comments = []
603
+
604
+ for entry in entries:
605
+ sections.append(self.format_entry(entry))
606
+
607
+ return "\n\n".join(sections)
608
+
609
+ def calculate_column_widths(self, tree: ParseTree):
610
+ self.logger.info("Calculate column width")
611
+ for statement in tree.children:
612
+ if statement is None:
613
+ continue
614
+ self.logger.debug(
615
+ "Calculate column width for statement at line %s", statement.meta.line
616
+ )
617
+ self.logger.log(VERBOSE_LOG_LEVEL, "Statement %s", statement)
618
+ first_child = statement.children[0]
619
+ if isinstance(first_child, Token):
620
+ continue
621
+ account: typing.Optional[Token] = None
622
+ amount: typing.Optional[Tree] = None
623
+ if first_child.data == "posting":
624
+ # Simple posting
625
+ if first_child.children[0].data == "detailed_posting":
626
+ _, account, amount, *_ = first_child.children[0].children
627
+ else:
628
+ _, account = first_child.children[0].children
629
+ elif (
630
+ first_child.data == "date_directive"
631
+ and first_child.children[0].data == "balance"
632
+ ):
633
+ _, account, amount = first_child.children[0].children
634
+ if account is not None and len(account.value) > self.account_width:
635
+ # bump account width
636
+ self.account_width = len(account.value)
637
+ if amount is not None:
638
+ width = len(self.format_number_expr(amount.children[0]))
639
+ self.number_width = max(width, self.number_width)
640
+
641
+ def format(self, tree: ParseTree, output_file: io.TextIOBase):
642
+ if tree.data != "start":
643
+ raise ValueError("expected start as the root rule")
644
+ self.calculate_column_widths(tree)
645
+
646
+ collector = Collector()
647
+ collector.collect(tree)
648
+
649
+ # write header comments
650
+ sections: typing.List[str] = []
651
+ if collector.header_comments:
652
+ lines: typing.List[str] = [
653
+ self.format_comment(header_comment)
654
+ for header_comment in collector.header_comments
655
+ ]
656
+ sections.append("\n".join(lines))
657
+
658
+ for group in collector.statement_groups:
659
+ # Console(emoji=False).print(group)
660
+ sections.append(self.format_statement_group(group))
661
+
662
+ output_file.write("\n\n".join(sections))
663
+ if sections:
664
+ output_file.write("\n")
@@ -0,0 +1,128 @@
1
+ [build-system]
2
+ requires = ["flit-core~=3.9"]
3
+ build-backend = "flit_core.buildapi"
4
+
5
+ [project]
6
+ name = "beancount-format"
7
+ version = "0.0.1"
8
+ description = "Typed rtorrent rpc client"
9
+ authors = [
10
+ { name = "trim21", email = "trim21me@gmail.com" },
11
+ ]
12
+ readme = 'readme.md'
13
+ license = { text = 'MIT' }
14
+ keywords = ['rtorrent', 'rpc']
15
+ classifiers = [
16
+ 'Intended Audience :: Developers',
17
+ 'Development Status :: 4 - Beta',
18
+ 'License :: OSI Approved :: MIT License',
19
+ 'Programming Language :: Python :: 3 :: Only',
20
+ ]
21
+
22
+ requires-python = "~=3.9"
23
+
24
+ dependencies = [
25
+ 'beancount-parser==1.2.3',
26
+ 'click~=8.0',
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ dev = [
31
+ "pytest==8.3.2",
32
+ "pytest-github-actions-annotate-failures==0.2.0",
33
+ "coverage==7.6.1",
34
+ 'pre-commit==3.8.0; python_version >= "3.9"',
35
+ 'mypy==1.13.0; python_version >= "3.9"',
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/trim21/beancount-format"
40
+
41
+ [project.scripts]
42
+ beancount-format = "beancount_format.cli:main"
43
+
44
+ [tool.pytest.ini_options]
45
+ addopts = '-rav -Werror'
46
+
47
+ [tool.mypy]
48
+ python_version = "3.8"
49
+ disallow_untyped_defs = true
50
+ ignore_missing_imports = true
51
+ warn_return_any = false
52
+ warn_unused_configs = true
53
+ show_error_codes = true
54
+
55
+ platform = 'unix'
56
+
57
+ [tool.black]
58
+ target-version = ['py38']
59
+
60
+ [tool.ruff]
61
+ target-version = "py38"
62
+
63
+ [tool.ruff.lint]
64
+ select = [
65
+ "B",
66
+ "C",
67
+ "E",
68
+ "F",
69
+ "G",
70
+ "I",
71
+ "N",
72
+ "Q",
73
+ "S",
74
+ "W",
75
+ "BLE",
76
+ "EXE",
77
+ "ICN",
78
+ "INP",
79
+ "ISC",
80
+ "NPY",
81
+ "PD",
82
+ "PGH",
83
+ "PIE",
84
+ "PL",
85
+ "PT",
86
+ "PYI",
87
+ "RET",
88
+ "RSE",
89
+ "RUF",
90
+ "SIM",
91
+ "SLF",
92
+ "TCH",
93
+ "TID",
94
+ "TRY",
95
+ "YTT",
96
+ ]
97
+
98
+ ignore = [
99
+ 'PLR0911',
100
+ 'INP001',
101
+ 'N806',
102
+ 'N802',
103
+ 'N803',
104
+ 'E501',
105
+ 'BLE001',
106
+ 'RUF002',
107
+ 'S324',
108
+ 'S301',
109
+ 'S314',
110
+ 'S101',
111
+ 'N815',
112
+ 'S104',
113
+ 'C901',
114
+ 'PLR0913',
115
+ 'RUF001',
116
+ 'SIM108',
117
+ 'TCH003',
118
+ 'RUF003',
119
+ 'RET504',
120
+ 'TRY300',
121
+ 'TRY003',
122
+ 'TRY201',
123
+ 'TRY301',
124
+ 'PLR0912',
125
+ 'PLR0915',
126
+ 'PLR2004',
127
+ 'PGH003',
128
+ ]
@@ -0,0 +1,11 @@
1
+ format beancount
2
+
3
+ as pre-commit hooks
4
+
5
+ ```yaml
6
+ repos:
7
+ - repo: https://github.com/trim21/beancount-format
8
+ rev: 801ab26
9
+ hooks:
10
+ - id: beancount-format
11
+ ```
@@ -0,0 +1,25 @@
1
+ version: 3
2
+
3
+ tasks:
4
+ default:
5
+ - black .
6
+ - ruff check . --fix
7
+
8
+ minor:
9
+ cmds:
10
+ - pyproject-bump minor
11
+ - task: bump
12
+
13
+ patch:
14
+ cmds:
15
+ - pyproject-bump micro
16
+ - task: bump
17
+
18
+ bump:
19
+ vars:
20
+ VERSION:
21
+ sh: yq '.project.version' pyproject.toml
22
+ cmds:
23
+ - git add pyproject.toml
24
+ - 'git commit -m "bump: {{.VERSION}}"'
25
+ - 'git tag "v{{.VERSION}}" -m "v{{.VERSION}}"'