extf 0.1.0__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.
extf/__init__.py ADDED
@@ -0,0 +1,106 @@
1
+ """extf: read and write files in the DATEV format (EXTF).
2
+
3
+ Not affiliated with or endorsed by DATEV eG. Everything public is exported here; any other
4
+ module is private.
5
+ """
6
+
7
+ from extf._errors import DatevFormatError, ExtfError, LineError
8
+ from extf._format import (
9
+ ACCOUNT_LABELS,
10
+ BOOKINGS,
11
+ CATEGORY_NAMES,
12
+ DATEV_ENCODING,
13
+ PERSONAL_ACCOUNTS,
14
+ fiscal_year_end,
15
+ )
16
+ from extf._reader import (
17
+ FORMAT_VERSIONS,
18
+ MAX_COLUMNS,
19
+ MAX_FIELD_LENGTH,
20
+ MAX_HEADER_SEARCH,
21
+ MAX_INPUT_BYTES,
22
+ MAX_LINES,
23
+ AccountLabel,
24
+ Balance,
25
+ Booking,
26
+ BookingFile,
27
+ FixedAsset,
28
+ Header,
29
+ PersonalAccount,
30
+ is_datev_format,
31
+ parse_amount,
32
+ read_account_labels,
33
+ read_bookings,
34
+ read_fixed_assets,
35
+ read_personal_accounts,
36
+ read_trial_balance,
37
+ )
38
+ from extf._writer import (
39
+ BOOKING_COLUMNS,
40
+ CATEGORY_VERSIONS,
41
+ FORMAT_VERSION,
42
+ LABEL_COLUMNS,
43
+ PERSONAL_COLUMNS,
44
+ ExportHeader,
45
+ Label,
46
+ Personal,
47
+ Record,
48
+ amount_de,
49
+ clean_text,
50
+ document_number,
51
+ validate,
52
+ write_account_labels,
53
+ write_bookings,
54
+ write_personal_accounts,
55
+ )
56
+
57
+ __version__ = "0.1.0"
58
+
59
+ __all__ = [
60
+ "ACCOUNT_LABELS",
61
+ "BOOKINGS",
62
+ "BOOKING_COLUMNS",
63
+ "CATEGORY_NAMES",
64
+ "CATEGORY_VERSIONS",
65
+ "DATEV_ENCODING",
66
+ "FORMAT_VERSION",
67
+ "FORMAT_VERSIONS",
68
+ "LABEL_COLUMNS",
69
+ "MAX_COLUMNS",
70
+ "MAX_FIELD_LENGTH",
71
+ "MAX_HEADER_SEARCH",
72
+ "MAX_INPUT_BYTES",
73
+ "MAX_LINES",
74
+ "PERSONAL_ACCOUNTS",
75
+ "PERSONAL_COLUMNS",
76
+ "AccountLabel",
77
+ "Balance",
78
+ "Booking",
79
+ "BookingFile",
80
+ "DatevFormatError",
81
+ "ExportHeader",
82
+ "ExtfError",
83
+ "FixedAsset",
84
+ "Header",
85
+ "Label",
86
+ "LineError",
87
+ "Personal",
88
+ "PersonalAccount",
89
+ "Record",
90
+ "__version__",
91
+ "amount_de",
92
+ "clean_text",
93
+ "document_number",
94
+ "fiscal_year_end",
95
+ "is_datev_format",
96
+ "parse_amount",
97
+ "read_account_labels",
98
+ "read_bookings",
99
+ "read_fixed_assets",
100
+ "read_personal_accounts",
101
+ "read_trial_balance",
102
+ "validate",
103
+ "write_account_labels",
104
+ "write_bookings",
105
+ "write_personal_accounts",
106
+ ]
extf/_errors.py ADDED
@@ -0,0 +1,33 @@
1
+ """The exceptions of the library: every refusal names each bad line it found."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class LineError:
11
+ """A bad line of a file: which file, which line (1 = the first), what is wrong."""
12
+
13
+ file: str
14
+ line: int
15
+ message: str
16
+
17
+ def __str__(self) -> str:
18
+ return f"{self.file}, line {self.line}: {self.message}"
19
+
20
+
21
+ class ExtfError(Exception):
22
+ """The base of every refusal; ``errors`` names every bad line. :func:`extf.validate` and
23
+ the writers raise it, the readers its subclass :class:`DatevFormatError`."""
24
+
25
+ def __init__(self, errors: Sequence[LineError]) -> None:
26
+ self.errors = list(errors)
27
+ shown = "; ".join(str(error) for error in self.errors[:5])
28
+ more = f" (and {len(self.errors) - 5} more)" if len(self.errors) > 5 else ""
29
+ super().__init__(f"{len(self.errors)} bad line(s): {shown}{more}")
30
+
31
+
32
+ class DatevFormatError(ExtfError):
33
+ """A file that is not what it claims to be."""
extf/_format.py ADDED
@@ -0,0 +1,65 @@
1
+ """What the reader and the writer share: the constants of the format and the amount range."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, timedelta
6
+ from decimal import ROUND_HALF_UP, Context, Decimal
7
+
8
+ #: The encoding of the DATEV format.
9
+ DATEV_ENCODING = "cp1252"
10
+ BOOKINGS, ACCOUNT_LABELS, PERSONAL_ACCOUNTS = 21, 20, 16
11
+ CATEGORY_NAMES = {BOOKINGS: "Buchungsstapel", ACCOUNT_LABELS: "Kontenbeschriftungen", PERSONAL_ACCOUNTS: "Debitoren/Kreditoren"}
12
+ #: The length of a general ledger account number a header may state (Sachkontenlänge).
13
+ ACCOUNT_LENGTHS = (4, 8)
14
+ #: The length the CSV lists are read with unless the caller says otherwise: they have no header.
15
+ DEFAULT_ACCOUNT_LENGTH = 4
16
+
17
+ #: The arithmetic of amounts, independent of the caller's decimal context.
18
+ MONEY_CONTEXT = Context(prec=40, rounding=ROUND_HALF_UP)
19
+ CENT = Decimal("0.01")
20
+ #: The range of an amount read: its absolute value lies below this.
21
+ AMOUNT_LIMIT = Decimal(10) ** 14
22
+ #: The correction digit (Berichtigungsschlüssel) of a two-digit BU-Schlüssel that marks a
23
+ #: Generalumkehr: ``29`` is the cancellation of an entry with tax key 9. Unproven: the
24
+ #: exports seen so far mark a Generalumkehr in its column.
25
+ REVERSAL_CORRECTION_DIGIT = "2"
26
+
27
+
28
+ def round_money(value: Decimal) -> Decimal:
29
+ """``value`` rounded commercially (half up) to cents."""
30
+ return value.quantize(CENT, context=MONEY_CONTEXT)
31
+
32
+
33
+ def is_reversal_key(key: str) -> bool:
34
+ """Whether the BU-Schlüssel ``key`` is the old notation of a Generalumkehr. The reader
35
+ takes such a key as one, so the writer refuses it: it would not come back as written."""
36
+ return len(key) == 2 and key[0] == REVERSAL_CORRECTION_DIGIT
37
+
38
+
39
+ def is_utf8(data: bytes) -> bool:
40
+ """Whether ``data`` is UTF-8 beyond ASCII: as Windows-1252 it would read as mojibake. The
41
+ reader refuses such a file, so the writer refuses to write one."""
42
+ if data.isascii():
43
+ return False
44
+ try:
45
+ data.decode("utf-8")
46
+ except UnicodeDecodeError:
47
+ return False
48
+ return True
49
+
50
+
51
+ def fiscal_year_end(start: date) -> date:
52
+ """The last day of the fiscal year that starts on ``start`` (one starting on 29.02
53
+ ends on the last day of February of the next year).
54
+
55
+ Raises :class:`ValueError` for a fiscal year that would end after year 9999, the end of
56
+ ``date``: that is every start in year 9999 but 01.01."""
57
+ if start.year == date.max.year:
58
+ if (start.month, start.day) == (1, 1):
59
+ return date.max
60
+ raise ValueError(f"the fiscal year starting on {start.isoformat()} would end after year {date.max.year}")
61
+ try:
62
+ following = date(start.year + 1, start.month, start.day)
63
+ except ValueError:
64
+ following = date(start.year + 1, 3, 1)
65
+ return following - timedelta(days=1)