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 +106 -0
- extf/_errors.py +33 -0
- extf/_format.py +65 -0
- extf/_reader.py +880 -0
- extf/_writer.py +565 -0
- extf/py.typed +0 -0
- extf-0.1.0.dist-info/METADATA +168 -0
- extf-0.1.0.dist-info/RECORD +12 -0
- extf-0.1.0.dist-info/WHEEL +5 -0
- extf-0.1.0.dist-info/licenses/LICENSE +202 -0
- extf-0.1.0.dist-info/licenses/NOTICE +9 -0
- extf-0.1.0.dist-info/top_level.txt +1 -0
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)
|