xml-auditfile 0.0.1__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.
- xml_auditfile/__init__.py +88 -0
- xml_auditfile/_checks.py +279 -0
- xml_auditfile/_decimal.py +44 -0
- xml_auditfile/_frames.py +147 -0
- xml_auditfile/_json.py +92 -0
- xml_auditfile/_keys.py +4 -0
- xml_auditfile/_resources.py +55 -0
- xml_auditfile/_session.py +756 -0
- xml_auditfile/_spec/diagnostics.json +735 -0
- xml_auditfile/_spec/registry.json +310 -0
- xml_auditfile/_spec/spec.json +1 -0
- xml_auditfile/_spec/standards/xaa/3_2/auditfile.xsd +1147 -0
- xml_auditfile/_spec/standards/xaa/3_2/definition.json +2800 -0
- xml_auditfile/_spec/standards/xaa/3_2/dialects.json +4 -0
- xml_auditfile/_spec/standards/xaa/3_2/iso3166_20170327.xsd +260 -0
- xml_auditfile/_spec/standards/xaa/3_2/iso4217_20170327.xsd +199 -0
- xml_auditfile/_spec/standards/xaa/3_2/manifest.json +2407 -0
- xml_auditfile/_spec/standards/xab/1_0/auditfile.xsd +1457 -0
- xml_auditfile/_spec/standards/xab/1_0/definition.json +3676 -0
- xml_auditfile/_spec/standards/xab/1_0/dialects.json +4 -0
- xml_auditfile/_spec/standards/xab/1_0/manifest.json +3378 -0
- xml_auditfile/_spec/standards/xaf/3_2/auditfile.xsd +1183 -0
- xml_auditfile/_spec/standards/xaf/3_2/definition.json +3443 -0
- xml_auditfile/_spec/standards/xaf/3_2/dialects.json +4 -0
- xml_auditfile/_spec/standards/xaf/3_2/manifest.json +2706 -0
- xml_auditfile/_spec/standards/xaf/4_0_3/auditfile.xsd +349 -0
- xml_auditfile/_spec/standards/xaf/4_0_3/definition.json +1342 -0
- xml_auditfile/_spec/standards/xaf/4_0_3/dialects.json +4 -0
- xml_auditfile/_spec/standards/xaf/4_0_3/manifest.json +960 -0
- xml_auditfile/_spec/standards/xar/1_2/auditfile.xsd +1147 -0
- xml_auditfile/_spec/standards/xar/1_2/definition.json +3709 -0
- xml_auditfile/_spec/standards/xar/1_2/dialects.json +4 -0
- xml_auditfile/_spec/standards/xar/1_2/manifest.json +2306 -0
- xml_auditfile/_spec/standards/xar/2_3_6_1/auditfile.xsd +761 -0
- xml_auditfile/_spec/standards/xar/2_3_6_1/definition.json +4019 -0
- xml_auditfile/_spec/standards/xar/2_3_6_1/dialects.json +4 -0
- xml_auditfile/_spec/standards/xar/2_3_6_1/manifest.json +2091 -0
- xml_auditfile/_spec/standards/xas/2018_1_0/auditfile.xsd +1680 -0
- xml_auditfile/_spec/standards/xas/2018_1_0/definition.json +3441 -0
- xml_auditfile/_spec/standards/xas/2018_1_0/dialects.json +4 -0
- xml_auditfile/_spec/standards/xas/2018_1_0/manifest.json +1573 -0
- xml_auditfile/_spec/standards/xas/2019_1_0/auditfile.xsd +1688 -0
- xml_auditfile/_spec/standards/xas/2019_1_0/definition.json +3079 -0
- xml_auditfile/_spec/standards/xas/2019_1_0/dialects.json +4 -0
- xml_auditfile/_spec/standards/xas/2019_1_0/manifest.json +1573 -0
- xml_auditfile/_spec/standards/xas/2020_1_0/auditfile.xsd +1503 -0
- xml_auditfile/_spec/standards/xas/2020_1_0/definition.json +3301 -0
- xml_auditfile/_spec/standards/xas/2020_1_0/dialects.json +4 -0
- xml_auditfile/_spec/standards/xas/2020_1_0/manifest.json +1618 -0
- xml_auditfile/_spec/standards/xas/2021_1_1/auditfile.xsd +1513 -0
- xml_auditfile/_spec/standards/xas/2021_1_1/definition.json +3309 -0
- xml_auditfile/_spec/standards/xas/2021_1_1/dialects.json +4 -0
- xml_auditfile/_spec/standards/xas/2021_1_1/manifest.json +1618 -0
- xml_auditfile/_spec/standards/xas/2022_1_1/auditfile.xsd +987 -0
- xml_auditfile/_spec/standards/xas/2022_1_1/definition.json +3389 -0
- xml_auditfile/_spec/standards/xas/2022_1_1/dialects.json +4 -0
- xml_auditfile/_spec/standards/xas/2022_1_1/manifest.json +1663 -0
- xml_auditfile/_spec/standards/xas/2023_1_0/auditfile.xsd +1486 -0
- xml_auditfile/_spec/standards/xas/2023_1_0/definition.json +3427 -0
- xml_auditfile/_spec/standards/xas/2023_1_0/dialects.json +4 -0
- xml_auditfile/_spec/standards/xas/2023_1_0/manifest.json +1681 -0
- xml_auditfile/_spec/standards/xas/2024_1_0/auditfile.xsd +1598 -0
- xml_auditfile/_spec/standards/xas/2024_1_0/definition.json +3525 -0
- xml_auditfile/_spec/standards/xas/2024_1_0/dialects.json +4 -0
- xml_auditfile/_spec/standards/xas/2024_1_0/manifest.json +1681 -0
- xml_auditfile/_spec/standards/xas/2025_1_0/auditfile.xsd +1534 -0
- xml_auditfile/_spec/standards/xas/2025_1_0/definition.json +3525 -0
- xml_auditfile/_spec/standards/xas/2025_1_0/dialects.json +4 -0
- xml_auditfile/_spec/standards/xas/2025_1_0/manifest.json +1681 -0
- xml_auditfile/_spec/standards/xas/2026_1_0/auditfile.xsd +1544 -0
- xml_auditfile/_spec/standards/xas/2026_1_0/definition.json +3533 -0
- xml_auditfile/_spec/standards/xas/2026_1_0/dialects.json +4 -0
- xml_auditfile/_spec/standards/xas/2026_1_0/manifest.json +1681 -0
- xml_auditfile/_spec/standards/xas/2027_1_0/auditfile.xsd +1550 -0
- xml_auditfile/_spec/standards/xas/2027_1_0/definition.json +3523 -0
- xml_auditfile/_spec/standards/xas/2027_1_0/dialects.json +4 -0
- xml_auditfile/_spec/standards/xas/2027_1_0/manifest.json +1681 -0
- xml_auditfile/_spec/views/xaf.json +1075 -0
- xml_auditfile/_spec/views/xas.json +407 -0
- xml_auditfile/_streams.py +240 -0
- xml_auditfile/_xml.py +242 -0
- xml_auditfile/auditfile.py +196 -0
- xml_auditfile/custom.py +187 -0
- xml_auditfile/definitions.py +460 -0
- xml_auditfile/diagnostics.py +237 -0
- xml_auditfile/memory.py +103 -0
- xml_auditfile/options.py +132 -0
- xml_auditfile/py.typed +0 -0
- xml_auditfile/rows.py +116 -0
- xml_auditfile/schema.py +280 -0
- xml_auditfile/sinks.py +82 -0
- xml_auditfile/source.py +136 -0
- xml_auditfile/values.py +124 -0
- xml_auditfile/versions.py +312 -0
- xml_auditfile/views.py +521 -0
- xml_auditfile/xsd/__init__.py +6 -0
- xml_auditfile/xsd/_datatypes.py +147 -0
- xml_auditfile/xsd/_regex.py +79 -0
- xml_auditfile/xsd/_schema_set.py +534 -0
- xml_auditfile/xsd/_validator.py +429 -0
- xml_auditfile-0.0.1.dist-info/METADATA +256 -0
- xml_auditfile-0.0.1.dist-info/RECORD +104 -0
- xml_auditfile-0.0.1.dist-info/WHEEL +4 -0
- xml_auditfile-0.0.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Streaming reader for Dutch tax audit files (XAF and related), following auditfile-spec."""
|
|
2
|
+
|
|
3
|
+
from ._json import JsonFormatError
|
|
4
|
+
from .auditfile import Auditfile, AuditfileReader, SinkAbortError
|
|
5
|
+
from .custom import DialectRegistry, ViewRegistry
|
|
6
|
+
from .definitions import (
|
|
7
|
+
AuditfileManifest,
|
|
8
|
+
ColumnType,
|
|
9
|
+
Dialect,
|
|
10
|
+
DialectAction,
|
|
11
|
+
DialectCorrection,
|
|
12
|
+
ElementDefinition,
|
|
13
|
+
LocalizedText,
|
|
14
|
+
ManifestColumn,
|
|
15
|
+
ManifestKey,
|
|
16
|
+
ManifestReference,
|
|
17
|
+
ManifestTable,
|
|
18
|
+
TextLanguage,
|
|
19
|
+
ViewAggregate,
|
|
20
|
+
ViewColumn,
|
|
21
|
+
ViewDefinition,
|
|
22
|
+
ViewJoin,
|
|
23
|
+
ViewJoinCondition,
|
|
24
|
+
ViewOperator,
|
|
25
|
+
ViewParameter,
|
|
26
|
+
ViewValue,
|
|
27
|
+
)
|
|
28
|
+
from .diagnostics import AuditfileReport, Diagnostic, DiagnosticSeverity
|
|
29
|
+
from .memory import MemorySink
|
|
30
|
+
from .options import ReadCancelledError, ReadMode, ReadOptions, ReadProgress
|
|
31
|
+
from .rows import AuditfileRow
|
|
32
|
+
from .schema import AuditfileSchema, SchemaColumn, SchemaTable, VersionSource
|
|
33
|
+
from .sinks import ImportContext, Sink, SinkCapabilities
|
|
34
|
+
from .source import AuditfileSource, PartInfo, SourcePart
|
|
35
|
+
from .versions import AuditfileVersion
|
|
36
|
+
from .views import ViewResult
|
|
37
|
+
from .xsd import XsdError
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"Auditfile",
|
|
41
|
+
"AuditfileManifest",
|
|
42
|
+
"AuditfileReader",
|
|
43
|
+
"AuditfileReport",
|
|
44
|
+
"AuditfileRow",
|
|
45
|
+
"AuditfileSchema",
|
|
46
|
+
"AuditfileSource",
|
|
47
|
+
"AuditfileVersion",
|
|
48
|
+
"ColumnType",
|
|
49
|
+
"Diagnostic",
|
|
50
|
+
"DiagnosticSeverity",
|
|
51
|
+
"Dialect",
|
|
52
|
+
"DialectAction",
|
|
53
|
+
"DialectCorrection",
|
|
54
|
+
"DialectRegistry",
|
|
55
|
+
"ElementDefinition",
|
|
56
|
+
"ImportContext",
|
|
57
|
+
"JsonFormatError",
|
|
58
|
+
"LocalizedText",
|
|
59
|
+
"ManifestColumn",
|
|
60
|
+
"ManifestKey",
|
|
61
|
+
"ManifestReference",
|
|
62
|
+
"ManifestTable",
|
|
63
|
+
"MemorySink",
|
|
64
|
+
"PartInfo",
|
|
65
|
+
"ReadCancelledError",
|
|
66
|
+
"ReadMode",
|
|
67
|
+
"ReadOptions",
|
|
68
|
+
"ReadProgress",
|
|
69
|
+
"SchemaColumn",
|
|
70
|
+
"SchemaTable",
|
|
71
|
+
"Sink",
|
|
72
|
+
"SinkAbortError",
|
|
73
|
+
"SinkCapabilities",
|
|
74
|
+
"SourcePart",
|
|
75
|
+
"TextLanguage",
|
|
76
|
+
"VersionSource",
|
|
77
|
+
"ViewAggregate",
|
|
78
|
+
"ViewColumn",
|
|
79
|
+
"ViewDefinition",
|
|
80
|
+
"ViewJoin",
|
|
81
|
+
"ViewJoinCondition",
|
|
82
|
+
"ViewOperator",
|
|
83
|
+
"ViewParameter",
|
|
84
|
+
"ViewRegistry",
|
|
85
|
+
"ViewResult",
|
|
86
|
+
"ViewValue",
|
|
87
|
+
"XsdError",
|
|
88
|
+
]
|
xml_auditfile/_checks.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Checks that see every row as it is emitted: keys and references, and the XAF business rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
|
|
7
|
+
from ._decimal import add, format_decimal
|
|
8
|
+
from ._keys import SEPARATOR as _SEPARATOR
|
|
9
|
+
from .definitions import ColumnType
|
|
10
|
+
from .diagnostics import DiagnosticCollector, Location
|
|
11
|
+
from .rows import AuditfileRow
|
|
12
|
+
from .schema import AuditfileSchema, SchemaColumn, SchemaTable
|
|
13
|
+
from .values import to_text
|
|
14
|
+
|
|
15
|
+
_MAX_PENDING_PER_REFERENCE = 100_000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _composite(row: AuditfileRow, columns: tuple[int, ...]) -> str | None:
|
|
19
|
+
parts = []
|
|
20
|
+
for column in columns:
|
|
21
|
+
value = row.values[column]
|
|
22
|
+
if value is None:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
parts.append(to_text(value))
|
|
26
|
+
|
|
27
|
+
return _SEPARATOR.join(parts)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _display(value: str) -> str:
|
|
31
|
+
return value.replace(_SEPARATOR, "/")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _indexes(table: SchemaTable, columns: tuple[str, ...]) -> tuple[int, ...]:
|
|
35
|
+
result = []
|
|
36
|
+
for name in columns:
|
|
37
|
+
column = table.find_column(name)
|
|
38
|
+
if column is None:
|
|
39
|
+
raise LookupError(f"Key column {name} not in {table.name}.")
|
|
40
|
+
|
|
41
|
+
result.append(column.index)
|
|
42
|
+
|
|
43
|
+
return tuple(result)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _KeyState:
|
|
47
|
+
__slots__ = ("table", "columns", "values")
|
|
48
|
+
|
|
49
|
+
def __init__(self, table: SchemaTable, columns: tuple[int, ...]) -> None:
|
|
50
|
+
self.table = table
|
|
51
|
+
self.columns = columns
|
|
52
|
+
self.values: set[str] = set()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _ReferenceState:
|
|
56
|
+
__slots__ = ("table", "column_names", "columns", "key", "pending", "overflowed")
|
|
57
|
+
|
|
58
|
+
def __init__(self, table: SchemaTable, column_names: str, columns: tuple[int, ...], key: _KeyState) -> None:
|
|
59
|
+
self.table = table
|
|
60
|
+
self.column_names = column_names
|
|
61
|
+
self.columns = columns
|
|
62
|
+
self.key = key
|
|
63
|
+
self.pending: set[str] = set()
|
|
64
|
+
self.overflowed = False
|
|
65
|
+
"""True once the pending limit was reached and XAF-R017 was reported."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ReferenceChecker:
|
|
69
|
+
"""
|
|
70
|
+
Checks keys (unique) and references (existing) from the schema.
|
|
71
|
+
Only key values and unresolved reference values are kept in memory; references are resolved at the end,
|
|
72
|
+
so a reference to a key that appears later (e.g. in a later part) is still found.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, schema: AuditfileSchema, diagnostics: DiagnosticCollector) -> None:
|
|
76
|
+
self._diagnostics = diagnostics
|
|
77
|
+
keys_by_name: dict[str, _KeyState] = {}
|
|
78
|
+
self._keys_by_table: dict[int, list[_KeyState]] = {}
|
|
79
|
+
self._references_by_table: dict[int, list[_ReferenceState]] = {}
|
|
80
|
+
|
|
81
|
+
for table in schema.tables:
|
|
82
|
+
for key in table.keys:
|
|
83
|
+
state = _KeyState(table, _indexes(table, key.columns))
|
|
84
|
+
keys_by_name[key.name] = state
|
|
85
|
+
self._keys_by_table.setdefault(id(table), []).append(state)
|
|
86
|
+
|
|
87
|
+
for table in schema.tables:
|
|
88
|
+
for reference in table.references:
|
|
89
|
+
target = keys_by_name.get(reference.key)
|
|
90
|
+
if target is not None:
|
|
91
|
+
self._references_by_table.setdefault(id(table), []).append(
|
|
92
|
+
_ReferenceState(table, ", ".join(reference.columns), _indexes(table, reference.columns), target)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def on_row(self, row: AuditfileRow) -> None:
|
|
96
|
+
for key in self._keys_by_table.get(id(row.table), ()):
|
|
97
|
+
value = _composite(row, key.columns)
|
|
98
|
+
if value is not None:
|
|
99
|
+
if value in key.values:
|
|
100
|
+
self._diagnostics.add("XAF-R004", Location(row.part, None, None, row.table.path), value=_display(value), table=row.table.name)
|
|
101
|
+
else:
|
|
102
|
+
key.values.add(value)
|
|
103
|
+
|
|
104
|
+
for reference in self._references_by_table.get(id(row.table), ()):
|
|
105
|
+
value = _composite(row, reference.columns)
|
|
106
|
+
if value is None or value in reference.key.values or value in reference.pending:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
if len(reference.pending) < _MAX_PENDING_PER_REFERENCE:
|
|
110
|
+
reference.pending.add(value)
|
|
111
|
+
elif not reference.overflowed:
|
|
112
|
+
# From here on new values are not checked; say so once, so a clean result is not mistaken for a complete one.
|
|
113
|
+
reference.overflowed = True
|
|
114
|
+
self._diagnostics.add(
|
|
115
|
+
"XAF-R017", Location(row.part, None, None, row.table.path),
|
|
116
|
+
table=row.table.name, column=reference.column_names, target=reference.key.table.name, max=_MAX_PENDING_PER_REFERENCE,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def on_end(self) -> None:
|
|
120
|
+
for references in self._references_by_table.values():
|
|
121
|
+
for reference in references:
|
|
122
|
+
for value in sorted(v for v in reference.pending if v not in reference.key.values):
|
|
123
|
+
self._diagnostics.add(
|
|
124
|
+
"XAF-R003", Location(None, None, None, reference.table.path),
|
|
125
|
+
table=reference.table.name, column=reference.column_names, value=_display(value), target=reference.key.table.name,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
XAF = "xaf"
|
|
130
|
+
"""Audit file type the XAF rules apply to."""
|
|
131
|
+
|
|
132
|
+
_AMOUNT = "amnt"
|
|
133
|
+
_AMOUNT_TYPE = "amnt_tp"
|
|
134
|
+
_DEBIT = "D"
|
|
135
|
+
_CREDIT = "C"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class _Lines:
|
|
139
|
+
"""A table of lines with an amount and a debit/credit indicator, with its columns looked up once."""
|
|
140
|
+
|
|
141
|
+
__slots__ = ("table", "amount", "amount_type")
|
|
142
|
+
|
|
143
|
+
def __init__(self, table: SchemaTable, amount: SchemaColumn, amount_type: SchemaColumn) -> None:
|
|
144
|
+
self.table = table
|
|
145
|
+
self.amount = amount
|
|
146
|
+
self.amount_type = amount_type
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def find(table: SchemaTable | None) -> _Lines | None:
|
|
150
|
+
"""None when the table or a column does not exist, or has another type, in this version."""
|
|
151
|
+
amount = table.find_column(_AMOUNT) if table is not None else None
|
|
152
|
+
amount_type = table.find_column(_AMOUNT_TYPE) if table is not None else None
|
|
153
|
+
if table is None or amount is None or amount_type is None:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
if amount.type not in (ColumnType.DECIMAL, ColumnType.INTEGER) or amount_type.type is not ColumnType.STRING:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
return _Lines(table, amount, amount_type)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class _Totals:
|
|
163
|
+
__slots__ = ("count", "debit", "credit")
|
|
164
|
+
|
|
165
|
+
def __init__(self) -> None:
|
|
166
|
+
self.count = 0
|
|
167
|
+
self.debit = Decimal(0)
|
|
168
|
+
self.credit = Decimal(0)
|
|
169
|
+
|
|
170
|
+
def add(self, row: AuditfileRow, lines: _Lines) -> None:
|
|
171
|
+
self.count += 1
|
|
172
|
+
amount = row.get_decimal(lines.amount)
|
|
173
|
+
if amount is None:
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
type_ = row.get_string(lines.amount_type)
|
|
177
|
+
if type_ == _DEBIT:
|
|
178
|
+
self.debit = add(self.debit, amount)
|
|
179
|
+
elif type_ == _CREDIT:
|
|
180
|
+
self.credit = add(self.credit, amount)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _text(value: object) -> str:
|
|
184
|
+
return format_decimal(value) if isinstance(value, Decimal) else str(value)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _compare(row: AuditfileRow, field: str, expected: object, actual: object, diagnostics: DiagnosticCollector) -> None:
|
|
188
|
+
if expected is None or expected == actual:
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
diagnostics.add(
|
|
192
|
+
"XAF-R002", Location(row.part, None, None, row.table.path),
|
|
193
|
+
field=field, table=row.table.name, expected=_text(expected), actual=_text(actual),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class _TotalsRule:
|
|
198
|
+
def __init__(self, lines: _Lines, owner: SchemaTable, per_parent: bool, count: SchemaColumn, debit: SchemaColumn, credit: SchemaColumn) -> None:
|
|
199
|
+
self._lines = lines
|
|
200
|
+
self._owner = owner
|
|
201
|
+
self._per_parent = per_parent
|
|
202
|
+
self._count = count
|
|
203
|
+
self._debit = debit
|
|
204
|
+
self._credit = credit
|
|
205
|
+
self._by_parent: dict[int, _Totals] = {}
|
|
206
|
+
self._all = _Totals()
|
|
207
|
+
|
|
208
|
+
def on_row(self, row: AuditfileRow, diagnostics: DiagnosticCollector) -> None:
|
|
209
|
+
if row.table is self._lines.table:
|
|
210
|
+
totals = self._by_parent.setdefault(row.parent_id, _Totals()) if self._per_parent and row.parent_id is not None else self._all
|
|
211
|
+
totals.add(row, self._lines)
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
if row.table is not self._owner:
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
actual = self._by_parent.pop(row.id, _Totals()) if self._per_parent else self._all
|
|
218
|
+
_compare(row, self._count.name, row.get_integer(self._count), actual.count, diagnostics)
|
|
219
|
+
_compare(row, self._debit.name, row.get_decimal(self._debit), actual.debit, diagnostics)
|
|
220
|
+
_compare(row, self._credit.name, row.get_decimal(self._credit), actual.credit, diagnostics)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class XafRules:
|
|
224
|
+
"""
|
|
225
|
+
Business rules for Auditfile Financieel (XAF):
|
|
226
|
+
every transaction balances, and the counts and totals in the headers match the lines.
|
|
227
|
+
Rules whose tables or columns do not exist in a version are skipped. Columns are looked up once, not per row.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
def __init__(self, schema: AuditfileSchema, diagnostics: DiagnosticCollector) -> None:
|
|
231
|
+
self._diagnostics = diagnostics
|
|
232
|
+
self._transactions: dict[int, _Totals] = {}
|
|
233
|
+
self._rules: list[_TotalsRule] = []
|
|
234
|
+
self._lines = _Lines.find(schema.find_table("tr_line"))
|
|
235
|
+
self._transaction = schema.find_table("transaction")
|
|
236
|
+
self._transaction_number = self._transaction.find_column("nr") if self._transaction is not None else None
|
|
237
|
+
|
|
238
|
+
self._add_rule(schema, "tr_line", "company", False, "transactions_lines_count", "transactions_total_debit", "transactions_total_credit")
|
|
239
|
+
self._add_rule(schema, "ob_line", "company", False, "opening_balance_lines_count", "opening_balance_total_debit", "opening_balance_total_credit")
|
|
240
|
+
self._add_rule(schema, "sb_line", "subledger", True, "lines_count", "total_debit", "total_credit")
|
|
241
|
+
self._add_rule(schema, "ob_sb_line", "ob_subledger", True, "lines_count", "total_debit", "total_credit")
|
|
242
|
+
|
|
243
|
+
def on_row(self, row: AuditfileRow) -> None:
|
|
244
|
+
if self._lines is not None and row.table is self._lines.table and row.parent_id is not None:
|
|
245
|
+
self._transactions.setdefault(row.parent_id, _Totals()).add(row, self._lines)
|
|
246
|
+
elif row.table is self._transaction and row.id in self._transactions:
|
|
247
|
+
lines = self._transactions.pop(row.id)
|
|
248
|
+
if lines.debit != lines.credit:
|
|
249
|
+
number = row[self._transaction_number] if self._transaction_number is not None else None
|
|
250
|
+
self._diagnostics.add(
|
|
251
|
+
"XAF-R001", Location(row.part, None, None, row.table.path), transaction=number, debit=lines.debit, credit=lines.credit
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
for rule in self._rules:
|
|
255
|
+
rule.on_row(row, self._diagnostics)
|
|
256
|
+
|
|
257
|
+
def on_end(self) -> None:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
def _add_rule(self, schema: AuditfileSchema, child: str, owner: str, per_parent: bool, count: str, debit: str, credit: str) -> None:
|
|
261
|
+
lines = _Lines.find(schema.find_table(child))
|
|
262
|
+
owner_table = schema.find_table(owner)
|
|
263
|
+
if lines is None or owner_table is None:
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
count_column = owner_table.find_column(count)
|
|
267
|
+
debit_column = owner_table.find_column(debit)
|
|
268
|
+
credit_column = owner_table.find_column(credit)
|
|
269
|
+
if (
|
|
270
|
+
count_column is None
|
|
271
|
+
or count_column.type is not ColumnType.INTEGER
|
|
272
|
+
or debit_column is None
|
|
273
|
+
or debit_column.type is not ColumnType.DECIMAL
|
|
274
|
+
or credit_column is None
|
|
275
|
+
or credit_column.type is not ColumnType.DECIMAL
|
|
276
|
+
):
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
self._rules.append(_TotalsRule(lines, owner_table, per_parent, count_column, debit_column, credit_column))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Exact decimals in XSD notation, within the range of .NET decimal so every implementation accepts the same values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import decimal
|
|
6
|
+
import re
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
|
|
9
|
+
_PATTERN = re.compile(r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)")
|
|
10
|
+
_MAX_SCALE = 28
|
|
11
|
+
_MAX = Decimal("79228162514264337593543950335")
|
|
12
|
+
|
|
13
|
+
# Additions never round: an inexact result raises instead of silently losing a cent.
|
|
14
|
+
EXACT = decimal.Context(prec=200, traps=[decimal.Inexact, decimal.InvalidOperation, decimal.Overflow])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_decimal(text: str) -> Decimal | None:
|
|
18
|
+
"""Parses XSD notation (sign, digits, optional point; no exponent). Returns None when invalid or out of range."""
|
|
19
|
+
if _PATTERN.fullmatch(text) is None:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
fraction = text.partition(".")[2]
|
|
23
|
+
if len(fraction) > _MAX_SCALE:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
digits = text.lstrip("+-").replace(".", "").lstrip("0")
|
|
27
|
+
if len(digits) > 29:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
value = Decimal(text)
|
|
31
|
+
if abs(value) > _MAX:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
return value.copy_abs() if value.is_zero() else value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def add(a: Decimal, b: Decimal) -> Decimal:
|
|
38
|
+
return EXACT.add(a, b)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def format_decimal(value: Decimal) -> str:
|
|
42
|
+
"""Invariant notation with the scale as written, never scientific: 100.00, -0.0000001."""
|
|
43
|
+
text = format(value, "f")
|
|
44
|
+
return text[1:] if text.startswith("-") and value.is_zero() else text
|
xml_auditfile/_frames.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""pandas and polars DataFrames from tables and views. Both are optional:
|
|
2
|
+
``pip install xml-auditfile[pandas]`` or ``pip install xml-auditfile[polars]``."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import datetime as dt
|
|
7
|
+
from collections.abc import Iterable, Sequence
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
10
|
+
|
|
11
|
+
from .definitions import ColumnType
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
import pandas
|
|
15
|
+
import polars
|
|
16
|
+
|
|
17
|
+
Decimals = Literal["decimal", "float"]
|
|
18
|
+
"""How decimal columns end up in a DataFrame: exact decimals or float64."""
|
|
19
|
+
|
|
20
|
+
Columns = Sequence[tuple[str, "ColumnType | None"]]
|
|
21
|
+
"""(name, type) per column; a type of None is inferred from the values."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _module(name: str, method: str) -> Any:
|
|
25
|
+
try:
|
|
26
|
+
return __import__(name)
|
|
27
|
+
except ImportError as error:
|
|
28
|
+
raise ImportError(f"{method} needs {name}: pip install xml-auditfile[{name}]") from error
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _infer(values: Sequence[object]) -> ColumnType | None:
|
|
32
|
+
"""The column type of view values, from the first value that is not None; None when every value is None."""
|
|
33
|
+
for value in values:
|
|
34
|
+
if value is None:
|
|
35
|
+
continue
|
|
36
|
+
if isinstance(value, bool):
|
|
37
|
+
return ColumnType.BOOLEAN
|
|
38
|
+
if isinstance(value, Decimal):
|
|
39
|
+
return ColumnType.DECIMAL
|
|
40
|
+
if isinstance(value, int):
|
|
41
|
+
return ColumnType.INTEGER
|
|
42
|
+
if isinstance(value, dt.datetime):
|
|
43
|
+
return ColumnType.DATETIME
|
|
44
|
+
if isinstance(value, dt.date):
|
|
45
|
+
return ColumnType.DATE
|
|
46
|
+
if isinstance(value, dt.time):
|
|
47
|
+
return ColumnType.TIME
|
|
48
|
+
return ColumnType.STRING
|
|
49
|
+
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cells(columns: Columns, rows: Iterable[Sequence[object]], decimals: Decimals) -> list[tuple[str, ColumnType | None, list[object]]]:
|
|
54
|
+
"""(name, type, values) per column, with the types of None inferred."""
|
|
55
|
+
if decimals not in ("decimal", "float"):
|
|
56
|
+
raise ValueError(f"decimals must be 'decimal' or 'float', not {decimals!r}.")
|
|
57
|
+
|
|
58
|
+
cells: list[list[object]] = [[] for _ in columns]
|
|
59
|
+
for row in rows:
|
|
60
|
+
for i, value in enumerate(row):
|
|
61
|
+
cells[i].append(value)
|
|
62
|
+
|
|
63
|
+
return [(name, type_ if type_ is not None else _infer(values), values) for (name, type_), values in zip(columns, cells)]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _is_aware(values: list[object]) -> bool:
|
|
67
|
+
# One time zone per column: with any aware value, everything is converted to UTC (naive values count as UTC).
|
|
68
|
+
return any(isinstance(v, dt.datetime) and v.tzinfo is not None for v in values)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _pandas_series(pd: Any, name: str, values: list[object], type_: ColumnType | None, decimals: Decimals) -> pandas.Series:
|
|
72
|
+
if type_ is ColumnType.STRING:
|
|
73
|
+
return pd.Series(values, name=name, dtype="string")
|
|
74
|
+
|
|
75
|
+
if type_ is ColumnType.INTEGER:
|
|
76
|
+
return pd.Series(values, name=name, dtype="Int64")
|
|
77
|
+
|
|
78
|
+
if type_ is ColumnType.BOOLEAN:
|
|
79
|
+
return pd.Series(values, name=name, dtype="boolean")
|
|
80
|
+
|
|
81
|
+
if type_ is ColumnType.DECIMAL:
|
|
82
|
+
if decimals == "float":
|
|
83
|
+
return pd.Series([None if v is None else float(v) for v in values], name=name, dtype="float64") # type: ignore[arg-type]
|
|
84
|
+
return pd.Series(values, name=name, dtype="object")
|
|
85
|
+
|
|
86
|
+
if type_ is ColumnType.DATE:
|
|
87
|
+
return pd.Series(pd.to_datetime(values), name=name)
|
|
88
|
+
|
|
89
|
+
if type_ is ColumnType.DATETIME:
|
|
90
|
+
return pd.Series(pd.to_datetime(values, utc=_is_aware(values)), name=name)
|
|
91
|
+
|
|
92
|
+
return pd.Series(values, name=name, dtype="object")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def pandas_frame(columns: Columns, rows: Iterable[Sequence[object]], decimals: Decimals, index: tuple[str, list[int]] | None = None) -> pandas.DataFrame:
|
|
96
|
+
"""A pandas DataFrame; decimals stay Decimal objects (object dtype) unless decimals is "float"."""
|
|
97
|
+
cells = _cells(columns, rows, decimals)
|
|
98
|
+
pd = _module("pandas", "to_pandas")
|
|
99
|
+
result = pd.DataFrame({name: _pandas_series(pd, name, values, type_, decimals) for name, type_, values in cells},
|
|
100
|
+
columns=[name for name, _ in columns])
|
|
101
|
+
if index is not None:
|
|
102
|
+
result.index = pd.Index(index[1], name=index[0], dtype="int64")
|
|
103
|
+
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _scale(values: list[object]) -> int:
|
|
108
|
+
"""The largest number of decimals in the column, so the Decimal dtype holds every value exactly."""
|
|
109
|
+
scales = [-v.as_tuple().exponent for v in values if isinstance(v, Decimal) and isinstance(v.as_tuple().exponent, int)]
|
|
110
|
+
return max([0, *scales])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _polars_series(pl: Any, name: str, values: list[object], type_: ColumnType | None, decimals: Decimals) -> polars.Series:
|
|
114
|
+
if type_ is ColumnType.STRING:
|
|
115
|
+
return pl.Series(name, values, dtype=pl.String)
|
|
116
|
+
|
|
117
|
+
if type_ is ColumnType.INTEGER:
|
|
118
|
+
return pl.Series(name, values, dtype=pl.Int64)
|
|
119
|
+
|
|
120
|
+
if type_ is ColumnType.BOOLEAN:
|
|
121
|
+
return pl.Series(name, values, dtype=pl.Boolean)
|
|
122
|
+
|
|
123
|
+
if type_ is ColumnType.DECIMAL:
|
|
124
|
+
if decimals == "float":
|
|
125
|
+
return pl.Series(name, [None if v is None else float(v) for v in values], dtype=pl.Float64) # type: ignore[arg-type]
|
|
126
|
+
return pl.Series(name, values, dtype=pl.Decimal(38, _scale(values)))
|
|
127
|
+
|
|
128
|
+
if type_ is ColumnType.DATE:
|
|
129
|
+
return pl.Series(name, values, dtype=pl.Date)
|
|
130
|
+
|
|
131
|
+
if type_ is ColumnType.DATETIME:
|
|
132
|
+
if not _is_aware(values):
|
|
133
|
+
return pl.Series(name, values, dtype=pl.Datetime("us"))
|
|
134
|
+
utc = [v.astimezone(dt.timezone.utc).replace(tzinfo=None) if isinstance(v, dt.datetime) and v.tzinfo else v for v in values]
|
|
135
|
+
return pl.Series(name, utc, dtype=pl.Datetime("us")).dt.replace_time_zone("UTC")
|
|
136
|
+
|
|
137
|
+
if type_ is ColumnType.TIME:
|
|
138
|
+
return pl.Series(name, values, dtype=pl.Time)
|
|
139
|
+
|
|
140
|
+
return pl.Series(name, values, dtype=pl.Null)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def polars_frame(columns: Columns, rows: Iterable[Sequence[object]], decimals: Decimals) -> polars.DataFrame:
|
|
144
|
+
"""A polars DataFrame; decimals get an exact Decimal dtype unless decimals is "float"."""
|
|
145
|
+
cells = _cells(columns, rows, decimals)
|
|
146
|
+
pl = _module("polars", "to_polars")
|
|
147
|
+
return pl.DataFrame([_polars_series(pl, name, values, type_, decimals) for name, type_, values in cells])
|
xml_auditfile/_json.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Typed access to parsed JSON. Every accessor checks the type, so malformed input fails with a clear message."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JsonFormatError(ValueError):
|
|
10
|
+
"""JSON that does not have the expected structure."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_object(text: str, name: str) -> dict[str, Any]:
|
|
14
|
+
try:
|
|
15
|
+
value = json.loads(text)
|
|
16
|
+
except json.JSONDecodeError as error:
|
|
17
|
+
raise JsonFormatError(f"{name} is not valid JSON: {error}") from error
|
|
18
|
+
|
|
19
|
+
if not isinstance(value, dict):
|
|
20
|
+
raise JsonFormatError(f"{name} is not a JSON object.")
|
|
21
|
+
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get(obj: dict[str, Any], key: str) -> Any:
|
|
26
|
+
return obj.get(key)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def string(obj: dict[str, Any], key: str) -> str:
|
|
30
|
+
value = _get(obj, key)
|
|
31
|
+
if not isinstance(value, str):
|
|
32
|
+
raise JsonFormatError(f"JSON: '{key}' must be a string.")
|
|
33
|
+
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def optional_string(obj: dict[str, Any], key: str) -> str | None:
|
|
38
|
+
return None if _get(obj, key) is None else string(obj, key)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def integer(obj: dict[str, Any], key: str) -> int:
|
|
42
|
+
value = _get(obj, key)
|
|
43
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
44
|
+
raise JsonFormatError(f"JSON: '{key}' must be an integer.")
|
|
45
|
+
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def optional_integer(obj: dict[str, Any], key: str) -> int | None:
|
|
50
|
+
return None if _get(obj, key) is None else integer(obj, key)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def boolean(obj: dict[str, Any], key: str) -> bool:
|
|
54
|
+
return _get(obj, key) is True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def obj(parent: dict[str, Any], key: str) -> dict[str, Any] | None:
|
|
58
|
+
value = _get(parent, key)
|
|
59
|
+
if value is None:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
if not isinstance(value, dict):
|
|
63
|
+
raise JsonFormatError(f"JSON: '{key}' must be an object.")
|
|
64
|
+
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def array(parent: dict[str, Any], key: str) -> list[Any]:
|
|
69
|
+
value = _get(parent, key)
|
|
70
|
+
if value is None:
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
if not isinstance(value, list):
|
|
74
|
+
raise JsonFormatError(f"JSON: '{key}' must be an array.")
|
|
75
|
+
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def objects(parent: dict[str, Any], key: str) -> list[dict[str, Any]]:
|
|
80
|
+
items = array(parent, key)
|
|
81
|
+
if not all(isinstance(item, dict) for item in items):
|
|
82
|
+
raise JsonFormatError(f"JSON: '{key}' must contain objects.")
|
|
83
|
+
|
|
84
|
+
return items
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def strings(parent: dict[str, Any], key: str) -> list[str]:
|
|
88
|
+
items = array(parent, key)
|
|
89
|
+
if not all(isinstance(item, str) for item in items):
|
|
90
|
+
raise JsonFormatError(f"JSON: '{key}' must contain strings.")
|
|
91
|
+
|
|
92
|
+
return items
|
xml_auditfile/_keys.py
ADDED