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 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
@@ -0,0 +1,10 @@
1
+ """Allow ``python -m paradoxdb ...`` as an alias of the ``paradoxdb`` console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__": # pragma: no cover
10
+ sys.exit(main())
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