diff --git a/.gitignore b/.gitignore index cddb2dc..505a3b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,10 @@ -.venv/ -access_parser.egg-info/ +# Python-generated files +__pycache__/ +*.py[oc] build/ -**/__pycache__/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/README.md b/README.md index 678a7c1..00421f0 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ Microsoft Access (.mdb / .accdb) database files parser. The parsing logic is ful # Installing Use pip: `pip install access-parser` -Or install manually: +Or install from source with uv: ```bash git clone https://github.com/ClarotyICS/access_parser.git cd access_parser -python3 setup.py install +uv sync ``` # Demo @@ -27,11 +27,42 @@ print(db.catalog) # Tables are stored as defaultdict(list) -- table[column][row_index] table = db.parse_table("table_name") +# Access values use their corresponding Python types. In particular: +# Date/Time -> datetime.datetime, GUID -> uuid.UUID, +# Currency and Numeric/Decimal -> decimal.Decimal. + # Pretty print all tables db.print_database() ``` +## SQL Queries + +Copy some or all parsed tables into a query-only, in-memory SQLite database: + +```python +db = AccessParser("/path/to/mdb/file.mdb") +sql = db.to_sqlite(["Customers", "Orders"]) + +rows = sql.execute( + """ + SELECT Customers.Name, SUM(Orders.Total) + FROM Customers + JOIN Orders ON Orders.CustomerID = Customers.ID + GROUP BY Customers.Name + """ +).fetchall() + +sql.close() +``` + +Omit the table list to import every table in `db.catalog`. The original Access +database is never modified; selected tables are fully parsed and copied into +memory before SQLite runs the query. Date/Time and GUID values are copied as +canonical text. Currency and Numeric/Decimal values are also copied as exact +text because SQLite has no arbitrary-precision decimal storage class; SQLite's +built-in numeric arithmetic may coerce those values to inexact floating point. + ### Known Issues * diff --git a/access_parser/__init__.py b/access_parser/__init__.py deleted file mode 100644 index 8802810..0000000 --- a/access_parser/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from access_parser.access_parser import AccessParser diff --git a/access_parser/parsing_primitives.py b/access_parser/parsing_primitives.py deleted file mode 100644 index 9feb9ac..0000000 --- a/access_parser/parsing_primitives.py +++ /dev/null @@ -1,261 +0,0 @@ -from construct import * - - -def version_specific(version, v3_subcon, v4_subcon): - """ - There are some differences in the parsing structure between v3 and v4. Some fields are different length and some - exist only in one of the versions. this returns the relevant parsing structure by version - :param version: int 3 or 4 - :param v3_subcon: the parsing struct if version is 3 - :param v4_subcon: the parsing struct if version is 4 - """ - if version == 3: - return v3_subcon - else: - return v4_subcon - - -ACCESSHEADER = Struct( - Const(b'\00\x01\x00\x00'), - "jet_string" / CString("utf8"), - "jet_version" / Int32ul, - # RC4 encrypted with key 0x6b39dac7. Database metadata - Padding(126)) - -MEMO = Struct( - "memo_length" / Int32ul, - "record_pointer" / Int32ul, - "memo_unknown" / Int32ul, - "memo_end" / Tell) - -VERSION_3_FLAGS = BitStruct( - "hyperlink" / Flag, - "auto_GUID" / Flag, - "unk_1" / Flag, - "replication" / Flag, - "unk_2" / Flag, - "autonumber" / Flag, - "can_be_null" / Flag, - "fixed_length" / Flag) - -VERSION_4_FLAGS = BitStruct( - "hyperlink" / Flag, - "auto_GUID" / Flag, - "unk_1" / Flag, - "replication" / Flag, - "unk_2" / Flag, - "autonumber" / Flag, - "can_be_null" / Flag, - "fixed_length" / Flag, - "unk_3" / Flag, - "unk_4" / Flag, - "unk_5" / Flag, - 'modern_package_type' / Flag, - "unk_6" / Flag, - "unk_7" / Flag, - "unk_8" / Flag, - "compressed_unicode" / Flag) - -TDEF_HEADER = Struct( - Const(b'\02\x01'), - "peek_version" / Peek(Int16ul), - "tdef_ver" / IfThenElse(lambda x: x.peek_version == b"VC", Const(b"VC"), Int16ul), - "next_page_ptr" / Int32ul, - "header_end" / Tell) - -LVPROP_CHUNK_NAMES_INT = Struct( - "name_length" / Int16ul, - "name" / PaddedString(this.name_length, "utf16"), -) -LVPROP_CHUNK_NAMES = Struct( - "names" / GreedyRange(LVPROP_CHUNK_NAMES_INT), - # "leftover" / GreedyBytes -) -LVPROP_DATA = Struct( - "data_length" / Int16ul, - "ddl_flag" / Int8ul, - "type" / Int8ul, - "name_index" / Int16ul, - "only_data_length" / Int16ul, - "actual_data" / Bytes(this.only_data_length) -) -LVPROP_VALUE = Struct( - "val_length" / Int32ul, - "name_length" / Int16ul, - "column_name" / PaddedString(this.name_length, "utf16"), - "data" / GreedyRange(LVPROP_DATA), - "left" / GreedyBytes -) - -LVPROP_CHUNK = Struct( - "length" / Int32ul, - "chunk_type" / Int16ul, - - "data" / Prefixed(Computed(this.length - 6), Switch(this.chunk_type, { - # 128: GreedyRange(LVPROP_CHUNK_NAMES) - 128: LVPROP_CHUNK_NAMES, - 0:LVPROP_VALUE, - 1: LVPROP_VALUE - }, default=Bytes(this.length - 4))) -) -LVPROP = Struct( - #'KKD\0' in Jet3 and 'MR2\0' in Jet 4. - "magic" / Bytes(4), - "chunks" / GreedyRange(LVPROP_CHUNK), - "leftover" / GreedyBytes -) - -def parse_table_head(buffer, version=3): - return Struct( - "TDEF_header" / TDEF_HEADER, - # Table - "table_definition_length" / Int32ul, - "ver4_unknown" / If(lambda x: version > 3, Int32ul), - "number_of_rows" / Int32ul, - "autonumber" / Int32ul, - "autonumber_increment" / If(lambda x: version > 3, Int32ul), - "complex_autonumber" / If(lambda x: version > 3, Int32ul), - "ver4_unknown_1" / If(lambda x: version > 3, Int32ul), - "ver4_unknown_2" / If(lambda x: version > 3, Int32ul), - # 0x53 system table - # 0x4e user table - "table_type_flags" / Int8ul, - "next_column_id" / Int16ul, - "variable_columns" / Int16ul, - "column_count" / Int16ul, - "index_count" / Int32ul, - "real_index_count" / Int32ul, - "row_page_map" / Int32ul, - "free_space_page_map" / Int32ul, - "tdef_header_end" / Tell).parse(buffer) - - -def parse_table_data(buffer, index_count, real_index_count, column_count, version=3): - REAL_INDEX = Struct( - "unk1" / Int32ul, - "index_row_count" / Int32ul, - "ver4_always_zero" / If(lambda x: version > 3, Int32ul)) - - VARIOUS_TEXT_V3 = Struct( - "LCID" / Int16ul, - "code_page" / Int16ul, - "various_text3_unknown" / Int16ul) - - VARIOUS_TEXT_V4 = Struct( - "collation" / Int16ul, - "various_text4_unknown" / Int8ul, - "collation_version_number" / Int8ul) - - VARIOUS_TEXT = VARIOUS_TEXT_V3 if version == 3 else VARIOUS_TEXT_V4 - - VARIOUS_DEC_V3 = Struct( - "various_dec3_unknown" / Int16ul, - "max_number_of_digits" / Int8ul, - "number_of_decimal" / Int8ul, - "various_dec3_unknown2" / Int16ul) - - VARIOUS_DEC_V4 = Struct( - "max_num_of_digits" / Int8ul, - "num_of_decimal_digits" / Int8ul, - "various_dec4_unknown" / Int16ul) - - VARIOUS_DEC = VARIOUS_DEC_V3 if version == 3 else VARIOUS_DEC_V4 - - VARIOUS_NUMERIC_V3 = Struct("prec" / Int8ul, "scale" / Int8ul, "unknown" / Int32ul) - VARIOUS_NUMERIC_V4 = Struct("prec" / Int8ul, "scale" / Int8ul, "unknown" / Int16ul) - VARIOUS_NUMERIC = VARIOUS_NUMERIC_V3 if version == 3 else VARIOUS_NUMERIC_V4 - - COLUMN = Struct( - "type" / Int8ul, - "ver4_unknown_3" / If(lambda x: version > 3, Int32ul), - "column_id" / Int16ul, - "variable_column_number" / Int16ul, - "column_index" / Int16ul, - "various" / Switch(lambda ctx: ctx.type, - { - 9: VARIOUS_TEXT, - 10: VARIOUS_TEXT, - 11: VARIOUS_TEXT, - 12: VARIOUS_TEXT, - 16: VARIOUS_NUMERIC, - - 1: VARIOUS_DEC, - 2: VARIOUS_DEC, - 3: VARIOUS_DEC, - 4: VARIOUS_DEC, - 5: VARIOUS_DEC, - 6: VARIOUS_DEC, - 7: VARIOUS_DEC, - 8: VARIOUS_DEC, - - }, default=version_specific(version, Bytes(6), Bytes(4))), - "column_flags" / version_specific(version, VERSION_3_FLAGS, VERSION_4_FLAGS), - "ver4_unknown_4" / If(lambda x: version > 3, Int32ul), - "fixed_offset" / Int16ul, - "length" / Int16ul) - - COLUMN_NAMES = Struct( - "col_name_len" / version_specific(version, Int8ul, Int16ul), - "col_name_str" / version_specific(version, - PaddedString(lambda x: x.col_name_len, encoding="utf8"), - PaddedString(lambda x: x.col_name_len, encoding="utf16")), - ) - - REAL_INDEX2 = Struct( - "unknown_b1" / If(lambda x: version > 3, Int32ul), - "unk_struct" / Array(10, Struct("col_id" / Int16ul, "idx_flags" / Int8ul)), - "runk" / Int32ul, - "first_index_page" / Int32ul, - "flags" / Int8ul, - "unknown_b3" / If(lambda x: version > 3, Padding(9))) - - ALL_INDEXES = Struct( - "unknown_c1" / If(lambda x: version > 3, Int32ul), - "idx_num" / Int32ul, - "idx_col_num" / Int32ul, - "rel_tbl_type" / Int8ul, - "rel_idx_num" / Int32sl, - "rel_tbl_page" / Int32ul, - "cascade_ups" / Int8ul, - "cascade_dels" / Int8ul, - "idx_type" / Int8ul, - "unknown_c2" / If(lambda x: version > 3, Int32ul)) - - INDEX_NAMES = Struct( - "idx_name_len" / version_specific(version, Int8ul, Int16ul), - "idx_name_str" / version_specific(version, - PaddedString(lambda x: x.idx_name_len, encoding="utf8"), - PaddedString(lambda x: x.idx_name_len, encoding="utf16")), - ) - - return Struct( - "real_index" / Array(real_index_count, REAL_INDEX), - "column" / Array(column_count, COLUMN), - "column_names" / Array(column_count, COLUMN_NAMES), - "real_index_2" / Array(real_index_count, REAL_INDEX2), - "all_indexes" / Array(index_count, ALL_INDEXES), - "index_names" / Array(index_count, INDEX_NAMES)).parse(buffer) - - -def parse_data_page_header(buffer, version=3): - return Struct( - Const(b"\x01\x01"), - "data_free_space" / Int16ul, - "owner" / Int32ul, - "ver4_unknown_dat1" / If(lambda x: version > 3, Int32ul), - "record_count" / Int16ul, - "record_offsets" / Array(lambda x: x.record_count, Int16ul)).parse(buffer) - - -# buffer should be the record data in reverse -def parse_relative_object_metadata_struct(buffer, variable_jump_tables_cnt=0, version=3): - return Struct( - "variable_length_field_count" / version_specific(version, Int8ub, Int16ub), - "variable_length_jump_table" / If(lambda x: version == 3, Array(variable_jump_tables_cnt, Int8ub)), - # This currently supports up to 255 columns for versions > 3 - "variable_length_field_offsets" / version_specific(version, - Array(lambda x: x.variable_length_field_count, Int8ub), - Array(lambda x: x.variable_length_field_count & 0xff, - Int16ub)), - "var_len_count" / version_specific(version, Int8ub, Int16ub), - "relative_metadata_end" / Tell).parse(buffer) diff --git a/access_parser/utils.py b/access_parser/utils.py deleted file mode 100644 index 0153be2..0000000 --- a/access_parser/utils.py +++ /dev/null @@ -1,218 +0,0 @@ -import logging -import os -import struct -import uuid -import math -from datetime import datetime, timedelta - -LOGGER = logging.getLogger("access_parser.utils") - - -TYPE_BOOLEAN = 1 -TYPE_INT8 = 2 -TYPE_INT16 = 3 -TYPE_INT32 = 4 -TYPE_MONEY = 5 -TYPE_FLOAT32 = 6 -TYPE_FLOAT64 = 7 -TYPE_DATETIME = 8 -TYPE_BINARY = 9 -TYPE_TEXT = 10 -TYPE_OLE = 11 -TYPE_MEMO = 12 -TYPE_GUID = 15 -TYPE_96_bit_17_BYTES = 16 -TYPE_COMPLEX = 18 - -TABLE_PAGE_MAGIC = b"\x02\x01" -DATA_PAGE_MAGIC = b"\x01\x01" - - -ACCESS_EPOCH = datetime(1899, 12, 30) - -PERCENT_DEFAULT = '0.00%' -EURO_DEFAULT = '€0.00' -DOLLAR_DEFAULT = '$0.00' -GENERAL_NUMBER_DEFAULT = '0' -FIXED_AND_STANDARD_DEFAULT = '0.00' -SCIENTIFIC_DEFAULT = '0.00E+00' - -FORMAT_PERCENT = "Percent" -FORMAT_DOLLAR = "$" -FORMAT_EURO = "€" -FORMAT_GENERAL_NUMBER = "General Number" -FORMAT_FIXED = "Fixed" -FORMAT_STANDARD = "Standard" -FORMAT_SCIENTIFIC = "Scientific" - -FORMAT_TO_DEFAULT_VALUE = { - FORMAT_DOLLAR: DOLLAR_DEFAULT, - FORMAT_STANDARD: FIXED_AND_STANDARD_DEFAULT, - FORMAT_FIXED: FIXED_AND_STANDARD_DEFAULT, - FORMAT_PERCENT: PERCENT_DEFAULT, - FORMAT_EURO: EURO_DEFAULT, - FORMAT_GENERAL_NUMBER: GENERAL_NUMBER_DEFAULT, - FORMAT_SCIENTIFIC: SCIENTIFIC_DEFAULT -} - - -# https://stackoverflow.com/questions/45560782 -def mdb_date_to_readable(double_time): - try: - dtime_bytes = struct.pack("Q", double_time) - - dtime_double = struct.unpack(' scale: - dot_len = len(full_number) - scale - full_number = full_number[:dot_len] + "." + full_number[dot_len:] - numeric_string = "-" if neg else "" - numeric_string += full_number - return numeric_string - - -def get_decoded_text(bytes_data): - try: - decoded = bytes_data.decode('utf-8') - except UnicodeDecodeError: - try: - decoded = bytes_data.decode('latin1') - except UnicodeDecodeError: - decoded = bytes_data.decode('utf-8', errors='ignore') - return decoded - - -def parse_money_type(parsed, prop_format): - """ - Parse and format a money value according to the specified format. - - Args: - parsed (int): The numerical value to be parsed. - prop_format (str): The format string specifying the desired format. - - Returns: - str: The parsed and formatted money value. - """ - parsed = str(parsed) - if prop_format == FORMAT_PERCENT: - special_format = "{:.2f}%" - dot_location = -2 - elif prop_format.startswith(FORMAT_DOLLAR): - special_format = '${:,.2f}' - dot_location = -4 - elif prop_format.startswith(FORMAT_EURO): - special_format = '€{:,.2f}' - dot_location = -4 - elif prop_format == FORMAT_GENERAL_NUMBER: - special_format = '{:,.1f}' - dot_location = -4 - elif prop_format == FORMAT_SCIENTIFIC: - special_format = '{:.2e}' - dot_location = -4 - elif prop_format in [FORMAT_FIXED, FORMAT_STANDARD]: - dot_location = -4 - special_format = '{:,.2f}' - else: - LOGGER.warning(f"parse_money_type - unsupported format: {prop_format} value {parsed} may be wrong") - return parsed - - money_float = parsed[:dot_location] + "." + parsed[dot_location:] - if special_format: - money_float = special_format.format(float(money_float)) - return money_float - - -def parse_type(data_type, buffer, length=None, version=3, props=None): - parsed = "" - # Bool or int8 - if data_type == TYPE_INT8: - parsed = struct.unpack_from("b", buffer)[0] - elif data_type == TYPE_INT16: - parsed = struct.unpack_from("h", buffer)[0] - elif data_type == TYPE_INT32 or data_type == TYPE_COMPLEX: - parsed = struct.unpack_from("i", buffer)[0] - elif data_type == TYPE_MONEY: - parsed = struct.unpack_from("q", buffer)[0] - if props and "Format" in props: - prop_format = props['Format'] - if parsed == 0: - parsed = [y for x, y in FORMAT_TO_DEFAULT_VALUE.items() if prop_format.startswith(x)] - if not parsed: - LOGGER.warning(f"parse_type got unknown format while parsing money field {prop_format}") - else: - parsed = parsed[0] - else: - parsed = parse_money_type(parsed, prop_format) - elif data_type == TYPE_FLOAT32: - parsed = struct.unpack_from("f", buffer)[0] - elif data_type == TYPE_FLOAT64: - parsed = struct.unpack_from("d", buffer)[0] - elif data_type == TYPE_DATETIME: - double_datetime = struct.unpack_from("q", buffer)[0] - parsed = mdb_date_to_readable(double_datetime) - elif data_type == TYPE_BINARY: - parsed = buffer[:length] - offset = length - elif data_type == TYPE_OLE: - parsed = buffer - elif data_type == TYPE_GUID: - parsed = buffer[:16] - guid = uuid.UUID(parsed.hex()) - parsed = str(guid) - elif data_type == TYPE_96_bit_17_BYTES: - parsed = buffer[:17] - elif data_type == TYPE_TEXT: - if version > 3: - # Looks like if BOM is present text is already decoded - if buffer.startswith(b"\xfe\xff") or buffer.startswith(b"\xff\xfe"): - buff = buffer[2:] - parsed = get_decoded_text(buff) - else: - parsed = buffer.decode("utf-16", errors='ignore') - else: - parsed = get_decoded_text(buffer) - - if "\x00" in parsed: - LOGGER.debug(f"Parsed string contains NUL (0x00) characters: {parsed}") - parsed = parsed.replace("\x00", "") - else: - LOGGER.debug(f"parse_type - unsupported data type: {data_type}") - return parsed - - -def categorize_pages(db_data, page_size): - if len(db_data) % page_size: - LOGGER.warning(f"DB is not full or PAGE_SIZE is wrong. page size: {page_size} DB length {len(db_data)}") - pages = {i: db_data[i:i + page_size] for i in range(0, len(db_data), page_size)} - data_pages = {} - table_defs = {} - for page in pages: - if pages[page].startswith(DATA_PAGE_MAGIC): - data_pages[page] = pages[page] - elif pages[page].startswith(TABLE_PAGE_MAGIC): - table_defs[page] = pages[page] - return table_defs, data_pages, pages - - -def read_db_file(path): - if not os.path.isfile(path): - LOGGER.error(f"File {path} not found") - raise FileNotFoundError(f"File {path} not found") - with open(path, "rb") as f: - return f.read() diff --git a/examples/parse_db.py b/examples/parse_db.py index 1d96ef7..06be22b 100644 --- a/examples/parse_db.py +++ b/examples/parse_db.py @@ -1,23 +1,33 @@ -from access_parser import AccessParser -from tabulate import tabulate +"""Print tables from an Access database.""" + import argparse +import os + +from tabulate import tabulate + +from access_parser import AccessParser -def print_tables(db_path, only_catalog=False, specific_table=None): +def print_tables( + db_path: str | os.PathLike[str], + only_catalog: bool = False, + specific_table: str | None = None, +) -> None: + """Print the catalog, one table, or the complete database.""" db = AccessParser(db_path) if only_catalog: - for k in db.catalog.keys(): + for k in db.catalog: print(f"{k}\n") elif specific_table: table = db.parse_table(specific_table) - print(f'TABLE NAME: {specific_table}\r\n') + print(f"TABLE NAME: {specific_table}\r\n") print(tabulate(table, headers="keys", disable_numparse=True)) print("\n\n\n\n") else: db.print_database() -if __name__ == '__main__': +if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("-c", "--catalog", required=False, help="Print DB table names", action="store_true") parser.add_argument("-f", "--file", required=True, help="*.mdb / *.accdb File") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bffcdfa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,90 @@ +[project] +name = "access-parser" +version = "0.1.0" +description = "Access database (*.mdb, *.accdb) parser" +readme = "README.md" +license = "Apache-2.0" +license-files = [ "LICENSE" ] +urls = { "source" = "https://github.com/ClarotyICS/access_parser" } +authors = [ + { name = "Uri Katz", email = "uri.k@claroty.com" }, + { name = "Alex Chandel", email = "637714+alexchandel@users.noreply.github.com" } +] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", +] +requires-python = ">=3.14" +dependencies = [ + "construct>=2.10.70", + "tabulate>=0.10.0", +] + +[build-system] +requires = ["uv_build>=0.12.5,<0.13.0"] +build-backend = "uv_build" + +[tool.ruff] +line-length = 120 +target-version = "py314" + +[tool.ruff.lint] +extend-select = [ + # correctness + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # Pyflakes errors + # best practices + "I", # isort import sorting + # "N", # pep8-naming + "D", # pydocstyle + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "S", # flake8-bandit + # "T20", # flake8-print => no print() + "PT", # flake8-pytest-style + "C90", # mccabe + "ANN", # type annotations + "ARG", # unused args + "RUF", # ruff rules +] + +ignore = [ + "BLE001", # just for now - catching blind exceptions + "D203", # conflicts with "D211" - class docstring blank line + "D213", # conflicts with "D212" - func docstring first line +] + +[tool.ruff.lint.isort] +combine-as-imports = true + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ + "BLE001", # catching blind exceptions + "D", # test names document behavior more clearly than repetitive docstrings + "S101", # assert +] + +[tool.pyright] +exclude = [ + "**/.*", + "**/node_modules", + ".venv", + ".cache", + "**/__pycache__", +] +pythonVersion = "3.14" +typeCheckingMode = "strict" +venvPath = "." +venv = ".venv" + +[tool.uv] +preview = true + +[dependency-groups] +dev = [ + "construct-typing>=0.8.1", + "pytest>=9.1.1", +] diff --git a/setup.py b/setup.py deleted file mode 100644 index f5c0879..0000000 --- a/setup.py +++ /dev/null @@ -1,26 +0,0 @@ -import setuptools - -with open("README.md", "r") as f: - long_description = f.read() - -setuptools.setup( - name="access_parser", - version="0.0.6", - author="Uri Katz", - author_email="uri.k@claroty.com", - description="Access database (*.mdb, *.accdb) parser", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/ClarotyICS/access_parser", - packages=setuptools.find_packages(), - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: Apache Software License", - "Operating System :: OS Independent", - ], - python_requires='>=3.6', - install_requires=[ - 'construct', - 'tabulate', - ], -) diff --git a/src/access_parser/__init__.py b/src/access_parser/__init__.py new file mode 100644 index 0000000..a1ab258 --- /dev/null +++ b/src/access_parser/__init__.py @@ -0,0 +1,5 @@ +"""Pure-Python Microsoft Access database parser.""" + +from .access_parser import AccessParser as AccessParser + +__all__ = ["AccessParser"] diff --git a/access_parser/access_parser.py b/src/access_parser/access_parser.py similarity index 56% rename from access_parser/access_parser.py rename to src/access_parser/access_parser.py index 2cdfc09..b474efb 100644 --- a/access_parser/access_parser.py +++ b/src/access_parser/access_parser.py @@ -1,14 +1,49 @@ +"""Public Access database parser.""" + import logging +import os +import sqlite3 import struct from collections import defaultdict +from collections.abc import Iterable +from datetime import datetime +from decimal import Decimal +from typing import cast +from uuid import UUID from construct import ConstructError from tabulate import tabulate -from .parsing_primitives import parse_relative_object_metadata_struct, parse_table_head, parse_data_page_header, \ - ACCESSHEADER, MEMO, parse_table_data, TDEF_HEADER, LVPROP -from .utils import categorize_pages, parse_type, TYPE_MEMO, TYPE_TEXT, TYPE_BOOLEAN, read_db_file, numeric_to_string, \ - TYPE_96_bit_17_BYTES, TYPE_OLE +from .parsing_primitives import ( + Column, + RelativeMetadata, + TableHeader, + parse_access_header, + parse_data_page_header, + parse_lvprop as parse_lvprop_value, + parse_memo, + parse_relative_object_metadata_struct, + parse_table_data, + parse_table_head, + parse_tdef_header, +) +from .utils import ( + TYPE_BOOLEAN, + TYPE_MEMO, + TYPE_NUMERIC, + TYPE_OLE, + TYPE_TEXT, + PageMap, + ParsedValue, + categorize_pages, + parse_type, + read_db_file, +) + +type ParsedTable = defaultdict[str, list[ParsedValue]] +type ColumnProperties = dict[str, ParsedValue] +type TableProperties = dict[str, ColumnProperties] +type ExtraProperties = dict[str, TableProperties | None] # Page sizes PAGE_SIZE_V3 = 0x800 @@ -28,69 +63,97 @@ LOGGER = logging.getLogger("access_parser") -class TableObj(object): - def __init__(self, offset, val): +def _quote_sqlite_identifier(identifier: str) -> str: + return '"' + identifier.replace('"', '""') + '"' + + +def _to_sqlite_value(value: ParsedValue) -> bool | bytes | float | int | str | None: + if isinstance(value, datetime): + return value.isoformat(sep=" ", timespec="microseconds") + if isinstance(value, (Decimal, UUID)): + return str(value) + return value + + +def _get_primary_keys(columns: list[Column], table_header: TableHeader) -> list[str]: + columns_by_id = {column.column_id: column for column in columns} + return [ + columns_by_id[index_column.col_id].col_name_str + for index in table_header.all_indexes + for index_column in table_header.real_index_2[index.idx_col_num].unk_struct + if index.idx_type == 1 and index_column.col_id != 0xFFFF + ] + + +class TableObj: + """A table-definition page and its linked data pages.""" + + def __init__(self, offset: int, val: bytes) -> None: + """Initialize a table object.""" self.value = val self.offset = offset - self.linked_pages = [] + self.linked_pages: list[bytes] = [] + +class AccessParser: + """Parse tables from a Microsoft Access database file.""" -class AccessParser(object): - def __init__(self, db_path): + def __init__(self, db_path: str | os.PathLike[str]) -> None: + """Read and index an Access database.""" self.db_data = read_db_file(db_path) + self.version: int + self.page_size: int self._parse_file_header(self.db_data) self._table_defs, self._data_pages, self._all_pages = categorize_pages(self.db_data, self.page_size) self._tables_with_data = self._link_tables_to_data() self.catalog = self._parse_catalog() self.extra_props = self.parse_msys_table() - def parse_msys_table(self): - """The MSysObjects contains extra metadata about tables and columns, like the Format of money field types """ + def parse_msys_table(self) -> ExtraProperties: + """Parse column metadata from MSysObjects.""" msys_table = self.parse_table("MSysObjects") - if not msys_table: - return None - if not msys_table.get('Name') or not msys_table.get('LvProp'): - return [] - table_to_lval_memo = {key: self.parse_lvprop(value) for key, value in zip(msys_table['Name'], - msys_table['LvProp']) if value} - return table_to_lval_memo + names = msys_table.get("Name") + values = msys_table.get("LvProp") + if not names or not values: + return {} - def _parse_file_header(self, db_data): - """ - Parse the basic file header and determine the Access DB version based on the parsing results. - :param db_data: db file data + properties: ExtraProperties = {} + for name, value in zip(names, values, strict=False): + if isinstance(name, str) and isinstance(value, bytes): + properties[name] = self.parse_lvprop(value) + return properties + + def _parse_file_header(self, db_data: bytes) -> None: + """Parse the basic file header and determine the Access DB version based on the parsing results. + + :param db_data: db file data. """ try: - head = ACCESSHEADER.parse(db_data) - except ConstructError: + head = parse_access_header(db_data) + except ConstructError as error: # This is a very minimal parsing of the header. If we fail this probable is not a valid mdb file - raise ValueError("Failed to parse DB file header. Check it is a valid access database") + raise ValueError("Failed to parse DB file header. Check it is a valid access database") from error version = head.jet_version if version in NEW_VERSIONS: - if version == VERSION_4: - self.version = ALL_VERSIONS[VERSION_4] - elif version == VERSION_5: - self.version = ALL_VERSIONS[VERSION_5] - elif version == VERSION_2010: - self.version = ALL_VERSIONS[VERSION_2010] + self.version = ALL_VERSIONS[version] self.page_size = PAGE_SIZE_V4 else: - if not version == VERSION_3: + if version != VERSION_3: LOGGER.error(f"Unknown database version {version} Trying to parse database as version 3") self.version = ALL_VERSIONS[VERSION_3] self.page_size = PAGE_SIZE_V3 LOGGER.info(f"DataBase version {version}") - def _link_tables_to_data(self): - """ - Link tables definitions to their data pages - :return: dict of {ofssets : PageObj} + def _link_tables_to_data(self) -> dict[int, TableObj]: + """Link tables definitions to their data pages. + + :return: dict of {ofssets : PageObj}. """ - tables_with_data = {} + tables_with_data: dict[int, TableObj] = {} # Link table definitions to data # the offset of the table definition page / 0x800 == the owner of a Data page - for offset, data in self._data_pages.items(): + for data in self._data_pages.values(): try: parsed_dp = parse_data_page_header(data, version=self.version) except ConstructError: @@ -98,36 +161,39 @@ def _link_tables_to_data(self): continue page_offset = parsed_dp.owner * self.page_size if page_offset in self._table_defs: - table_page_value = self._table_defs.get(parsed_dp.owner * self.page_size) if page_offset not in tables_with_data: - tables_with_data[page_offset] = TableObj(page_offset, table_page_value) + tables_with_data[page_offset] = TableObj(page_offset, self._table_defs[page_offset]) tables_with_data[page_offset].linked_pages.append(data) return tables_with_data - def _parse_catalog(self): - """ - Parse the catalog to get the DB tables and their offsets - :return: dict {table : offset} + def _parse_catalog(self) -> dict[str, int]: + """Parse the catalog to get the DB tables and their offsets. + + :return: dict {table : offset}. """ catalog_page = self._tables_with_data[2 * self.page_size] access_table = AccessTable(catalog_page, self.version, self.page_size, self._data_pages, self._table_defs) catalog = access_table.parse() - tables_mapping = {} - for i, table_name in enumerate(catalog['Name']): + names = cast("list[str]", catalog["Name"]) + ids = cast("list[int]", catalog["Id"]) + types = cast("list[int]", catalog["Type"]) + flags = cast("list[int]", catalog["Flags"]) + tables_mapping: dict[str, int] = {} + for table_name, table_id, table_type, table_flags in zip(names, ids, types, flags, strict=True): # We need the MSysObjects table for metadata so exclude it from the system table filter. if table_name == "MSysObjects": - tables_mapping[table_name] = catalog['Id'][i] + tables_mapping[table_name] = table_id # Visible user tables are type 1 - table_type = 1 - if catalog["Type"][i] == table_type: + if table_type == 1: # Don't parse system tables - if not catalog["Flags"][i] in SYSTEM_TABLE_FLAGS: - tables_mapping[table_name] = catalog['Id'][i] + if table_flags not in SYSTEM_TABLE_FLAGS: + tables_mapping[table_name] = table_id else: LOGGER.debug(f"Not parsing system table - {table_name}") return tables_mapping - def get_table(self, table_name): + def get_table(self, table_name: str) -> AccessTable | None: + """Return a parsed table definition, if it exists.""" table_offset = self.catalog.get(table_name) if not table_offset: LOGGER.error(f"Could not find table {table_name} in DataBase") @@ -144,28 +210,27 @@ def get_table(self, table_name): return # Try to get extra metadata for the table if it exists in the MSysObjects table - props = None - if table_name != "MSysObjects" and table_name in self.extra_props: - props = self.extra_props[table_name] + props = self.extra_props.get(table_name) if table_name != "MSysObjects" else None return AccessTable(table, self.version, self.page_size, self._data_pages, self._table_defs, props) - def parse_lvprop(self, lvprop_raw): + def parse_lvprop(self, lvprop_raw: bytes) -> TableProperties | None: + """Parse table and column properties from an LVPROP value.""" try: - parsed = LVPROP.parse(lvprop_raw) + parsed = parse_lvprop_value(lvprop_raw, version=self.version) except ConstructError: return None - if not parsed.get("chunks"): + if not parsed.chunks: return None table_names = [x.name for x in parsed.chunks[0].data.names] # Chunk type 0 does not have a column name, so we cannot link it to a column chunk_type_one = [x for x in parsed.chunks if x.chunk_type == 1] - reconstructed_column_data = {} + reconstructed_column_data: TableProperties = {} for chunk in chunk_type_one: if not chunk.data.column_name: LOGGER.error("Error while parsing MSysObjects table chunk.") continue - data_values = {} + data_values: ColumnProperties = {} for dv in chunk.data.data: val = parse_type(dv.type, dv.actual_data, version=self.version) try: @@ -177,51 +242,95 @@ def parse_lvprop(self, lvprop_raw): reconstructed_column_data[chunk.data.column_name] = data_values return reconstructed_column_data - def parse_table(self, table_name): - """ - Parse a table from the db. + def parse_table(self, table_name: str) -> ParsedTable: + """Parse a table from the db. + tables names are in self.catalog - :return defaultdict(list) with the parsed table -- table[column][row_index] + :return defaultdict(list) with the parsed table -- table[column][row_index]. """ - return self.get_table(table_name).parse() + table = self.get_table(table_name) + if table is None: + raise KeyError(f"Unknown table: {table_name}") + return table.parse() - def print_database(self): - """ - Print data from all database tables - """ + def to_sqlite(self, tables: Iterable[str] | None = None) -> sqlite3.Connection: + """Copy tables into a query-only, in-memory SQLite database.""" + connection = sqlite3.connect(":memory:") + table_names = self.catalog if tables is None else tables + + try: + with connection: + for table_name in table_names: + table = self.parse_table(table_name) + columns = list(table) + quoted_table = _quote_sqlite_identifier(table_name) + definitions = ", ".join(_quote_sqlite_identifier(column) for column in columns) + connection.execute(f"CREATE TABLE {quoted_table} ({definitions})") + + if columns: + placeholders = ", ".join("?" for _ in columns) + rows = ( + tuple(_to_sqlite_value(value) for value in row) + for row in zip(*(table[column] for column in columns), strict=True) + ) + # Table names are quoted as SQLite identifiers above. + insert_sql = f"INSERT INTO {quoted_table} VALUES ({placeholders})" # noqa: S608 + connection.executemany(insert_sql, rows) + + connection.execute("PRAGMA query_only = ON") + except Exception: + connection.close() + raise + + return connection + + def print_database(self) -> None: + """Print data from all database tables.""" table_names = self.catalog for table_name in table_names: table = self.parse_table(table_name) if not table: continue - print(f'TABLE NAME: {table_name}\r\n') + print(f"TABLE NAME: {table_name}\r\n") print(tabulate(table, headers="keys", disable_numparse=True)) - print('\r\n\r\n\r\n\r\n') - - -class AccessTable(object): - def __init__(self, table, version, page_size, data_pages, table_defs, props=None): + print("\r\n\r\n\r\n\r\n") + + +class AccessTable: + """Decode rows using a parsed Access table definition.""" + + def __init__( + self, + table: TableObj, + version: int, + page_size: int, + data_pages: PageMap, + table_defs: PageMap, + props: TableProperties | None = None, + ) -> None: + """Initialize an Access table decoder.""" self.version = version self.props = props self.page_size = page_size self._data_pages = data_pages self._table_defs = table_defs self.table = table - self.parsed_table = defaultdict(list) + self.parsed_table: ParsedTable = defaultdict(list) self.columns, self.primary_keys, self.table_header = self._get_table_columns() - def create_empty_table(self): - parsed_table = defaultdict(list) - columns, *_ = self._get_table_columns() - for i, column in columns.items(): - parsed_table[column.col_name_str] = "" + def create_empty_table(self) -> ParsedTable: + """Create an empty result containing every table column.""" + parsed_table: ParsedTable = defaultdict(list) + for column in self.columns.values(): + parsed_table[column.col_name_str] = [] return parsed_table - def parse(self): - """ - This is the main table parsing function. We go through all of the data pages linked to the table, separate each + def parse(self) -> ParsedTable: + """Parse (main table parsing function). + + We go through all of the data pages linked to the table, separate each data page to rows(records) and parse each record. - :return defaultdict(list) with the parsed data -- table[column][row_index] + :return defaultdict(list) with the parsed data -- table[column][row_index]. """ if not self.table.linked_pages: return self.create_empty_table() @@ -229,20 +338,20 @@ def parse(self): original_data = data_chunk parsed_data = parse_data_page_header(original_data, version=self.version) - last_offset = None + last_offset: int | None = None for rec_offset in parsed_data.record_offsets: # Deleted row - Just skip it if rec_offset & 0x8000: - last_offset = rec_offset & 0xfff + last_offset = rec_offset & 0xFFF continue # Overflow page if rec_offset & 0x4000: # overflow ptr is 4 bits flags, 12 bits ptr - rec_ptr_offset = rec_offset & 0xfff + rec_ptr_offset = rec_offset & 0xFFF # update last pointer to pointer without flags last_offset = rec_ptr_offset # The ptr is the offset in the current data page. we get a 4 byte record_pointer from that - overflow_rec_ptr = original_data[rec_ptr_offset:rec_ptr_offset + 4] + overflow_rec_ptr = original_data[rec_ptr_offset : rec_ptr_offset + 4] overflow_rec_ptr = struct.unpack(" None: + """Parse record (row) of data. + + First parse all fixed-length data field and then parse the relative length data. :param record: the current row data :return: """ @@ -269,20 +379,20 @@ def _parse_row(self, record): # Records contain null bitmaps for columns. The number of bitmaps is the number of columns / 8 rounded up null_table_len = (self.table_header.column_count + 7) // 8 if null_table_len and null_table_len < len(original_record): - null_table = record[-null_table_len:] + null_bytes = record[-null_table_len:] # Turn bitmap to a list of True False values - null_table = [((null_table[i // 8]) & (1 << (i % 8))) != 0 for i in range(len(null_table) * 8)] + null_table = [(null_bytes[i // 8] & (1 << (i % 8))) != 0 for i in range(len(null_bytes) * 8)] else: LOGGER.error(f"Failed to parse null table column count {self.table_header.column_count}") return if self.version > 3: - field_count = struct.unpack_from("h", record)[0] + struct.unpack_from("h", record)[0] record = record[2:] else: - field_count = struct.unpack_from("b", record)[0] + struct.unpack_from("b", record)[0] record = record[1:] - relative_records_column_map = {} + relative_records_column_map: dict[int, Column] = {} # Iterate columns for i, column in self.columns.items(): # Fixed length columns are handled before variable length. If this is a variable length column add it to @@ -294,19 +404,23 @@ def _parse_row(self, record): self._parse_fixed_length_data(record, column, null_table) if relative_records_column_map: relative_records_column_map = dict(sorted(relative_records_column_map.items())) - metadata = self._parse_dynamic_length_records_metadata(reverse_record, original_record, - null_table_len) + metadata = self._parse_dynamic_length_records_metadata(reverse_record, original_record, null_table_len) if not metadata: return if metadata.variable_length_field_offsets: self._parse_dynamic_length_data(original_record, metadata, relative_records_column_map, null_table) - def _parse_fixed_length_data(self, original_record, column, null_table): - """ - Parse fixed-length data from record + def _parse_fixed_length_data( + self, + original_record: bytes, + column: Column, + null_table: list[bool], + ) -> None: + """Parse fixed-length data from record. + :param original_record: unmodified record :param column: column this data belongs to - :param null_table: null table of the row + :param null_table: null table of the row. """ column_name = column.col_name_str # The null table indicates null values in the row. @@ -322,24 +436,36 @@ def _parse_fixed_length_data(self, original_record, column, null_table): if column.type == TYPE_BOOLEAN: parsed_type = has_value else: - if column.fixed_offset > len(original_record): - LOGGER.error(f"Column offset is bigger than the length of the record {column.fixed_offset}") - return - record = original_record[column.fixed_offset:] - parsed_type = parse_type(column.type, record, version=self.version, props=column.extra_props or None) if not has_value: self.parsed_table[column_name].append(None) return + if column.fixed_offset > len(original_record): + LOGGER.error(f"Column offset is bigger than the length of the record {column.fixed_offset}") + return + record = original_record[column.fixed_offset :] + scale = column.various.get("scale", 0) + parsed_type = parse_type( + column.type, + record, + version=self.version, + scale=scale if isinstance(scale, int) else 0, + ) self.parsed_table[column_name].append(parsed_type) - def _parse_dynamic_length_records_metadata(self, reverse_record, original_record, null_table_length): - """ - parse the metadata of relative records. The metadata used to parse relative records is found at the end of the + def _parse_dynamic_length_records_metadata( + self, + reverse_record: bytes, + original_record: bytes, + null_table_length: int, + ) -> RelativeMetadata | None: + """Parse the metadata of relative records. + + The metadata used to parse relative records is found at the end of the record so reverse_record is used for parsing from the bottom up. :param reverse_record: original record in reverse :param original_record: unmodified record :param null_table_length: - :return: parsed relative record metadata + :return: parsed relative record metadata. """ if self.version > 3: reverse_record = reverse_record[null_table_length:] @@ -349,19 +475,22 @@ def _parse_dynamic_length_records_metadata(self, reverse_record, original_record variable_length_jump_table_cnt = (len(original_record) - 1) // 256 reverse_record = reverse_record[null_table_length:] try: - relative_record_metadata = parse_relative_object_metadata_struct(reverse_record, - variable_length_jump_table_cnt, - self.version) + relative_record_metadata = parse_relative_object_metadata_struct( + reverse_record, variable_length_jump_table_cnt, self.version + ) # relative_record_metadata = RELATIVE_OBJS.parse(reverse_record) # we use this offset in original_record so we have to update the length with the null_tables - relative_record_metadata.relative_metadata_end = relative_record_metadata.relative_metadata_end + null_table_length + relative_record_metadata.relative_metadata_end = ( + relative_record_metadata.relative_metadata_end + null_table_length + ) except ConstructError: relative_record_metadata = None LOGGER.error("Failed parsing record") - if relative_record_metadata and \ - relative_record_metadata.variable_length_field_count != self.table_header.variable_columns: - + if ( + relative_record_metadata + and relative_record_metadata.variable_length_field_count != self.table_header.variable_columns + ): # best effort - try to find variable column count in the record and parse from there # this is limited to the 10 first bytes to reduce false positives. # most of the time iv'e seen this there was an extra DWORD before the actual metadata @@ -369,31 +498,44 @@ def _parse_dynamic_length_records_metadata(self, reverse_record, original_record if metadata_start != -1 and metadata_start < 10: reverse_record = reverse_record[metadata_start:] try: - relative_record_metadata = parse_relative_object_metadata_struct(reverse_record, - variable_length_jump_table_cnt, - self.version) + relative_record_metadata = parse_relative_object_metadata_struct( + reverse_record, variable_length_jump_table_cnt, self.version + ) except ConstructError: LOGGER.error(f"Failed to parse record metadata: {original_record}") - relative_record_metadata.relative_metadata_end = relative_record_metadata.relative_metadata_end + \ - metadata_start + relative_record_metadata.relative_metadata_end = ( + relative_record_metadata.relative_metadata_end + metadata_start + ) else: LOGGER.warning( f"Record did not parse correctly. Number of columns: {self.table_header.variable_columns}" - f" number of parsed columns: {relative_record_metadata.variable_length_field_count}") + f" number of parsed columns: {relative_record_metadata.variable_length_field_count}" + ) return None return relative_record_metadata - def _parse_dynamic_length_data(self, original_record, relative_record_metadata, - relative_records_column_map, null_table): - """ - Parse dynamic (non fixed length) records from row + def _parse_dynamic_length_data( + self, + original_record: bytes, + relative_record_metadata: RelativeMetadata, + relative_records_column_map: dict[int, Column], + null_table: list[bool], + ) -> None: + """Parse dynamic (non fixed length) records from row. + :param original_record: full unmodified record :param relative_record_metadata: parsed record metadata :param relative_records_column_map: relative records colum mapping {index: column} - :param null_table: list indicating which columns have null value + :param null_table: list indicating which columns have null value. """ relative_offsets = relative_record_metadata.variable_length_field_offsets - jump_table_addition = 0 + jump_table = relative_record_metadata.variable_length_jump_table or [] + + def absolute_offset(offset: int, index: int) -> int: + if self.version != 3: + return offset + return offset + 0x100 * sum(jump_index <= index for jump_index in jump_table) + for i, column_index in enumerate(relative_records_column_map): column = relative_records_column_map[column_index] col_name = column.col_name_str @@ -406,54 +548,48 @@ def _parse_dynamic_length_data(self, original_record, relative_record_metadata, self.parsed_table[col_name].append(None) continue - if self.version == 3: - if i in relative_record_metadata.variable_length_jump_table: - jump_table_addition += 0x100 - rel_start = relative_offsets[i] + rel_start = absolute_offset(relative_offsets[i], i) # If this is the last one use var_len_count as end offset if i + 1 == len(relative_offsets): - rel_end = relative_record_metadata.var_len_count + rel_end = absolute_offset(relative_record_metadata.var_len_count, i + 1) else: - rel_end = relative_offsets[i + 1] + rel_end = absolute_offset(relative_offsets[i + 1], i + 1) # if rel_start and rel_end are the same there is no data in this slot if rel_start == rel_end: self.parsed_table[col_name].append("") continue - relative_obj_data = original_record[rel_start + jump_table_addition: rel_end + jump_table_addition] - # Parse types that require column data here, call parse_type on all other types - if column.type == TYPE_MEMO: - try: - parsed_type = self._parse_memo(relative_obj_data) - except ConstructError: - LOGGER.warning("Failed to parse memo field. Using data as bytes") - parsed_type = relative_obj_data - elif column.type == TYPE_OLE: - try: - parsed_type = self._parse_memo(relative_obj_data, return_raw=True) - except ConstructError: - LOGGER.warning("Failed to parse OLE field. Using data as bytes") - parsed_type = relative_obj_data - elif column.type == TYPE_96_bit_17_BYTES: - if len(relative_obj_data) != 17: - LOGGER.warning(f"Relative numeric field has invalid length {len(relative_obj_data)}, expected 17") - parsed_type = relative_obj_data - else: - # Get scale or None - scale = column.get('various', {}).get('scale', 6) - parsed_type = numeric_to_string(relative_obj_data, scale) - else: - parsed_type = parse_type(column.type, relative_obj_data, len(relative_obj_data), version=self.version) - self.parsed_table[col_name].append(parsed_type) + relative_obj_data = original_record[rel_start:rel_end] + self.parsed_table[col_name].append(self._parse_variable_value(column, relative_obj_data)) - def _get_table_columns(self): - """ - Parse columns for a specific table - """ + def _parse_variable_value(self, column: Column, data: bytes) -> ParsedValue: + if column.type in {TYPE_MEMO, TYPE_OLE}: + try: + return self._parse_memo(data, return_raw=column.type == TYPE_OLE) + except ConstructError: + LOGGER.warning("Failed to parse memo or OLE field. Using data as bytes") + return data + if column.type != TYPE_NUMERIC: + return parse_type(column.type, data, len(data), version=self.version) + if len(data) != 17: + LOGGER.warning(f"Relative numeric field has invalid length {len(data)}, expected 17") + return data + + scale = column.various.get("scale", 6) + return parse_type( + column.type, + data, + len(data), + version=self.version, + scale=scale if isinstance(scale, int) else 6, + ) + + def _get_table_columns(self) -> tuple[dict[int, Column], list[str], TableHeader]: + """Parse columns for a specific table.""" try: table_header = parse_table_head(self.table.value, version=self.version) - merged_data = self.table.value[table_header.tdef_header_end:] + merged_data = self.table.value[table_header.tdef_header_end :] if table_header.TDEF_header.next_page_ptr: merged_data = merged_data + self._merge_table_data(table_header.TDEF_header.next_page_ptr) @@ -466,15 +602,15 @@ def _get_table_columns(self): ) # Merge Data back to table_header - table_header['column'] = parsed_data['column'] - table_header['column_names'] = parsed_data['column_names'] - table_header['real_index_2'] = parsed_data['real_index_2'] - table_header["all_indexes"] = parsed_data["all_indexes"] - table_header["index_names"] = parsed_data["index_names"] + table_header.column = parsed_data.column + table_header.column_names = parsed_data.column_names + table_header.real_index_2 = parsed_data.real_index_2 + table_header.all_indexes = parsed_data.all_indexes + table_header.index_names = parsed_data.index_names - except ConstructError: + except ConstructError as error: LOGGER.error(f"Failed to parse table header {self.table.value}") - return + raise ValueError("Failed to parse table header") from error col_names = table_header.column_names columns = table_header.column @@ -496,55 +632,54 @@ def _get_table_columns(self): # Add the extra properties relevant for the column if self.props: - for i, col in column_dict.items(): + for col in column_dict.values(): if col.col_name_str in self.props: col.extra_props = self.props[col.col_name_str] - primary_keys = [ - column_dict[col.col_id].col_name_str - for idx in table_header.all_indexes - for col in table_header.real_index_2[idx.idx_col_num].unk_struct - if idx.idx_type == 1 and col.col_id ^ 0xFFFF - ] + primary_keys = _get_primary_keys(columns, table_header) if len(column_dict) != table_header.column_count: LOGGER.debug(f"expected {table_header.column_count} columns got {len(column_dict)}") return column_dict, primary_keys, table_header - def _merge_table_data(self, first_page): - """ - Merege data of tdef pages in case the data does not fit in one page + def _merge_table_data(self, first_page: int) -> bytes: + """Merge data of tdef pages in case the data does not fit in one page. + :param first_page: index of the next page - :return: merged data from all linked table definitions + :return: merged data from all linked table definitions. """ - table = self._table_defs.get(first_page * self.page_size) - parsed_header = TDEF_HEADER.parse(table) - data = table[parsed_header.header_end:] + table = self._table_defs[first_page * self.page_size] + parsed_header = parse_tdef_header(table) + data = table[parsed_header.header_end :] while parsed_header.next_page_ptr: - table = self._table_defs.get(parsed_header.next_page_ptr * self.page_size) - parsed_header = TDEF_HEADER.parse(table) - data = data + table[parsed_header.header_end:] + table = self._table_defs[parsed_header.next_page_ptr * self.page_size] + parsed_header = parse_tdef_header(table) + data = data + table[parsed_header.header_end :] return data - def _parse_memo(self, relative_obj_data, return_raw=False): + def _parse_memo(self, relative_obj_data: bytes, return_raw: bool = False) -> ParsedValue: LOGGER.debug(f"Parsing memo field {relative_obj_data}") - parsed_memo = MEMO.parse(relative_obj_data) + parsed_memo = parse_memo(relative_obj_data) memo_type = TYPE_TEXT if parsed_memo.memo_length & 0x80000000: LOGGER.debug("memo data inline") inline_memo_length = parsed_memo.memo_length & 0x3FFFFFFF if len(relative_obj_data) < parsed_memo.memo_end + inline_memo_length: LOGGER.warning("Inline memo field has invalid length using full data") - memo_data = relative_obj_data[parsed_memo.memo_end:] + memo_data = relative_obj_data[parsed_memo.memo_end :] else: - memo_data = relative_obj_data[parsed_memo.memo_end:parsed_memo.memo_end + inline_memo_length] + memo_data = relative_obj_data[parsed_memo.memo_end : parsed_memo.memo_end + inline_memo_length] elif parsed_memo.memo_length & 0x40000000: LOGGER.debug("LVAL type 1") memo_data = self._get_overflow_record(parsed_memo.record_pointer) + if memo_data is None: + return None else: LOGGER.debug("LVAL type 2") rec_data = self._get_overflow_record(parsed_memo.record_pointer) + if rec_data is None: + return None next_page = struct.unpack("I", rec_data[:4])[0] # LVAL2 has data over multiple pages. The first 4 bytes of the page are the next record, then that data. # Concat the data until we get a 0 next_page. @@ -552,6 +687,8 @@ def _parse_memo(self, relative_obj_data, return_raw=False): while next_page: memo_data += rec_data[4:] rec_data = self._get_overflow_record(next_page) + if rec_data is None: + return None next_page = struct.unpack("I", rec_data[:4])[0] memo_data += rec_data[4:] if memo_data: @@ -560,32 +697,34 @@ def _parse_memo(self, relative_obj_data, return_raw=False): parsed_type = parse_type(memo_type, memo_data, len(memo_data), version=self.version) return parsed_type - def _get_overflow_record(self, record_pointer): - """ - Get the actual record from a record pointer + return None + + def _get_overflow_record(self, record_pointer: int) -> bytes | None: + """Get the actual record from a record pointer. + :param record_pointer: - :return: record + :return: record. """ - record_offset = record_pointer & 0xff + record_offset = record_pointer & 0xFF page_num = record_pointer >> 8 record_page = self._data_pages.get(page_num * self.page_size) if not record_page: LOGGER.warning(f"Could not find overflow record data page overflow pointer: {record_pointer}") return parsed_data = parse_data_page_header(record_page, version=self.version) - if record_offset > len(parsed_data.record_offsets): + if record_offset >= len(parsed_data.record_offsets): LOGGER.warning("Failed parsing overflow record offset") return start = parsed_data.record_offsets[record_offset] if start & 0x8000: - start = start & 0xfff + start = start & 0xFFF else: LOGGER.debug(f"Overflow record flag is not present {start}") if record_offset == 0: record = record_page[start:] else: end = parsed_data.record_offsets[record_offset - 1] - if end & 0x8000 and (end & 0xff != 0): - end = end & 0xfff - record = record_page[start: end] + if end & 0x8000 and (end & 0xFF != 0): + end = end & 0xFFF + record = record_page[start:end] return record diff --git a/src/access_parser/parsing_primitives.py b/src/access_parser/parsing_primitives.py new file mode 100644 index 0000000..486ad63 --- /dev/null +++ b/src/access_parser/parsing_primitives.py @@ -0,0 +1,490 @@ +"""Binary structures used by the Access database parser.""" + +from collections.abc import Mapping +from typing import Protocol, cast + +from construct import ( + Array, + BitStruct, + Bytes, + Computed, + Const, + Construct, + CString, + Flag, + GreedyBytes, + GreedyRange, + If, + IfThenElse, + Int8ub, + Int8ul, + Int16ub, + Int16ul, + Int32sl, + Int32ul, + PaddedString, + Padding, + Peek, + Prefixed, + Struct, + Switch, + Tell, + this, +) + +from .utils import ParsedValue + + +class AccessHeader(Protocol): + """Parsed database header fields used by the parser.""" + + jet_version: int + + +class TDefHeader(Protocol): + """Parsed table-definition linkage header.""" + + next_page_ptr: int + header_end: int + + +class ColumnFlags(Protocol): + """Column flags used while decoding rows.""" + + fixed_length: bool + + +class Column(Protocol): + """Parsed column definition used while decoding rows.""" + + type: int + column_id: int + column_index: int + fixed_offset: int + column_flags: ColumnFlags + various: Mapping[str, object] + col_name_str: str + extra_props: Mapping[str, ParsedValue] | None + + +class ColumnName(Protocol): + """Parsed column name.""" + + col_name_str: str + + +class IndexColumn(Protocol): + """Column reference within an index.""" + + col_id: int + + +class RealIndex(Protocol): + """Parsed real-index definition.""" + + unk_struct: list[IndexColumn] + + +class TableIndex(Protocol): + """Parsed table index.""" + + idx_col_num: int + idx_type: int + + +class TableData(Protocol): + """Parsed columns and indexes following a table header.""" + + column: list[Column] + column_names: list[ColumnName] + real_index_2: list[RealIndex] + all_indexes: list[TableIndex] + index_names: list[object] + + +class TableHeader(TableData, Protocol): + """Parsed table header and its attached column/index definitions.""" + + TDEF_header: TDefHeader + tdef_header_end: int + number_of_rows: int + variable_columns: int + column_count: int + index_count: int + real_index_count: int + + +class DataPageHeader(Protocol): + """Parsed data-page fields used by the parser.""" + + owner: int + record_offsets: list[int] + + +class RelativeMetadata(Protocol): + """Offsets for variable-length fields in a record.""" + + variable_length_field_count: int + variable_length_jump_table: list[int] | None + variable_length_field_offsets: list[int] + var_len_count: int + relative_metadata_end: int + + +class LvPropName(Protocol): + """Name stored in an LVPROP name chunk.""" + + name: str + + +class LvPropDatum(Protocol): + """Value stored in an LVPROP data chunk.""" + + type: int + name_index: int + actual_data: bytes + + +class LvPropChunkData(Protocol): + """Payload of an LVPROP chunk.""" + + names: list[LvPropName] + column_name: str + data: list[LvPropDatum] + + +class LvPropChunk(Protocol): + """Parsed LVPROP chunk.""" + + chunk_type: int + data: LvPropChunkData + + +class LvProp(Protocol): + """Parsed collection of LVPROP chunks.""" + + chunks: list[LvPropChunk] + + +class Memo(Protocol): + """Parsed memo field header.""" + + memo_length: int + record_pointer: int + memo_end: int + + +def version_specific[ParsedT, BuildT]( + version: int, + v3_subcon: Construct[ParsedT, BuildT], + v4_subcon: Construct[ParsedT, BuildT], +) -> Construct[ParsedT, BuildT]: + """There are some differences in the parsing structure between v3 and v4. + + Some fields are different length and some + exist only in one of the versions. this returns the relevant parsing structure by version + :param version: int 3 or 4 + :param v3_subcon: the parsing struct if version is 3 + :param v4_subcon: the parsing struct if version is 4. + """ + return v3_subcon if version == 3 else v4_subcon + + +_ACCESS_HEADER = Struct( + Const(b"\00\x01\x00\x00"), + "jet_string" / CString("utf8"), + "jet_version" / Int32ul, + # RC4 encrypted with key 0x6b39dac7. Database metadata + Padding(126), +) + +_MEMO = Struct("memo_length" / Int32ul, "record_pointer" / Int32ul, "memo_unknown" / Int32ul, "memo_end" / Tell) + +VERSION_3_FLAGS = BitStruct( + "hyperlink" / Flag, + "auto_GUID" / Flag, + "unk_1" / Flag, + "replication" / Flag, + "unk_2" / Flag, + "autonumber" / Flag, + "can_be_null" / Flag, + "fixed_length" / Flag, +) + +VERSION_4_FLAGS = BitStruct( + "hyperlink" / Flag, + "auto_GUID" / Flag, + "unk_1" / Flag, + "replication" / Flag, + "unk_2" / Flag, + "autonumber" / Flag, + "can_be_null" / Flag, + "fixed_length" / Flag, + "unk_3" / Flag, + "unk_4" / Flag, + "unk_5" / Flag, + "modern_package_type" / Flag, + "unk_6" / Flag, + "unk_7" / Flag, + "unk_8" / Flag, + "compressed_unicode" / Flag, +) + +_TDEF_HEADER = Struct( + Const(b"\02\x01"), + "peek_version" / Peek(Int16ul), + "tdef_ver" / IfThenElse(this.peek_version == b"VC", Const(b"VC"), Int16ul), + "next_page_ptr" / Int32ul, + "header_end" / Tell, +) + + +def _make_lvprop_parser(encoding: str) -> Struct: + chunk_names = Struct( + "names" + / GreedyRange( + Struct( + "name_length" / Int16ul, + "name" / PaddedString(this.name_length, encoding), + ) + ), + ) + data = Struct( + "data_length" / Int16ul, + "ddl_flag" / Int8ul, + "type" / Int8ul, + "name_index" / Int16ul, + "only_data_length" / Int16ul, + "actual_data" / Bytes(this.only_data_length), + ) + value = Struct( + "val_length" / Int32ul, + "name_length" / Int16ul, + "column_name" / PaddedString(this.name_length, encoding), + "data" / GreedyRange(data), + "left" / GreedyBytes, + ) + chunk = Struct( + "length" / Int32ul, + "chunk_type" / Int16ul, + "data" + / Prefixed( + cast("Construct[int, int]", Computed(this.length - 6)), + Switch(this.chunk_type, {128: chunk_names, 0: value, 1: value}, default=Bytes(this.length - 4)), + ), + ) + return Struct( + # 'KKD\0' in Jet3 and 'MR2\0' in Jet 4. + "magic" / Bytes(4), + "chunks" / GreedyRange(chunk), + "leftover" / GreedyBytes, + ) + + +_LVPROP_V3 = _make_lvprop_parser("utf8") +_LVPROP_V4 = _make_lvprop_parser("utf16") + + +def parse_access_header(buffer: bytes) -> AccessHeader: + """Parse the database header.""" + return cast("AccessHeader", _ACCESS_HEADER.parse(buffer)) + + +def parse_memo(buffer: bytes) -> Memo: + """Parse a memo field header.""" + return cast("Memo", _MEMO.parse(buffer)) + + +def parse_tdef_header(buffer: bytes) -> TDefHeader: + """Parse a table-definition linkage header.""" + return cast("TDefHeader", _TDEF_HEADER.parse(buffer)) + + +def parse_lvprop(buffer: bytes, version: int = 4) -> LvProp: + """Parse an LVPROP metadata value.""" + parser = _LVPROP_V3 if version == 3 else _LVPROP_V4 + return cast("LvProp", parser.parse(buffer)) + + +def parse_table_head(buffer: bytes, version: int = 3) -> TableHeader: + """Parse a table-definition page header.""" + parser = Struct( + "TDEF_header" / _TDEF_HEADER, + # Table + "table_definition_length" / Int32ul, + "ver4_unknown" / If(version > 3, Int32ul), + "number_of_rows" / Int32ul, + "autonumber" / Int32ul, + "autonumber_increment" / If(version > 3, Int32ul), + "complex_autonumber" / If(version > 3, Int32ul), + "ver4_unknown_1" / If(version > 3, Int32ul), + "ver4_unknown_2" / If(version > 3, Int32ul), + # 0x53 system table + # 0x4e user table + "table_type_flags" / Int8ul, + "next_column_id" / Int16ul, + "variable_columns" / Int16ul, + "column_count" / Int16ul, + "index_count" / Int32ul, + "real_index_count" / Int32ul, + "row_page_map" / Int32ul, + "free_space_page_map" / Int32ul, + "tdef_header_end" / Tell, + ) + return cast("TableHeader", parser.parse(buffer)) + + +def parse_table_data( + buffer: bytes, + index_count: int, + real_index_count: int, + column_count: int, + version: int = 3, +) -> TableData: + """Parse the columns and indexes following a table header.""" + REAL_INDEX = Struct("unk1" / Int32ul, "index_row_count" / Int32ul, "ver4_always_zero" / If(version > 3, Int32ul)) + + VARIOUS_TEXT_V3 = Struct("LCID" / Int16ul, "code_page" / Int16ul, "various_text3_unknown" / Int16ul) + + VARIOUS_TEXT_V4 = Struct( + "collation" / Int16ul, "various_text4_unknown" / Int8ul, "collation_version_number" / Int8ul + ) + + VARIOUS_TEXT = VARIOUS_TEXT_V3 if version == 3 else VARIOUS_TEXT_V4 + + VARIOUS_DEC_V3 = Struct( + "various_dec3_unknown" / Int16ul, + "max_number_of_digits" / Int8ul, + "number_of_decimal" / Int8ul, + "various_dec3_unknown2" / Int16ul, + ) + + VARIOUS_DEC_V4 = Struct( + "max_num_of_digits" / Int8ul, "num_of_decimal_digits" / Int8ul, "various_dec4_unknown" / Int16ul + ) + + VARIOUS_DEC = VARIOUS_DEC_V3 if version == 3 else VARIOUS_DEC_V4 + + VARIOUS_NUMERIC_V3 = Struct("prec" / Int8ul, "scale" / Int8ul, "unknown" / Int32ul) + VARIOUS_NUMERIC_V4 = Struct("prec" / Int8ul, "scale" / Int8ul, "unknown" / Int16ul) + VARIOUS_NUMERIC = VARIOUS_NUMERIC_V3 if version == 3 else VARIOUS_NUMERIC_V4 + + COLUMN = Struct( + "type" / Int8ul, + "ver4_unknown_3" / If(version > 3, Int32ul), + "column_id" / Int16ul, + "variable_column_number" / Int16ul, + "column_index" / Int16ul, + "various" + / Switch( + this.type, + { + 9: VARIOUS_TEXT, + 10: VARIOUS_TEXT, + 11: VARIOUS_TEXT, + 12: VARIOUS_TEXT, + 16: VARIOUS_NUMERIC, + 1: VARIOUS_DEC, + 2: VARIOUS_DEC, + 3: VARIOUS_DEC, + 4: VARIOUS_DEC, + 5: VARIOUS_DEC, + 6: VARIOUS_DEC, + 7: VARIOUS_DEC, + 8: VARIOUS_DEC, + }, + default=version_specific(version, Bytes(6), Bytes(4)), + ), + "column_flags" / version_specific(version, VERSION_3_FLAGS, VERSION_4_FLAGS), + "ver4_unknown_4" / If(version > 3, Int32ul), + "fixed_offset" / Int16ul, + "length" / Int16ul, + ) + + COLUMN_NAMES = Struct( + "col_name_len" / version_specific(version, Int8ul, Int16ul), + "col_name_str" + / version_specific( + version, + PaddedString(this.col_name_len, encoding="utf8"), + PaddedString(this.col_name_len, encoding="utf16"), + ), + ) + + REAL_INDEX2 = Struct( + "unknown_b1" / If(version > 3, Int32ul), + "unk_struct" / Array(10, Struct("col_id" / Int16ul, "idx_flags" / Int8ul)), + "runk" / Int32ul, + "first_index_page" / Int32ul, + "flags" / Int8ul, + "unknown_b3" / If(version > 3, Padding(9)), + ) + + ALL_INDEXES = Struct( + "unknown_c1" / If(version > 3, Int32ul), + "idx_num" / Int32ul, + "idx_col_num" / Int32ul, + "rel_tbl_type" / Int8ul, + "rel_idx_num" / Int32sl, + "rel_tbl_page" / Int32ul, + "cascade_ups" / Int8ul, + "cascade_dels" / Int8ul, + "idx_type" / Int8ul, + "unknown_c2" / If(version > 3, Int32ul), + ) + + INDEX_NAMES = Struct( + "idx_name_len" / version_specific(version, Int8ul, Int16ul), + "idx_name_str" + / version_specific( + version, + PaddedString(this.idx_name_len, encoding="utf8"), + PaddedString(this.idx_name_len, encoding="utf16"), + ), + ) + + parser = Struct( + "real_index" / Array(real_index_count, REAL_INDEX), + "column" / Array(column_count, COLUMN), + "column_names" / Array(column_count, COLUMN_NAMES), + "real_index_2" / Array(real_index_count, REAL_INDEX2), + "all_indexes" / Array(index_count, ALL_INDEXES), + "index_names" / Array(index_count, INDEX_NAMES), + ) + return cast("TableData", parser.parse(buffer)) + + +def parse_data_page_header(buffer: bytes, version: int = 3) -> DataPageHeader: + """Parse a data-page header and its record offsets.""" + parser = Struct( + Const(b"\x01\x01"), + "data_free_space" / Int16ul, + "owner" / Int32ul, + "ver4_unknown_dat1" / If(version > 3, Int32ul), + "record_count" / Int16ul, + "record_offsets" / Array(this.record_count, Int16ul), + ) + return cast("DataPageHeader", parser.parse(buffer)) + + +# buffer should be the record data in reverse +def parse_relative_object_metadata_struct( + buffer: bytes, + variable_jump_tables_cnt: int = 0, + version: int = 3, +) -> RelativeMetadata: + """Parse variable-length field offsets stored at the end of a record.""" + parser = Struct( + "variable_length_field_count" / version_specific(version, Int8ub, Int16ub), + "variable_length_jump_table" / If(version == 3, Array(variable_jump_tables_cnt, Int8ub)), + # This currently supports up to 255 columns for versions > 3 + "variable_length_field_offsets" + / version_specific( + version, + Array(this.variable_length_field_count, Int8ub), + Array(this.variable_length_field_count & 0xFF, Int16ub), + ), + "var_len_count" / version_specific(version, Int8ub, Int16ub), + "relative_metadata_end" / Tell, + ) + return cast("RelativeMetadata", parser.parse(buffer)) diff --git a/src/access_parser/utils.py b/src/access_parser/utils.py new file mode 100644 index 0000000..4bfefdc --- /dev/null +++ b/src/access_parser/utils.py @@ -0,0 +1,142 @@ +"""Value decoding and page utilities for Access databases.""" + +import logging +import math +import os +import struct +import uuid +from datetime import datetime, timedelta +from decimal import Decimal + +type ParsedValue = bool | bytes | datetime | Decimal | float | int | str | uuid.UUID | None +type PageMap = dict[int, bytes] + +LOGGER = logging.getLogger("access_parser.utils") + + +TYPE_BOOLEAN = 1 +TYPE_INT8 = 2 +TYPE_INT16 = 3 +TYPE_INT32 = 4 +TYPE_MONEY = 5 +TYPE_FLOAT32 = 6 +TYPE_FLOAT64 = 7 +TYPE_DATETIME = 8 +TYPE_BINARY = 9 +TYPE_TEXT = 10 +TYPE_OLE = 11 +TYPE_MEMO = 12 +TYPE_GUID = 15 +TYPE_NUMERIC = 16 +TYPE_COMPLEX = 18 + +TABLE_PAGE_MAGIC = b"\x02\x01" +DATA_PAGE_MAGIC = b"\x01\x01" + + +ACCESS_EPOCH = datetime(1899, 12, 30) # noqa: DTZ001 - Access stores timezone-naive dates. +CURRENCY_SCALE = 4 +SECONDS_PER_DAY = 86_400 + +TYPE_TO_STRUCT_FORMAT = { + TYPE_INT8: "b", + TYPE_INT16: "h", + TYPE_INT32: "i", + TYPE_COMPLEX: "i", + TYPE_FLOAT32: "f", + TYPE_FLOAT64: "d", +} + + +def mdb_date_to_datetime(value: float) -> datetime: + """Convert an OLE Automation date to a timezone-naive datetime.""" + fractional_days, whole_days = math.modf(value) + return ACCESS_EPOCH + timedelta(days=int(whole_days), seconds=abs(fractional_days) * SECONDS_PER_DAY) + + +def numeric_to_decimal(buffer: bytes, scale: int) -> Decimal: + """Decode Access's 17-byte exact numeric representation.""" + sign, word1, word2, word3, word4 = struct.unpack(" str: + """Decode Jet 3 text as UTF-8 or Latin-1.""" + try: + return bytes_data.decode("utf-8") + except UnicodeDecodeError: + return bytes_data.decode("latin1") + + +def _parse_money(buffer: bytes) -> Decimal: + parsed = struct.unpack_from(" str: + if version <= 3: + parsed = get_decoded_text(buffer) + elif buffer.startswith((b"\xfe\xff", b"\xff\xfe")): + parsed = get_decoded_text(buffer[2:]) + else: + parsed = buffer.decode("utf-16", errors="ignore") + + if "\x00" in parsed: + LOGGER.debug(f"Parsed string contains NUL (0x00) characters: {parsed}") + return parsed.replace("\x00", "") + return parsed + + +def parse_type( + data_type: int, + buffer: bytes, + length: int | None = None, + version: int = 3, + scale: int = 0, +) -> ParsedValue: + """Decode a field according to its Access type identifier.""" + if struct_format := TYPE_TO_STRUCT_FORMAT.get(data_type): + return struct.unpack_from(struct_format, buffer)[0] + if data_type == TYPE_MONEY: + return _parse_money(buffer) + if data_type == TYPE_DATETIME: + return mdb_date_to_datetime(struct.unpack_from(" tuple[PageMap, PageMap, PageMap]: + """Split database bytes into table-definition, data, and complete page maps.""" + if len(db_data) % page_size: + LOGGER.warning(f"DB is not full or PAGE_SIZE is wrong. page size: {page_size} DB length {len(db_data)}") + pages = {i: db_data[i : i + page_size] for i in range(0, len(db_data), page_size)} + data_pages: PageMap = {} + table_defs: PageMap = {} + for page, value in pages.items(): + if value.startswith(DATA_PAGE_MAGIC): + data_pages[page] = value + elif value.startswith(TABLE_PAGE_MAGIC): + table_defs[page] = value + return table_defs, data_pages, pages + + +def read_db_file(path: str | os.PathLike[str]) -> bytes: + """Read a database file from disk.""" + if not os.path.isfile(path): + LOGGER.error(f"File {path} not found") + raise FileNotFoundError(f"File {path} not found") + with open(path, "rb") as f: + return f.read() diff --git a/tests/test_access_parser.py b/tests/test_access_parser.py new file mode 100644 index 0000000..7182210 --- /dev/null +++ b/tests/test_access_parser.py @@ -0,0 +1,287 @@ +import sqlite3 +import struct +from collections import defaultdict +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from types import SimpleNamespace +from typing import cast +from uuid import UUID + +import pytest + +from access_parser import AccessParser +from access_parser.access_parser import ( + AccessTable, + ParsedTable, + _get_primary_keys, # pyright: ignore[reportPrivateUsage] +) +from access_parser.parsing_primitives import Column, RelativeMetadata, TableHeader +from access_parser.utils import TYPE_BINARY, TYPE_TEXT, ParsedValue + +EXPECTED_CATALOG = { + "MSysObjects": 2, + "ClarotyTable": 18, + "f_AF8292619150475ABBCC3E04860C1240_Data": 22, + "MSysNameMap": 36, + "MSysNavPaneGroupCategories": 44, + "MSysNavPaneGroups": 47, + "MSysNavPaneGroupToObjects": 51, + "MSysNavPaneObjectIDs": 59, +} + +TABLE_DIMENSIONS = [ + ("MSysObjects", 17, 26), + ("ClarotyTable", 4, 2), + ("f_AF8292619150475ABBCC3E04860C1240_Data", 8, 1), + ("MSysNameMap", 5, 1), + ("MSysNavPaneGroupCategories", 7, 3), + ("MSysNavPaneGroups", 7, 9), + ("MSysNavPaneGroupToObjects", 7, 11), + ("MSysNavPaneObjectIDs", 3, 12), +] + + +@pytest.fixture(scope="module") +def database() -> AccessParser: + return AccessParser(Path(__file__).parents[1] / "examples" / "test.mdb") + + +def test_reads_jet4_catalog(database: AccessParser) -> None: + assert database.version == 4 + assert database.page_size == 4096 + assert database.catalog == EXPECTED_CATALOG + + +def test_parses_table_rows(database: AccessParser) -> None: + assert database.parse_table("ClarotyTable") == { + "ID": [1, 2], + "Field1": ["test", "test2"], + "Field2": ["Claroty", "Claroty"], + "Field3": ["ICS!", None], + } + + +@pytest.mark.parametrize(("table_name", "column_count", "row_count"), TABLE_DIMENSIONS) +def test_parses_all_discovered_tables( + database: AccessParser, + table_name: str, + column_count: int, + row_count: int, +) -> None: + table = database.parse_table(table_name) + + assert len(table) == column_count + assert {len(column) for column in table.values()} == {row_count} + + +def test_parses_schema_metadata(database: AccessParser) -> None: + table = database.get_table("ClarotyTable") + properties = database.extra_props["ClarotyTable"] + + assert table is not None + assert properties is not None + assert table.primary_keys == ["ID"] + assert properties["Field3"]["ColumnOrder"] == 4 + + +def test_primary_key_resolution_uses_stable_column_ids() -> None: + columns = [ + cast("Column", SimpleNamespace(column_id=0, col_name_str="Other")), + cast("Column", SimpleNamespace(column_id=5, col_name_str="PrimaryKey")), + ] + table_header = cast( + "TableHeader", + SimpleNamespace( + all_indexes=[SimpleNamespace(idx_type=1, idx_col_num=0)], + real_index_2=[SimpleNamespace(unk_struct=[SimpleNamespace(col_id=5), SimpleNamespace(col_id=0xFFFF)])], + ), + ) + + assert _get_primary_keys(columns, table_header) == ["PrimaryKey"] + + +def test_parses_guid_and_binary_fields(database: AccessParser) -> None: + name_map = database.parse_table("MSysNameMap") + resources = database.parse_table("f_AF8292619150475ABBCC3E04860C1240_Data") + name_map_data = name_map["NameMap"][0] + file_data = resources["FileData"][0] + + assert name_map["GUID"] == [UUID("4ad1b98b-9f0f-46c7-a827-0165678e8cbd")] + assert isinstance(name_map_data, bytes) + assert len(name_map_data) == 342 + assert resources["FileName"] == ["Office Theme.thmx"] + assert resources["FileTimeStamp"] == [None] + assert isinstance(file_data, bytes) + assert file_data.startswith(b"\x01\x00\x00\x00P\x0c\x00\x00x^") + assert len(file_data) == 2794 + + +def test_parses_access_dates_as_datetimes(database: AccessParser) -> None: + objects = database.parse_table("MSysObjects") + + assert objects["DateCreate"][0] == datetime(2020, 7, 5, 14, 29, 3, 135_000) # noqa: DTZ001 + + +def test_prints_database( + database: AccessParser, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr(database, "catalog", {"ClarotyTable": 18}) + + database.print_database() + + output = capsys.readouterr().out + assert "TABLE NAME: ClarotyTable" in output + assert "test2" in output + + +def test_rejects_missing_database(tmp_path: Path) -> None: + missing_database = tmp_path / "missing.mdb" + + with pytest.raises(FileNotFoundError, match=r"missing\.mdb"): + AccessParser(missing_database) + + +def test_rejects_invalid_database(tmp_path: Path) -> None: + invalid_database = tmp_path / "invalid.mdb" + invalid_database.write_bytes(b"not an Access database") + + with pytest.raises(ValueError, match="Failed to parse DB file header"): + AccessParser(invalid_database) + + +def test_rejects_unknown_table(database: AccessParser) -> None: + with pytest.raises(KeyError, match="Unknown table: MissingTable"): + database.parse_table("MissingTable") + + +def test_runs_read_only_sql_queries(database: AccessParser) -> None: + connection = database.to_sqlite(["ClarotyTable"]) + + try: + assert connection.execute( + "SELECT Field1, Field3 FROM ClarotyTable WHERE ID > ?", + (1,), + ).fetchall() == [("test2", None)] + assert connection.execute("SELECT name FROM sqlite_schema WHERE type = 'table'").fetchall() == [ + ("ClarotyTable",) + ] + assert connection.execute("PRAGMA query_only").fetchone() == (1,) + with pytest.raises(sqlite3.OperationalError, match="readonly"): + connection.execute("DELETE FROM ClarotyTable") + finally: + connection.close() + + +def test_sqlite_export_preserves_blobs(database: AccessParser) -> None: + connection = database.to_sqlite(["MSysNameMap"]) + + try: + value = connection.execute("SELECT GUID, NameMap FROM MSysNameMap").fetchone() + assert value is not None + assert value[0] == "4ad1b98b-9f0f-46c7-a827-0165678e8cbd" + assert isinstance(value[1], bytes) + assert len(value[1]) == 342 + finally: + connection.close() + + +def test_sqlite_export_quotes_identifiers_and_keeps_empty_tables(monkeypatch: pytest.MonkeyPatch) -> None: + parser = AccessParser.__new__(AccessParser) + parser.catalog = {'select "table"': 1, "empty": 2} + + def parse_table(table_name: str) -> ParsedTable: + if table_name == "empty": + return defaultdict(list, {"value": []}) + return defaultdict(list, {'from "column"': [1]}) + + monkeypatch.setattr(parser, "parse_table", parse_table) + connection = parser.to_sqlite() + + try: + assert connection.execute('SELECT "from ""column""" FROM "select ""table"""').fetchall() == [(1,)] + assert connection.execute("SELECT COUNT(*) FROM empty").fetchone() == (0,) + finally: + connection.close() + + +def test_sqlite_export_normalizes_semantic_values(monkeypatch: pytest.MonkeyPatch) -> None: + parser = AccessParser.__new__(AccessParser) + parser.catalog = {"types": 1} + + def parse_table(_table_name: str) -> ParsedTable: + return defaultdict( + list, + { + "timestamp": [datetime(2020, 7, 5, 14, 29, 3, 135_000)], # noqa: DTZ001 + "guid": [UUID("4ad1b98b-9f0f-46c7-a827-0165678e8cbd")], + "amount": [Decimal("12.3400")], + }, + ) + + monkeypatch.setattr(parser, "parse_table", parse_table) + connection = parser.to_sqlite() + + try: + assert connection.execute("SELECT timestamp, guid, amount FROM types").fetchone() == ( + "2020-07-05 14:29:03.135000", + "4ad1b98b-9f0f-46c7-a827-0165678e8cbd", + "12.3400", + ) + finally: + connection.close() + + +def test_jet3_jump_table_offsets_are_independent_of_null_fields() -> None: + record = bytearray(268) + record[250:260] = b"a" * 10 + record[260:264] = b"b" * 4 + record[264:268] = b"c" * 4 + columns: dict[int, Column] = { + index: cast( + "Column", + SimpleNamespace(type=TYPE_BINARY, column_id=index, col_name_str=f"column_{index}"), + ) + for index in range(3) + } + metadata = cast( + "RelativeMetadata", + SimpleNamespace( + variable_length_field_offsets=[250, 4, 8], + variable_length_jump_table=[1], + var_len_count=12, + ), + ) + table = AccessTable.__new__(AccessTable) + table.version = 3 + table.parsed_table = defaultdict[str, list[ParsedValue]](list) + + table._parse_dynamic_length_data( # pyright: ignore[reportPrivateUsage] + bytes(record), metadata, columns, [True, False, True] + ) + + assert table.parsed_table == { + "column_0": [b"a" * 10], + "column_1": [None], + "column_2": [b"c" * 4], + } + + +def test_parses_single_byte_jet3_lvprop_names() -> None: + property_name = b"Description" + column_name = b"Notes" + value = b"hello" + name_body = struct.pack(" None: + assert parse_type(data_type, buffer) == expected + + +@pytest.mark.parametrize( + ("stored_value", "expected"), + [ + (123_456, Decimal("12.3456")), + (0, Decimal("0.0000")), + (-123_456, Decimal("-12.3456")), + ], +) +def test_parse_type_decodes_money_exactly(stored_value: int, expected: Decimal) -> None: + assert parse_type(TYPE_MONEY, struct.pack(" None: + assert parse_type(TYPE_DATETIME, struct.pack(" None: + assert parse_type(TYPE_TEXT, buffer, version=version) == expected + + +def test_parse_type_decodes_byte_fields_and_guid() -> None: + guid = uuid.UUID("8bb9d14a-0f9f-c746-a827-0165678e8cbd") + payload = bytes(range(32)) + + assert parse_type(TYPE_BINARY, payload, length=5) == payload[:5] + assert parse_type(TYPE_OLE, payload) == payload + assert parse_type(TYPE_GUID, guid.bytes_le) == guid + + +@pytest.mark.parametrize( + ("sign", "scale", "expected"), + [ + (0, 6, Decimal("149.804168")), + (0x80, 6, Decimal("-149.804168")), + (0, 10, Decimal("0.0149804168")), + ], +) +def test_numeric_to_decimal(sign: int, scale: int, expected: Decimal) -> None: + numeric = struct.pack(" None: + table_page = b"\x02\x01aa" + data_page = b"\x01\x01bb" + other_page = b"other" + + table_defs, data_pages, all_pages = categorize_pages(table_page + data_page + other_page, page_size=4) + + assert table_defs == {0: table_page} + assert data_pages == {4: data_page} + assert all_pages == {0: table_page, 4: data_page, 8: b"othe", 12: b"r"} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..713b79b --- /dev/null +++ b/uv.lock @@ -0,0 +1,163 @@ +version = 1 +revision = 4 +requires-python = ">=3.14" + +[[package]] +name = "access-parser" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "construct" }, + { name = "tabulate" }, +] + +[package.dev-dependencies] +dev = [ + { name = "construct-typing" }, + { name = "pytest" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "construct" +version = "2.10.70" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/77/8c84b98eca70d245a2a956452f21d57930d22ab88cbeed9290ca630cf03f/construct-2.10.70.tar.gz", hash = "sha256:4d2472f9684731e58cc9c56c463be63baa1447d674e0d66aeb5627b22f512c29", size = 86337, upload-time = "2023-11-29T08:44:49.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/fb/08b3f4bf05da99aba8ffea52a558758def16e8516bc75ca94ff73587e7d3/construct-2.10.70-py3-none-any.whl", hash = "sha256:c80be81ef595a1a821ec69dc16099550ed22197615f4320b57cc9ce2a672cb30", size = 63020, upload-time = "2023-11-29T08:44:46.876Z" }, +] + +[[package]] +name = "construct-typing" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, + { name = "construct" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/de/de378332a7cb2f62df134e0b5915a0fea939219c80ecee99b29da8ba49c7/construct_typing-0.8.1.tar.gz", hash = "sha256:b3c33246b83f187f21fbfa9e229cf7225fe42823f3d598328b973c844071c456", size = 53118, upload-time = "2026-07-23T08:48:19.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/f4/7dd59e010765f5c0043a5cf59e0f3e6748cd966491645bbb10b88ad24c82/construct_typing-0.8.1-py3-none-any.whl", hash = "sha256:9b5845dbaf959c9d960f5105f9ab3533227c4da2b76baa57d1f1c0aa2a088cdb", size = 26727, upload-time = "2026-07-23T08:48:17.908Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +]