paradoxdb 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.
- paradoxdb/__init__.py +94 -0
- paradoxdb/__main__.py +10 -0
- paradoxdb/blocks.py +138 -0
- paradoxdb/cli.py +140 -0
- paradoxdb/errors.py +27 -0
- paradoxdb/fields.py +132 -0
- paradoxdb/header.py +329 -0
- paradoxdb/index.py +251 -0
- paradoxdb/memo.py +199 -0
- paradoxdb/numeric.py +128 -0
- paradoxdb/profile.py +284 -0
- paradoxdb/py.typed +0 -0
- paradoxdb/synthetic.py +507 -0
- paradoxdb/table.py +247 -0
- paradoxdb/values.py +337 -0
- paradoxdb/writer.py +233 -0
- paradoxdb-0.1.0.dist-info/METADATA +229 -0
- paradoxdb-0.1.0.dist-info/RECORD +21 -0
- paradoxdb-0.1.0.dist-info/WHEEL +4 -0
- paradoxdb-0.1.0.dist-info/entry_points.txt +2 -0
- paradoxdb-0.1.0.dist-info/licenses/LICENSE +21 -0
paradoxdb/__init__.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""paradoxdb: pure-Python reader and writer for Paradox tables (.DB / .MB / .PX).
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from paradoxdb import open_table
|
|
6
|
+
|
|
7
|
+
table = open_table("ORDERS.DB")
|
|
8
|
+
for record in table.records():
|
|
9
|
+
cell = record["Amount"]
|
|
10
|
+
if cell.is_ok:
|
|
11
|
+
print(cell.value)
|
|
12
|
+
else:
|
|
13
|
+
print(cell.state, cell.reason, cell.raw_bytes.hex())
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from .blocks import Anomaly
|
|
19
|
+
from .errors import (
|
|
20
|
+
DuplicateKeyError,
|
|
21
|
+
EncodingError,
|
|
22
|
+
EncryptedTableError,
|
|
23
|
+
FormatError,
|
|
24
|
+
ParadoxError,
|
|
25
|
+
WriteError,
|
|
26
|
+
)
|
|
27
|
+
from .fields import FieldDef, FieldType
|
|
28
|
+
from .header import TableHeader, parse_header
|
|
29
|
+
from .index import DataBlock, build_index, read_data_blocks, verify_index
|
|
30
|
+
from .memo import MemoFile, MemoPointer
|
|
31
|
+
from .profile import TableProfile, export_csv, export_json, profile_table
|
|
32
|
+
from .table import Decryptor, ParadoxTable, Record, open_table
|
|
33
|
+
from .values import (
|
|
34
|
+
CellValue,
|
|
35
|
+
ValueState,
|
|
36
|
+
decode_value,
|
|
37
|
+
encode_alpha,
|
|
38
|
+
encode_bcd,
|
|
39
|
+
encode_bool,
|
|
40
|
+
encode_date,
|
|
41
|
+
encode_double,
|
|
42
|
+
encode_int,
|
|
43
|
+
encode_null,
|
|
44
|
+
encode_time,
|
|
45
|
+
encode_timestamp,
|
|
46
|
+
)
|
|
47
|
+
from .writer import WriteRecord, WriteResult, legacy_sort_key, write_table
|
|
48
|
+
|
|
49
|
+
__version__ = "0.1.0"
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"Anomaly",
|
|
53
|
+
"CellValue",
|
|
54
|
+
"DataBlock",
|
|
55
|
+
"Decryptor",
|
|
56
|
+
"DuplicateKeyError",
|
|
57
|
+
"EncodingError",
|
|
58
|
+
"EncryptedTableError",
|
|
59
|
+
"FieldDef",
|
|
60
|
+
"FieldType",
|
|
61
|
+
"FormatError",
|
|
62
|
+
"MemoFile",
|
|
63
|
+
"MemoPointer",
|
|
64
|
+
"ParadoxError",
|
|
65
|
+
"ParadoxTable",
|
|
66
|
+
"Record",
|
|
67
|
+
"TableHeader",
|
|
68
|
+
"TableProfile",
|
|
69
|
+
"ValueState",
|
|
70
|
+
"WriteError",
|
|
71
|
+
"WriteRecord",
|
|
72
|
+
"WriteResult",
|
|
73
|
+
"__version__",
|
|
74
|
+
"build_index",
|
|
75
|
+
"decode_value",
|
|
76
|
+
"encode_alpha",
|
|
77
|
+
"encode_bcd",
|
|
78
|
+
"encode_bool",
|
|
79
|
+
"encode_date",
|
|
80
|
+
"encode_double",
|
|
81
|
+
"encode_int",
|
|
82
|
+
"encode_null",
|
|
83
|
+
"encode_time",
|
|
84
|
+
"encode_timestamp",
|
|
85
|
+
"export_csv",
|
|
86
|
+
"export_json",
|
|
87
|
+
"legacy_sort_key",
|
|
88
|
+
"open_table",
|
|
89
|
+
"parse_header",
|
|
90
|
+
"profile_table",
|
|
91
|
+
"read_data_blocks",
|
|
92
|
+
"verify_index",
|
|
93
|
+
"write_table",
|
|
94
|
+
]
|
paradoxdb/__main__.py
ADDED
paradoxdb/blocks.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Data blocks of ``.DB`` and ``.PX`` files: walking the chain and packing records.
|
|
2
|
+
|
|
3
|
+
Blocks are numbered from 1 and start at ``header_size + (number - 1) * block_size``.
|
|
4
|
+
Each block begins with a 6-byte header: ``next`` (u16), ``previous`` (u16) and
|
|
5
|
+
the offset of the last record relative to the start of the block data (i16).
|
|
6
|
+
An empty block stores ``-record_size`` there.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import struct
|
|
12
|
+
from collections.abc import Iterator, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from .header import TableHeader
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BLOCK_HEADER_SIZE",
|
|
19
|
+
"Anomaly",
|
|
20
|
+
"RawBlock",
|
|
21
|
+
"iter_blocks",
|
|
22
|
+
"pack_block_groups",
|
|
23
|
+
"pack_blocks",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
BLOCK_HEADER_SIZE = 6
|
|
27
|
+
_BLOCK_HEADER = struct.Struct("<HHh")
|
|
28
|
+
MAX_BLOCKS = 0xFFFF
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class Anomaly:
|
|
33
|
+
"""Something odd found while walking the block chain. Never fatal."""
|
|
34
|
+
|
|
35
|
+
block_no: int
|
|
36
|
+
message: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class RawBlock:
|
|
41
|
+
number: int
|
|
42
|
+
next_block: int
|
|
43
|
+
prev_block: int
|
|
44
|
+
last_offset: int
|
|
45
|
+
records: tuple[bytes, ...]
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def record_count(self) -> int:
|
|
49
|
+
return len(self.records)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def block_start(header: TableHeader, number: int) -> int:
|
|
53
|
+
return header.header_size + (number - 1) * header.block_size
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def iter_blocks(data: bytes, header: TableHeader, anomalies: list[Anomaly]) -> Iterator[RawBlock]:
|
|
57
|
+
"""Follow the block chain from the header's first block.
|
|
58
|
+
|
|
59
|
+
Every record present in a block is yielded, whatever the declared record
|
|
60
|
+
count says. Cycles, blocks outside the file and blocks declaring more
|
|
61
|
+
records than they can hold are appended to ``anomalies`` (the latter count
|
|
62
|
+
as empty); none of them aborts the walk.
|
|
63
|
+
"""
|
|
64
|
+
number = header.first_block
|
|
65
|
+
seen: set[int] = set()
|
|
66
|
+
record_size = header.record_size
|
|
67
|
+
capacity = header.block_capacity
|
|
68
|
+
while number:
|
|
69
|
+
if number in seen:
|
|
70
|
+
anomalies.append(Anomaly(number, "block chain loops back to an earlier block"))
|
|
71
|
+
return
|
|
72
|
+
seen.add(number)
|
|
73
|
+
start = block_start(header, number)
|
|
74
|
+
end = start + header.block_size
|
|
75
|
+
if start < header.header_size or end > len(data):
|
|
76
|
+
anomalies.append(Anomaly(number, "block lies outside the file"))
|
|
77
|
+
return
|
|
78
|
+
next_block, prev_block, last_offset = _BLOCK_HEADER.unpack_from(data, start)
|
|
79
|
+
if last_offset < 0:
|
|
80
|
+
count = 0
|
|
81
|
+
else:
|
|
82
|
+
count = last_offset // record_size + 1
|
|
83
|
+
if count > capacity:
|
|
84
|
+
anomalies.append(
|
|
85
|
+
Anomaly(
|
|
86
|
+
number,
|
|
87
|
+
f"block declares {count} records but can hold {capacity}; counted as 0",
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
count = 0
|
|
91
|
+
base = start + BLOCK_HEADER_SIZE
|
|
92
|
+
records = tuple(
|
|
93
|
+
bytes(data[base + i * record_size : base + (i + 1) * record_size]) for i in range(count)
|
|
94
|
+
)
|
|
95
|
+
yield RawBlock(number, next_block, prev_block, last_offset, records)
|
|
96
|
+
number = next_block
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def pack_blocks(records: Sequence[bytes], record_size: int, block_size: int) -> tuple[bytes, int]:
|
|
100
|
+
"""Lay ``records`` out in a chain of full-size blocks.
|
|
101
|
+
|
|
102
|
+
Returns the block bytes and the number of blocks. An empty record set still
|
|
103
|
+
produces one empty block, as Paradox does.
|
|
104
|
+
"""
|
|
105
|
+
capacity = (block_size - BLOCK_HEADER_SIZE) // record_size
|
|
106
|
+
if capacity < 1:
|
|
107
|
+
raise ValueError("record does not fit in a block")
|
|
108
|
+
groups = [records[i : i + capacity] for i in range(0, len(records), capacity)] or [()]
|
|
109
|
+
return pack_block_groups(groups, record_size, block_size)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def pack_block_groups(
|
|
113
|
+
groups: Sequence[Sequence[bytes]], record_size: int, block_size: int
|
|
114
|
+
) -> tuple[bytes, int]:
|
|
115
|
+
"""Write one block per group, chained in order; groups must respect the block capacity."""
|
|
116
|
+
capacity = (block_size - BLOCK_HEADER_SIZE) // record_size
|
|
117
|
+
if capacity < 1:
|
|
118
|
+
raise ValueError("record does not fit in a block")
|
|
119
|
+
if not groups:
|
|
120
|
+
groups = [()]
|
|
121
|
+
if len(groups) > MAX_BLOCKS:
|
|
122
|
+
raise ValueError("too many blocks for a 16-bit block number")
|
|
123
|
+
out = bytearray()
|
|
124
|
+
total = len(groups)
|
|
125
|
+
for i, chunk in enumerate(groups, start=1):
|
|
126
|
+
if len(chunk) > capacity:
|
|
127
|
+
raise ValueError(f"{len(chunk)} records exceed the block capacity of {capacity}")
|
|
128
|
+
block = bytearray(block_size)
|
|
129
|
+
last_offset = (len(chunk) - 1) * record_size if chunk else -record_size
|
|
130
|
+
_BLOCK_HEADER.pack_into(block, 0, i + 1 if i < total else 0, i - 1, last_offset)
|
|
131
|
+
pos = BLOCK_HEADER_SIZE
|
|
132
|
+
for rec in chunk:
|
|
133
|
+
if len(rec) != record_size:
|
|
134
|
+
raise ValueError(f"record of {len(rec)} bytes, expected {record_size}")
|
|
135
|
+
block[pos : pos + record_size] = rec
|
|
136
|
+
pos += record_size
|
|
137
|
+
out += block
|
|
138
|
+
return bytes(out), total
|
paradoxdb/cli.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Command line interface: ``paradoxdb profile | export | verify-index``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from importlib.metadata import entry_points
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .errors import ParadoxError
|
|
15
|
+
from .index import read_data_blocks, verify_index
|
|
16
|
+
from .profile import export_csv, export_json, profile_directory
|
|
17
|
+
from .table import Decryptor, find_sibling
|
|
18
|
+
|
|
19
|
+
DECRYPTOR_ENTRY_POINT_GROUP = "paradoxdb.decryptors"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_decryptor(spec: str | None) -> Decryptor:
|
|
23
|
+
"""Instantiate a decryptor from ``module:attribute`` or from the first registered entry point.
|
|
24
|
+
|
|
25
|
+
The attribute may be a ready object or a zero-argument factory (class or
|
|
26
|
+
function) returning one.
|
|
27
|
+
"""
|
|
28
|
+
if spec:
|
|
29
|
+
module_name, _, attr = spec.partition(":")
|
|
30
|
+
if not attr:
|
|
31
|
+
raise SystemExit("--decrypt expects MODULE:ATTRIBUTE")
|
|
32
|
+
target = getattr(importlib.import_module(module_name), attr)
|
|
33
|
+
else:
|
|
34
|
+
candidates = list(entry_points(group=DECRYPTOR_ENTRY_POINT_GROUP))
|
|
35
|
+
if not candidates:
|
|
36
|
+
raise SystemExit(
|
|
37
|
+
f"no decryptor installed: register one under the {DECRYPTOR_ENTRY_POINT_GROUP!r} "
|
|
38
|
+
"entry-point group or pass --decrypt MODULE:ATTRIBUTE"
|
|
39
|
+
)
|
|
40
|
+
target = candidates[0].load()
|
|
41
|
+
instance = target() if callable(target) and not hasattr(target, "decrypt") else target
|
|
42
|
+
if not hasattr(instance, "decrypt"):
|
|
43
|
+
raise SystemExit(f"{spec or 'entry point'} does not provide a decrypt() method")
|
|
44
|
+
return instance
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _cmd_profile(args: argparse.Namespace) -> int:
|
|
48
|
+
decryptor = load_decryptor(args.decrypt) if args.decrypt is not None else None
|
|
49
|
+
profiles, errors = profile_directory(
|
|
50
|
+
args.directory, decryptor=decryptor, default_codec=args.codec
|
|
51
|
+
)
|
|
52
|
+
payload = {
|
|
53
|
+
"tool": f"paradoxdb {__version__}",
|
|
54
|
+
"directory": str(args.directory),
|
|
55
|
+
"tables": [p.to_dict() for p in profiles],
|
|
56
|
+
"errors": errors,
|
|
57
|
+
}
|
|
58
|
+
text = json.dumps(payload, indent=2, ensure_ascii=False)
|
|
59
|
+
if args.output:
|
|
60
|
+
Path(args.output).write_text(text, encoding="utf-8")
|
|
61
|
+
print(f"{len(profiles)} table(s) profiled, {len(errors)} error(s) -> {args.output}")
|
|
62
|
+
else:
|
|
63
|
+
print(text)
|
|
64
|
+
return 1 if errors and not profiles else 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _cmd_export(args: argparse.Namespace) -> int:
|
|
68
|
+
decryptor = load_decryptor(args.decrypt) if args.decrypt is not None else None
|
|
69
|
+
table = Path(args.table)
|
|
70
|
+
output = Path(args.output) if args.output else table.with_suffix(f".{args.format}")
|
|
71
|
+
if args.format == "csv":
|
|
72
|
+
count = export_csv(table, output, decryptor=decryptor)
|
|
73
|
+
else:
|
|
74
|
+
count = export_json(table, output, decryptor=decryptor)
|
|
75
|
+
print(f"{count} record(s) -> {output}")
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _cmd_verify_index(args: argparse.Namespace) -> int:
|
|
80
|
+
table = Path(args.table)
|
|
81
|
+
px = find_sibling(table, ".PX")
|
|
82
|
+
if px is None:
|
|
83
|
+
print(f"{table.name}: no .PX file found next to the table", file=sys.stderr)
|
|
84
|
+
return 2
|
|
85
|
+
problems = verify_index(px, read_data_blocks(table))
|
|
86
|
+
if problems:
|
|
87
|
+
print(f"{px.name}: {len(problems)} problem(s)")
|
|
88
|
+
for problem in problems:
|
|
89
|
+
print(f" - {problem}")
|
|
90
|
+
return 1
|
|
91
|
+
print(f"{px.name}: index is consistent with {table.name}")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
96
|
+
parser = argparse.ArgumentParser(
|
|
97
|
+
prog="paradoxdb", description="Read, profile and check Paradox tables."
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
100
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
101
|
+
|
|
102
|
+
p = sub.add_parser("profile", help="aggregate every .DB in a directory into a JSON profile")
|
|
103
|
+
p.add_argument("directory", type=Path)
|
|
104
|
+
p.add_argument("--output", "-o", help="write the JSON here instead of stdout")
|
|
105
|
+
p.add_argument(
|
|
106
|
+
"--decrypt",
|
|
107
|
+
nargs="?",
|
|
108
|
+
const="",
|
|
109
|
+
default=None,
|
|
110
|
+
metavar="MODULE:ATTR",
|
|
111
|
+
help="use an external decryptor (entry point if no value is given)",
|
|
112
|
+
)
|
|
113
|
+
p.add_argument("--codec", default="cp437", help="fallback code page (default cp437)")
|
|
114
|
+
p.set_defaults(func=_cmd_profile)
|
|
115
|
+
|
|
116
|
+
e = sub.add_parser("export", help="dump a table with value, state and reason per cell")
|
|
117
|
+
e.add_argument("table", help="path to the .DB file")
|
|
118
|
+
e.add_argument("--format", "-f", choices=("csv", "json"), default="csv")
|
|
119
|
+
e.add_argument("--output", "-o", help="destination file (default: table name + format)")
|
|
120
|
+
e.add_argument("--decrypt", nargs="?", const="", default=None, metavar="MODULE:ATTR")
|
|
121
|
+
e.set_defaults(func=_cmd_export)
|
|
122
|
+
|
|
123
|
+
v = sub.add_parser("verify-index", help="check the .PX of a table against its data blocks")
|
|
124
|
+
v.add_argument("table", help="path to the .DB file")
|
|
125
|
+
v.set_defaults(func=_cmd_verify_index)
|
|
126
|
+
return parser
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
130
|
+
parser = build_parser()
|
|
131
|
+
args = parser.parse_args(argv)
|
|
132
|
+
try:
|
|
133
|
+
return args.func(args)
|
|
134
|
+
except ParadoxError as exc:
|
|
135
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
136
|
+
return 1
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__": # pragma: no cover
|
|
140
|
+
sys.exit(main())
|
paradoxdb/errors.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Exception hierarchy of :mod:`paradoxdb`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ParadoxError(Exception):
|
|
7
|
+
"""Base class for every error raised by the library."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FormatError(ParadoxError):
|
|
11
|
+
"""The file does not look like a coherent Paradox table or index."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EncryptedTableError(ParadoxError):
|
|
15
|
+
"""The table is encrypted and no decryptor was supplied (or writing was requested)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WriteError(ParadoxError):
|
|
19
|
+
"""A record set cannot be written on top of the given template."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DuplicateKeyError(WriteError):
|
|
23
|
+
"""Two records share the same primary key."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EncodingError(ParadoxError):
|
|
27
|
+
"""A Python value cannot be represented in the requested Paradox field."""
|
paradoxdb/fields.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Field types, field definitions and code pages of Paradox tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import codecs
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import IntEnum
|
|
8
|
+
|
|
9
|
+
__all__ = ["CODE_PAGES", "FieldDef", "FieldType", "codec_for_code_page", "physical_size"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FieldType(IntEnum):
|
|
13
|
+
"""Field type codes as stored in the 2-byte field descriptors."""
|
|
14
|
+
|
|
15
|
+
ALPHA = 0x01
|
|
16
|
+
DATE = 0x02
|
|
17
|
+
SHORT = 0x03
|
|
18
|
+
LONG = 0x04
|
|
19
|
+
CURRENCY = 0x05
|
|
20
|
+
NUMBER = 0x06
|
|
21
|
+
LOGICAL = 0x09
|
|
22
|
+
MEMO = 0x0C
|
|
23
|
+
BLOB = 0x0D
|
|
24
|
+
FORMATTED_MEMO = 0x0E
|
|
25
|
+
OLE = 0x0F
|
|
26
|
+
GRAPHIC = 0x10
|
|
27
|
+
TIME = 0x14
|
|
28
|
+
TIMESTAMP = 0x15
|
|
29
|
+
AUTOINCREMENT = 0x16
|
|
30
|
+
BCD = 0x17
|
|
31
|
+
BYTES = 0x18
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def is_blob(self) -> bool:
|
|
35
|
+
"""True for every type whose content lives (at least partly) in the ``.MB`` file."""
|
|
36
|
+
return self in _BLOB_TYPES
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_text_blob(self) -> bool:
|
|
40
|
+
"""True for memo types, whose content is text decoded with the table code page."""
|
|
41
|
+
return self in (FieldType.MEMO, FieldType.FORMATTED_MEMO)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_numeric(self) -> bool:
|
|
45
|
+
return self in _NUMERIC_TYPES
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_fixed_size(self) -> bool:
|
|
49
|
+
return self in _FIXED_SIZES
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_BLOB_TYPES = frozenset(
|
|
53
|
+
{FieldType.MEMO, FieldType.BLOB, FieldType.FORMATTED_MEMO, FieldType.OLE, FieldType.GRAPHIC}
|
|
54
|
+
)
|
|
55
|
+
_NUMERIC_TYPES = frozenset(
|
|
56
|
+
{
|
|
57
|
+
FieldType.SHORT,
|
|
58
|
+
FieldType.LONG,
|
|
59
|
+
FieldType.CURRENCY,
|
|
60
|
+
FieldType.NUMBER,
|
|
61
|
+
FieldType.AUTOINCREMENT,
|
|
62
|
+
FieldType.BCD,
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
_FIXED_SIZES: dict[FieldType, int] = {
|
|
66
|
+
FieldType.DATE: 4,
|
|
67
|
+
FieldType.SHORT: 2,
|
|
68
|
+
FieldType.LONG: 4,
|
|
69
|
+
FieldType.CURRENCY: 8,
|
|
70
|
+
FieldType.NUMBER: 8,
|
|
71
|
+
FieldType.LOGICAL: 1,
|
|
72
|
+
FieldType.TIME: 4,
|
|
73
|
+
FieldType.TIMESTAMP: 8,
|
|
74
|
+
FieldType.AUTOINCREMENT: 4,
|
|
75
|
+
FieldType.BCD: 17,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#: Length of the pointer that closes every BLOB-like field inside the record.
|
|
79
|
+
BLOB_POINTER_SIZE = 10
|
|
80
|
+
|
|
81
|
+
#: DOS / Windows code pages found in the header extension and their Python codecs.
|
|
82
|
+
CODE_PAGES: dict[int, str] = {437: "cp437", 850: "cp850", 1252: "cp1252"}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def codec_for_code_page(code_page: int | None, default: str) -> str:
|
|
86
|
+
"""Return the Python codec for a Paradox code page, or ``default`` if unknown."""
|
|
87
|
+
if code_page is None:
|
|
88
|
+
return default
|
|
89
|
+
return CODE_PAGES.get(code_page, default)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def physical_size(field_type: FieldType, size_byte: int) -> int:
|
|
93
|
+
"""Number of bytes the field occupies in the record.
|
|
94
|
+
|
|
95
|
+
``size_byte`` is the second byte of the descriptor: a length for alpha,
|
|
96
|
+
bytes and BLOB fields, the number of decimals for BCD, ignored otherwise.
|
|
97
|
+
"""
|
|
98
|
+
fixed = _FIXED_SIZES.get(field_type)
|
|
99
|
+
if fixed is not None:
|
|
100
|
+
return fixed
|
|
101
|
+
return size_byte
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class FieldDef:
|
|
106
|
+
"""One column of the table, with its position inside the fixed-size record."""
|
|
107
|
+
|
|
108
|
+
name: str
|
|
109
|
+
type: FieldType
|
|
110
|
+
size_byte: int
|
|
111
|
+
offset: int
|
|
112
|
+
byte_length: int
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def decimals(self) -> int:
|
|
116
|
+
"""Number of decimals of a BCD field (``size_byte`` for that type)."""
|
|
117
|
+
return self.size_byte if self.type is FieldType.BCD else 0
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def leader_length(self) -> int:
|
|
121
|
+
"""Bytes of BLOB content kept inside the record, before the 10-byte pointer."""
|
|
122
|
+
if not self.type.is_blob:
|
|
123
|
+
return 0
|
|
124
|
+
return max(self.byte_length - BLOB_POINTER_SIZE, 0)
|
|
125
|
+
|
|
126
|
+
def slice(self, record: bytes) -> bytes:
|
|
127
|
+
return record[self.offset : self.offset + self.byte_length]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def validate_codec(name: str) -> str:
|
|
131
|
+
"""Normalise a codec name, raising ``LookupError`` if Python does not know it."""
|
|
132
|
+
return codecs.lookup(name).name
|