From ee3ab9a871f6f2642a490df27e93a9cbcb470ecd Mon Sep 17 00:00:00 2001 From: WinChua Date: Sat, 18 Oct 2025 16:02:27 +0800 Subject: [PATCH 01/10] version release: 0.0.28 --- .gitignore | 2 ++ devtools/deploy_mysqld.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index fb788c8..22cfe95 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ tests/mysql8/ tests/test_data.tgz .deploy_mysqld datadir/ +target +pyinnodb.sh diff --git a/devtools/deploy_mysqld.py b/devtools/deploy_mysqld.py index 467e1e6..70f057c 100644 --- a/devtools/deploy_mysqld.py +++ b/devtools/deploy_mysqld.py @@ -99,7 +99,7 @@ def mDeploy(version): deploy_container[version] = Instance( url=mysql.get_connection_url().replace("localhost", "127.0.0.1"), container_id=f"{mysql._container.short_id}", - cmd=f"mysql -h 127.0.0.1 -P{mysql.get_exposed_port(mysql.port)} -u{mysql.username} -p{mysql.password}", + cmd=f"mysql -h 127.0.0.1 -P{mysql.get_exposed_port(mysql.port)} -u{mysql.username} -p{mysql.password} test", datadir=datadir, ) dump_deploy(deploy_container, f) diff --git a/pyproject.toml b/pyproject.toml index bb84c0b..2bb6803 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyinnodb" -version = "0.0.27" +version = "0.0.28" description = "A parser for InnoDB file formats, in Python" authors = [ { name = "WinChua", email = "winchua@foxmail.com" } diff --git a/uv.lock b/uv.lock index 065d6b0..8453ecc 100644 --- a/uv.lock +++ b/uv.lock @@ -609,7 +609,7 @@ wheels = [ [[package]] name = "pyinnodb" -version = "0.0.27" +version = "0.0.28" source = { editable = "." } dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, From 1cffdd8dd1e7b7d5cc219c1ae9c089aba2489e7a Mon Sep 17 00:00:00 2001 From: WinChua Date: Wed, 22 Oct 2025 15:40:59 +0800 Subject: [PATCH 02/10] feat(iter): support search sdi by name --- devtools/deploy_mysqld.py | 10 +++++++--- src/pyinnodb/cli/sql.py | 19 +++++++++++++++---- src/pyinnodb/disk_struct/index.py | 25 ++++++++++++++++++++++++- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/devtools/deploy_mysqld.py b/devtools/deploy_mysqld.py index 70f057c..2db13cf 100644 --- a/devtools/deploy_mysqld.py +++ b/devtools/deploy_mysqld.py @@ -8,7 +8,7 @@ from testcontainers.mysql import MySqlContainer from testcontainers.core.config import testcontainers_config as c -from sqlalchemy import create_engine +from sqlalchemy import create_engine, text from pyinnodb import const @@ -140,12 +140,16 @@ def exec(version, sql, file): with open(file, "r") as f: sql = f.read() with engine.connect() as conn: - result = conn.exec_driver_sql(sql) + #result = conn.exec_driver_sql(sql) + result = conn.execute(text(sql)) + conn.commit() if result.rowcount == 0: print("无结果返回") - else: + elif result.returns_rows: for r in result.fetchall(): print(r) + else: + print("执行成功,影响行数:", result.rowcount) @main.command() diff --git a/src/pyinnodb/cli/sql.py b/src/pyinnodb/cli/sql.py index 80806ac..942853f 100644 --- a/src/pyinnodb/cli/sql.py +++ b/src/pyinnodb/cli/sql.py @@ -1,4 +1,5 @@ import json +import dataclasses from pyinnodb.disk_struct.index import MIndexPage, MSDIPage from pyinnodb.sdi.table import Table @@ -10,15 +11,17 @@ @main.command() @click.pass_context -@click.option("--mode", type=click.Choice(["sdi", "ddl", "dump"]), default="ddl") +@click.option("--mode", type=click.Choice(["sdi", "ddl", "dump", "json"]), default="ddl") @click.option("--sdi-idx", type=click.INT, default=0) +@click.option("--sdi-name", type=click.STRING, default=None) @click.option("--schema/--no-schema", default=True) -def tosql(ctx, mode, sdi_idx, schema): +def tosql(ctx, mode, sdi_idx, sdi_name, schema): """dump the ddl/dml/sdi of the ibd table ddl) output the create table ddl; dump) output the dml of ibd file; sdi) output the dd_object stored in the SDIPage as json format + json) dump records in json format """ f = ctx.obj["fn"] @@ -35,11 +38,13 @@ def tosql(ctx, mode, sdi_idx, schema): print(json.dumps(dd_obj)) elif mode == "ddl": print(table_object.gen_ddl(schema)) + elif mode == "json": + dump_ibd(table_object, f, in_json=True) else: dump_ibd(table_object, f) return -def dump_ibd(table_object, f, oneline=True): +def dump_ibd(table_object, f, oneline=True, in_json=False): root_page_no = int(table_object.indexes[0].private_data.get("root", 4)) f.seek(root_page_no * const.PAGE_SIZE) root_index_page = MIndexPage.parse_stream(f) @@ -50,8 +55,11 @@ def dump_ibd(table_object, f, oneline=True): print("no data") return + transfer = table_object.wrap_transfer + if in_json: + transfer = None default_value_parser = MIndexPage.default_value_parser( - table_object, table_object.wrap_transfer + table_object, transfer ) values = [] @@ -66,6 +74,9 @@ def dump_ibd(table_object, f, oneline=True): ) first_leaf_page_no = index_page.fil.next_page + if in_json: + print(json.dumps([dataclasses.asdict(v) for v in values], default=str)) + return values = [f"({','.join(v)})" for v in values] table_name = f"`{table_object.schema_ref}`.`{table_object.name}`" diff --git a/src/pyinnodb/disk_struct/index.py b/src/pyinnodb/disk_struct/index.py index 11b7177..ad09df6 100644 --- a/src/pyinnodb/disk_struct/index.py +++ b/src/pyinnodb/disk_struct/index.py @@ -107,6 +107,20 @@ class MIndexPage(CC): fseg_header: MFsegHeader = cfield(MFsegHeader) system_records: MIndexSystemRecord = cfield(MIndexSystemRecord) + @classmethod + def value_parser_with_primary_key_only( + cls, dd_object: Table, key_len: int + ): + def value_parser(rh: MRecordHeader, f, **ctx): + if const.RecordType(rh.record_type) == const.RecordType.NodePointer: + next_page_no = const.parse_mysql_int(f.read(4)) + return + + key_data = f.read(key_len) + return key_data + + return value_parser + @classmethod def default_value_parser(cls, dd_object: Table, transfer=None, hidden_col=False, quick=True): primary_data_layout_col = dd_object.get_disk_data_layout() @@ -389,7 +403,16 @@ def _post_parsed(self, stream, context, path): self.fil_tailer = MFilTrailer.parse_stream(stream) # self.ddl = next(self.iterate_sdi_record(stream)) - def ddl(self, stream, idx): + def ddl(self, stream, idx, name=None): + target = None + for i, table in enumerate(self.iterate_sdi_record(stream)): + if name is not None and table["dd_object_type"] == "Table": + if name == table["dd_object"]["name"]: + return table + if i == idx: + target = table + return target + return list(self.iterate_sdi_record(stream))[idx] return next(self.iterate_sdi_record(stream)) From 4088cea05dfbcf6aa0415ba781189b8a6b6e5471 Mon Sep 17 00:00:00 2001 From: WinChua Date: Wed, 22 Oct 2025 20:47:52 +0800 Subject: [PATCH 03/10] feat(treeascii): api to draw tree --- src/pyinnodb/treeascii.py | 143 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 src/pyinnodb/treeascii.py diff --git a/src/pyinnodb/treeascii.py b/src/pyinnodb/treeascii.py new file mode 100644 index 0000000..47b1076 --- /dev/null +++ b/src/pyinnodb/treeascii.py @@ -0,0 +1,143 @@ +import math +import itertools + + +def hello() -> str: + return "Hello from treeascii!" + + +uni_h, uni_v, uni_tl, uni_tr, uni_br, uni_bl, uni_invt, uni_t, nl = ( + "\u2500\u2502\u256d\u256e\u256f\u2570\u2534\u252c\n" +) +uni_ten = "\u253c" +stripped = uni_h + uni_tl + uni_tr + " " + + +class TextBlock: + def __init__(self, text: str): + self.text = text + self.lines = self._padded_lines(0, 0) + + def _find_span(self, s): + n = len(s) + r = len(s.rstrip(stripped)) + l = n - len(s.lstrip(stripped)) + return l, (l + r) // 2, r + + def _padded_lines(self, pad_l, pad_r): + r, l = 0, math.inf + for line in self.text.splitlines(): + if line.isspace(): + continue + r = max(r, len(line.rstrip())) + l = min(l, len(line) - len(line.lstrip())) + extra_l = max(pad_l - l, 0) + trim_l = max(l - pad_l, 0) + prefix = " " * extra_l + return [ + prefix + line[trim_l:].rstrip().ljust(r + pad_r) + for line in self.text.splitlines() + ] + + def build_top(self, kind=0) -> str: + top_line = uni_h * self.size[1] + l, mid, r = self.loc + if kind == 0: + l = mid + top_line = l * " " + top_line[l:] + top_line = top_line[:mid] + uni_tl + top_line[mid + 1 :] + elif kind == 1: + r = mid + top_line = top_line[:mid] + uni_tr + " " * (self.size[1] - r - 1) + elif kind == 2: + top_line = top_line[:mid] + uni_t + top_line[mid + 1 :] + elif kind == 3: # only one child + return ' ' * self.size[1] + return top_line + + @property + def loc(self): + return self._find_span(self.text.split("\n")[0]) + + @property + def size(self): + return len(self.lines), len(self.lines[0]) + + def pad(self, l, r): + lines = self._padded_lines(l, r) + self.lines = lines + self.text = "\n".join(lines) + + def add_line(self, num): + width = self.size[1] + for i in range(num): + self.lines.append(" " * width) + self.text = "\n".join(self.lines) + + +def hjoin(blocks: list[TextBlock], sep=1) -> str: + lines = [] + for ls in itertools.zip_longest( + *[b.lines for b in blocks], fillvalue=" " * blocks[0].size[1] + ): + lines.append(" ".join(ls)) + return "\n".join(lines) + + +class TreeNode: + def __init__(self, text: str, children=None): + if children is None: + children = [] + self.text = str(text) + self.children = children + + @property + def is_leaf(self) -> bool: + return len(self.children) == 0 + + def build_block(self, ellip_leaf=False, ellip_all=False) -> TextBlock: + if self.is_leaf: + return TextBlock(self.text) + + blocks = [] + ellip_leaf_cnt = 0 + for i, c in enumerate(self.children): + if ellip_leaf and len(self.children) > 3: + if i != 0 and i != len(self.children) - 1: + if c.is_leaf or ellip_all: + ellip_leaf_cnt += 1 + else: + if ellip_leaf_cnt != 0: + blocks.append(TextBlock(f"({ellip_leaf_cnt} ellip)")) + blocks.append(c.build_block(ellip_leaf=ellip_leaf, ellip_all=ellip_all)) + ellip_leaf_cnt = 0 + continue + if i == len(self.children) - 1: + if ellip_leaf_cnt != 0: + blocks.append(TextBlock(f"({ellip_leaf_cnt} ellip)")) + blocks.append(c.build_block(ellip_leaf=ellip_leaf, ellip_all=ellip_all)) + + max_lines = max(block.size[0] for block in blocks) + tops = [] + if len(blocks) == 1: + tops.append(blocks[0].build_top(kind=3)) + else: + for i in range(len(blocks)): + blocks[i].add_line(max_lines - blocks[i].size[0]) + if i == 0: + tops.append(blocks[i].build_top(kind=0)) + elif i == len(blocks) - 1: + tops.append(blocks[i].build_top(kind=1)) + else: + tops.append(blocks[i].build_top(kind=2)) + + top_line = uni_h.join(tops) + joined = TextBlock(hjoin(blocks)) + parent_text = (joined.size[1] - len(self.text)) // 2 * " " + self.text + parent_text = TextBlock(parent_text.ljust(joined.size[1], " ")) + l, m, r = parent_text.loc + p_mid = m + r_text = uni_invt if top_line[p_mid] == uni_h else uni_ten + r_text = uni_v if len(blocks) == 1 else r_text + top_line = top_line[:p_mid] + r_text + top_line[p_mid + 1 :] + return TextBlock("\n".join([parent_text.text, top_line, joined.text])) From b5941e861eae19bf41c1edd4064e139b754e490c Mon Sep 17 00:00:00 2001 From: WinChua Date: Wed, 22 Oct 2025 20:48:31 +0800 Subject: [PATCH 04/10] feat(tree): cmd interface to view innodb tree --- src/pyinnodb/cli/iter_record.py | 26 ++++- src/pyinnodb/cli/main.py | 1 + src/pyinnodb/cli/sql.py | 25 +++-- src/pyinnodb/cli/static_usage.py | 2 +- src/pyinnodb/const/dd_column_type.py | 144 ++++++++++++++++++++++--- src/pyinnodb/disk_struct/data.py | 30 +++--- src/pyinnodb/disk_struct/first_page.py | 9 +- src/pyinnodb/disk_struct/index.py | 83 +++++++++----- src/pyinnodb/disk_struct/rollback.py | 4 +- src/pyinnodb/sdi/column.py | 14 +-- src/pyinnodb/sdi/table.py | 98 ++++++++++------- src/pyinnodb/sdi/util.py | 2 +- 12 files changed, 324 insertions(+), 114 deletions(-) diff --git a/src/pyinnodb/cli/iter_record.py b/src/pyinnodb/cli/iter_record.py index a41d2e9..867841d 100644 --- a/src/pyinnodb/cli/iter_record.py +++ b/src/pyinnodb/cli/iter_record.py @@ -1,6 +1,6 @@ from pyinnodb.disk_struct.first_page import MFirstPage from pyinnodb.disk_struct.fsp import MFspPage -from pyinnodb.disk_struct.index import MSDIPage +from pyinnodb.disk_struct.index import MSDIPage, MIndexPage from pyinnodb.disk_struct.record import MRecordHeader from pyinnodb.sdi.table import Table from pyinnodb.disk_struct.rollback import History @@ -90,6 +90,20 @@ def value_parser(rh: MRecordHeader, f): return value_parser +@main.command() +@click.pass_context +@click.option("--ellip-leaf/--no-ellip-leaf", type=click.BOOL, default=True) +@click.option("--ellip-all/--no-ellip-all", type=click.BOOL, default=True) +def tree_view(ctx, ellip_leaf=True, ellip_all=True): + f = ctx.obj["fn"] + fsp_page: MFspPage = ctx.obj["fsp_page"] + f.seek(fsp_page.sdi_page_no * const.PAGE_SIZE) + sdi_page = MSDIPage.parse_stream(f) + dd_object = Table(**sdi_page.ddl(f, 0)["dd_object"]) + tree = dd_object.tree_view(f) + print(tree.build_block(ellip_leaf=ellip_leaf, ellip_all=ellip_all).text) + + @main.command() @click.pass_context @click.option("--garbage/--no-garbage", default=False, help="include garbage mark data") @@ -111,7 +125,7 @@ def iter_record(ctx, garbage, hidden_col, pageno, sdi_idx, header=0): """iterate on the leaf pages by default, iter_record will iterate from the first leaf page - output every record as a namedtuple whose filed is all the column + output every record as a namedtuple whose field is all the column name of the ibd file. """ @@ -131,6 +145,14 @@ def iter_record(ctx, garbage, hidden_col, pageno, sdi_idx, header=0): elif header == 4: tf = dd_object.trans_record_header_key(with_page=True) + if pageno != None: + f.seek(pageno * const.PAGE_SIZE) + page = MIndexPage.parse_stream(f) + default_value_parser = MIndexPage.value_parser_with_primary_key_only(dd_object) + for data in page.iterate_record_header(f, value_parser=default_value_parser): + print(data) + return + for data in dd_object.iter_record( f, hidden_col=hidden_col, garbage=garbage, transfer=tf ): diff --git a/src/pyinnodb/cli/main.py b/src/pyinnodb/cli/main.py index c4fa99b..4ffb551 100644 --- a/src/pyinnodb/cli/main.py +++ b/src/pyinnodb/cli/main.py @@ -28,6 +28,7 @@ def validate_ibd(fsp_page: MFspPage, fn: t.IO[t.Any]): return False return True + @click.group(invoke_without_command=True) @click.option("--fn", type=click.File("rb"), default=None) @click.option( diff --git a/src/pyinnodb/cli/sql.py b/src/pyinnodb/cli/sql.py index 942853f..5e10ad0 100644 --- a/src/pyinnodb/cli/sql.py +++ b/src/pyinnodb/cli/sql.py @@ -11,7 +11,9 @@ @main.command() @click.pass_context -@click.option("--mode", type=click.Choice(["sdi", "ddl", "dump", "json"]), default="ddl") +@click.option( + "--mode", type=click.Choice(["sdi", "ddl", "dump", "json"]), default="ddl" +) @click.option("--sdi-idx", type=click.INT, default=0) @click.option("--sdi-name", type=click.STRING, default=None) @click.option("--schema/--no-schema", default=True) @@ -44,6 +46,7 @@ def tosql(ctx, mode, sdi_idx, sdi_name, schema): dump_ibd(table_object, f) return + def dump_ibd(table_object, f, oneline=True, in_json=False): root_page_no = int(table_object.indexes[0].private_data.get("root", 4)) f.seek(root_page_no * const.PAGE_SIZE) @@ -58,9 +61,7 @@ def dump_ibd(table_object, f, oneline=True, in_json=False): transfer = table_object.wrap_transfer if in_json: transfer = None - default_value_parser = MIndexPage.default_value_parser( - table_object, transfer - ) + default_value_parser = MIndexPage.default_value_parser(table_object, transfer) values = [] while first_leaf_page_no != const.FFFFFFFF: @@ -68,8 +69,9 @@ def dump_ibd(table_object, f, oneline=True, in_json=False): index_page = MIndexPage.parse_stream(f) values.extend( index_page.iterate_record_header( - f, value_parser=default_value_parser, - page=first_leaf_page_no, # ctx + f, + value_parser=default_value_parser, + page=first_leaf_page_no, # ctx ) ) first_leaf_page_no = index_page.fil.next_page @@ -82,15 +84,18 @@ def dump_ibd(table_object, f, oneline=True, in_json=False): table_name = f"`{table_object.schema_ref}`.`{table_object.name}`" if not oneline: print( - f"INSERT INTO {table_name}({','.join( table_object.keys() )}) values {', '.join(values)}" + f"INSERT INTO {table_name}({','.join(table_object.keys())}) values {', '.join(values)}" ) else: for v in values: - print(f"INSERT INTO {table_name}({','.join(table_object.keys())}) values {v};") - + print( + f"INSERT INTO {table_name}({','.join(table_object.keys())}) values {v};" + ) return -# + + +# # 'type': sql/dd/types/column.h::enum_column_type # column_key : ag --cpp \ CK_NONE diff --git a/src/pyinnodb/cli/static_usage.py b/src/pyinnodb/cli/static_usage.py index 3c9b56d..057987e 100644 --- a/src/pyinnodb/cli/static_usage.py +++ b/src/pyinnodb/cli/static_usage.py @@ -31,7 +31,7 @@ def list_page(ctx, kind): index_header = MIndexHeader.parse_stream(f) ratios.append(index_header.heap_top_pos / const.PAGE_SIZE) elif const.PageType(fil.page_type) == const.PageType.TYPE_LOB_FIRST: - first_page_header =MFirstPageHeader.parse_stream(f) + first_page_header = MFirstPageHeader.parse_stream(f) ratios.append(first_page_header.data_len / const.PAGE_SIZE) else: ratios.append(-1) diff --git a/src/pyinnodb/const/dd_column_type.py b/src/pyinnodb/const/dd_column_type.py index d6ef953..77b7223 100644 --- a/src/pyinnodb/const/dd_column_type.py +++ b/src/pyinnodb/const/dd_column_type.py @@ -124,33 +124,143 @@ def is_big(cls, t): nop = namedtuple("nop", "") + def rand_none(col): return None + class DDColConf(DDColConf, Enum): DECIMAL = DDColumnType.DECIMAL, 0, float, lambda col: random.random() - TINY = DDColumnType.TINY, 1, int, lambda col: random.randint(0, 2**8 - 1) if col.is_unsigned else random.randint(-2**7, 2**7-1) - SHORT = DDColumnType.SHORT, 2, int, lambda col: random.randint(0, 2**16 - 1) if col.is_unsigned else random.randint(-2**15, 2**15-1) - LONG = DDColumnType.LONG, 4, int, lambda col: random.randint(0, 2**32 - 1) if col.is_unsigned else random.randint(-2**31, 2**31-1) + TINY = ( + DDColumnType.TINY, + 1, + int, + lambda col: random.randint(0, 2**8 - 1) + if col.is_unsigned + else random.randint(-(2**7), 2**7 - 1), + ) + SHORT = ( + DDColumnType.SHORT, + 2, + int, + lambda col: random.randint(0, 2**16 - 1) + if col.is_unsigned + else random.randint(-(2**15), 2**15 - 1), + ) + LONG = ( + DDColumnType.LONG, + 4, + int, + lambda col: random.randint(0, 2**32 - 1) + if col.is_unsigned + else random.randint(-(2**31), 2**31 - 1), + ) FLOAT = DDColumnType.FLOAT, 4, float, lambda col: random.random() DOUBLE = DDColumnType.DOUBLE, 8, float, lambda col: random.random() TYPE_NULL = DDColumnType.TYPE_NULL, 0, int, rand_none - TIMESTAMP = DDColumnType.TIMESTAMP, 0, int, lambda col: datetime.fromtimestamp(int(time.time()) + random.randint(-100, 100)).strftime("%Y-%m-%d %H:%M:%S") - LONGLONG = DDColumnType.LONGLONG, 8, int, lambda col: random.randint(0,2**64-1) if col.is_unsigned else random.randint(-2**63, 2**63 - 1) - INT24 = DDColumnType.INT24, 3, int, lambda col: random.randint(0, 2**24 - 1) if col.is_unsigned else random.randint(-2**23, 2**23-1) - DATE = DDColumnType.DATE, 0, date, lambda col: date(random.randint(0, 2000), random.randint(1, 12), random.randint(1, 28)) - TIME = DDColumnType.TIME, 0, timedelta, lambda col: timedelta(random.randint(0, 100)) - DATETIME = DDColumnType.DATETIME, 0, datetime, lambda col: datetime(random.randint(1, 10), random.randint(1, 10), random.randint(1, 10)) + TIMESTAMP = ( + DDColumnType.TIMESTAMP, + 0, + int, + lambda col: datetime.fromtimestamp( + int(time.time()) + random.randint(-100, 100) + ).strftime("%Y-%m-%d %H:%M:%S"), + ) + LONGLONG = ( + DDColumnType.LONGLONG, + 8, + int, + lambda col: random.randint(0, 2**64 - 1) + if col.is_unsigned + else random.randint(-(2**63), 2**63 - 1), + ) + INT24 = ( + DDColumnType.INT24, + 3, + int, + lambda col: random.randint(0, 2**24 - 1) + if col.is_unsigned + else random.randint(-(2**23), 2**23 - 1), + ) + DATE = ( + DDColumnType.DATE, + 0, + date, + lambda col: date( + random.randint(0, 2000), random.randint(1, 12), random.randint(1, 28) + ), + ) + TIME = ( + DDColumnType.TIME, + 0, + timedelta, + lambda col: timedelta(random.randint(0, 100)), + ) + DATETIME = ( + DDColumnType.DATETIME, + 0, + datetime, + lambda col: datetime( + random.randint(1, 10), random.randint(1, 10), random.randint(1, 10) + ), + ) YEAR = DDColumnType.YEAR, 1, int, lambda col: random.randint(1901, 2155) - NEWDATE = DDColumnType.NEWDATE, 3, date, lambda col: date(random.randint(0, 2000), random.randint(1, 12), random.randint(1, 28)) - VARCHAR = DDColumnType.VARCHAR, 0, str, lambda col, size=None: random.randbytes(size if size is not None and size < col.varchar_size else col.varchar_size).hex() - BIT = DDColumnType.BIT, 0, int, lambda col: random.randint(0,1) - TIMESTAMP2 = DDColumnType.TIMESTAMP2, 0, int, lambda col: datetime.fromtimestamp(int(time.time()) + random.randint(-100, 100)).strftime("%Y-%m-%d %H:%M:%S") - DATETIME2 = DDColumnType.DATETIME2, 0, datetime, lambda col: datetime(random.randint(1, 10), random.randint(1, 10), random.randint(1, 10)) - TIME2 = DDColumnType.TIME2, 0, timedelta, lambda col: f"{random.randint(-838, 838)}:{random.randint(0,59):02}:{random.randint(0,59):02}.{random.randint(0,30000)}" + NEWDATE = ( + DDColumnType.NEWDATE, + 3, + date, + lambda col: date( + random.randint(0, 2000), random.randint(1, 12), random.randint(1, 28) + ), + ) + VARCHAR = ( + DDColumnType.VARCHAR, + 0, + str, + lambda col, size=None: random.randbytes( + size if size is not None and size < col.varchar_size else col.varchar_size + ).hex(), + ) + BIT = DDColumnType.BIT, 0, int, lambda col: random.randint(0, 1) + TIMESTAMP2 = ( + DDColumnType.TIMESTAMP2, + 0, + int, + lambda col: datetime.fromtimestamp( + int(time.time()) + random.randint(-100, 100) + ).strftime("%Y-%m-%d %H:%M:%S"), + ) + DATETIME2 = ( + DDColumnType.DATETIME2, + 0, + datetime, + lambda col: datetime( + random.randint(1, 10), random.randint(1, 10), random.randint(1, 10) + ), + ) + TIME2 = ( + DDColumnType.TIME2, + 0, + timedelta, + lambda col: f"{random.randint(-838, 838)}:{random.randint(0, 59):02}:{random.randint(0, 59):02}.{random.randint(0, 30000)}", + ) NEWDECIMAL = DDColumnType.NEWDECIMAL, 0, float, lambda col: random.random() - ENUM = DDColumnType.ENUM, 0, str, lambda col : b64decode(random.choice(col.elements).name).decode(errors="replace") - SET = DDColumnType.SET, 0, set, lambda col : b64decode(random.choice(col.elements).name).decode(errors="replace") + ENUM = ( + DDColumnType.ENUM, + 0, + str, + lambda col: b64decode(random.choice(col.elements).name).decode( + errors="replace" + ), + ) + SET = ( + DDColumnType.SET, + 0, + set, + lambda col: b64decode(random.choice(col.elements).name).decode( + errors="replace" + ), + ) TINY_BLOB = DDColumnType.TINY_BLOB, 0, str, lambda col: "" MEDIUM_BLOB = DDColumnType.MEDIUM_BLOB, 0, str, lambda col: "" LONG_BLOB = DDColumnType.LONG_BLOB, 0, str, lambda col: "" diff --git a/src/pyinnodb/disk_struct/data.py b/src/pyinnodb/disk_struct/data.py index c1e9527..7a87ad6 100644 --- a/src/pyinnodb/disk_struct/data.py +++ b/src/pyinnodb/disk_struct/data.py @@ -5,21 +5,23 @@ from datetime import UTC except ImportError: from datetime import timezone + UTC = timezone.utc TIMEF_INT_OFS = 0x800000 TIMEF_OFS = 0x800000000000 + def long2time(hms, dec): neg = "" if hms < 0: neg = "-" hms = -hms tmp = hms >> 24 - hour = (tmp >> 12) % (1<<10) - minute = (tmp>> 6) % (1<<6) - second = (tmp) % (1<<6) - frac = (hms) % (1<<24) + hour = (tmp >> 12) % (1 << 10) + minute = (tmp >> 6) % (1 << 6) + second = (tmp) % (1 << 6) + frac = (hms) % (1 << 24) if dec == 5: frac //= 10 if dec != 0: @@ -27,12 +29,13 @@ def long2time(hms, dec): else: return f"'{neg}{hour:02}:{minute:02}:{second:02}'" + class MTime2(CC): bin_data: int = cfield(cs.Bytes(3)) def set_precision(self, dec): self.dec = dec - + def to_str(self): if self.dec in [5, 6]: int_part = self.bin_data @@ -40,17 +43,17 @@ def to_str(self): return long2time(total, self.dec) if self.dec == 0: int_part = cs.Int24ub.parse(self.bin_data) - TIMEF_INT_OFS - if int_part > (1<<23): - int_part -= (1<<24) - return long2time(int_part<<24, self.dec) + if int_part > (1 << 23): + int_part -= 1 << 24 + return long2time(int_part << 24, self.dec) elif self.dec in [1, 2]: int_part = cs.Int24ub.parse(self.bin_data) - TIMEF_INT_OFS frac_part = cs.Int8ub.parse(self.fsp) if int_part < 0 and frac_part != 0: int_part += 1 frac_part -= 0x100 - if int_part > (1<<23): - int_part -= (1<<24) + if int_part > (1 << 23): + int_part -= 1 << 24 if self.dec == 1: frac_part //= 10 return long2time((int_part << 24) + frac_part, self.dec) @@ -60,15 +63,16 @@ def to_str(self): if int_part < 0 and frac_part != 0: int_part += 1 frac_part -= 0x10000 - if int_part > (1<<23): - int_part -= (1<<24) + if int_part > (1 << 23): + int_part -= 1 << 24 if self.dec == 3: frac_part //= 10 return long2time((int_part << 24) + frac_part, self.dec) - + def parse_fsp(self, stream, fsp): self.fsp = stream.read(fsp) + class MDatetime(CC): signed: int = cfield(cs.BitsInteger(1)) year_month: int = cfield(cs.BitsInteger(17)) diff --git a/src/pyinnodb/disk_struct/first_page.py b/src/pyinnodb/disk_struct/first_page.py index 511c7a5..04789ba 100644 --- a/src/pyinnodb/disk_struct/first_page.py +++ b/src/pyinnodb/disk_struct/first_page.py @@ -39,6 +39,7 @@ class MIndexEntryNode(CC): ## index_entry_t data_len: int = cfield(cs.Int32ul) lob_version: int = cfield(cs.Int32ub) + class MFirstPageHeader(CC): version: int = cfield(cs.Int8ub) flag: int = cfield(cs.Int8ub) @@ -47,7 +48,7 @@ class MFirstPageHeader(CC): last_undo_no: int = cfield(cs.Int32ub) data_len: int = cfield(cs.Int32ub) trx_id: int = cfield(IntFromBytes(6)) - + class MFirstPage(CC): ## first_page_t fil: MFil = cfield(MFil) @@ -73,7 +74,11 @@ def _post_parsed(self, stream, context, path): def get_data(self, stream): ie = self.index_entry[0] data = self.first_page_data - logger.debug("data_len is %d, index_list.length is %d", self.data_len, self.index_list.length) + logger.debug( + "data_len is %d, index_list.length is %d", + self.data_len, + self.index_list.length, + ) for i in range(self.index_list.length): stream.seek(ie.page_no * const.PAGE_SIZE) dp = MDataPage.parse_stream(stream) diff --git a/src/pyinnodb/disk_struct/index.py b/src/pyinnodb/disk_struct/index.py index ad09df6..4fe2202 100644 --- a/src/pyinnodb/disk_struct/index.py +++ b/src/pyinnodb/disk_struct/index.py @@ -7,6 +7,8 @@ from .. import const from ..const.dd_column_type import DDColumnType +from .. import treeascii + from typing import TYPE_CHECKING import typing @@ -50,16 +52,18 @@ class MFsegHeader(CC): # should not use this way to determine the first leaf page number # as off-page may allocate first # def get_first_leaf_page(self, f): -const.FFFFFFFF # if self.leaf_pointer.page_number != const.FFFFFFFF: - # f.seek(self.leaf_pointer.seek_loc()) - # inode_entry = MInodeEntry.parse_stream(f) - # fp = inode_entry.first_page() - # if fp is not None: - # return fp - # f.seek(self.internal_pointer.seek_loc()) - # inode_entry = MInodeEntry.parse_stream(f) - # return inode_entry.first_page() + +const.FFFFFFFF # if self.leaf_pointer.page_number != const.FFFFFFFF: +# f.seek(self.leaf_pointer.seek_loc()) +# inode_entry = MInodeEntry.parse_stream(f) +# fp = inode_entry.first_page() +# if fp is not None: +# return fp + +# f.seek(self.internal_pointer.seek_loc()) +# inode_entry = MInodeEntry.parse_stream(f) +# return inode_entry.first_page() class MSystemRecord(CC): @@ -108,23 +112,29 @@ class MIndexPage(CC): system_records: MIndexSystemRecord = cfield(MIndexSystemRecord) @classmethod - def value_parser_with_primary_key_only( - cls, dd_object: Table, key_len: int - ): + def value_parser_with_primary_key_only(cls, dd_object: Table): + primary_key_col = dd_object.get_primary_key_col() + def value_parser(rh: MRecordHeader, f, **ctx): if const.RecordType(rh.record_type) == const.RecordType.NodePointer: - next_page_no = const.parse_mysql_int(f.read(4)) - return + values = [c.read_data(f) for c in primary_key_col] + next_page_no = int.from_bytes(f.read(4), "big") + return next_page_no, values + elif const.RecordType(rh.record_type) == const.RecordType.Conventional: + return [c.read_data(f) for c in primary_key_col] - key_data = f.read(key_len) - return key_data + return None return value_parser @classmethod - def default_value_parser(cls, dd_object: Table, transfer=None, hidden_col=False, quick=True): + def default_value_parser( + cls, dd_object: Table, transfer=None, hidden_col=False, quick=True + ): primary_data_layout_col = dd_object.get_disk_data_layout() + primary_key_col = dd_object.get_primary_key_col() + def value_parser(rh: MRecordHeader, f, **ctx): cur = f.tell() logger.debug( @@ -135,8 +145,12 @@ def value_parser(rh: MRecordHeader, f, **ctx): cur % const.PAGE_SIZE, ) if const.RecordType(rh.record_type) == const.RecordType.NodePointer: - next_page_no = const.parse_mysql_int(f.read(4)) - return + primary_key_values = [] + for c in primary_key_col: + primary_key_values.append(c.read_data(f)) + # next_page_no = const.parse_mysql_int(f.read(4)) + next_page_no = int.from_bytes(f.read(4), "big") + return next_page_no, primary_key_values # data scheme version data_schema_version = 0 @@ -209,10 +223,12 @@ def value_parser(rh: MRecordHeader, f, **ctx): (i, c[0]) for i, c in enumerate(cols_disk_layout) if (not c[0].column_type_utf8.startswith("binary")) - and (DDColumnType.is_big(c[0].type) - or DDColumnType.is_var( - c[0].type, mysqld_version=dd_object.mysql_version_id - )) + and ( + DDColumnType.is_big(c[0].type) + or DDColumnType.is_var( + c[0].type, mysqld_version=dd_object.mysql_version_id + ) + ) ] logger.debug( "may_var_col is %s", @@ -273,7 +289,10 @@ def value_parser(rh: MRecordHeader, f, **ctx): if col.name in ["DB_ROW_ID", "DB_TRX_ID", "DB_ROLL_PTR"]: if not hidden_col and col.name in disk_data_parsed: disk_data_parsed.pop(col.name) - elif col.private_data.get("version_dropped", 0) != 0 or col.is_hidden_from_user: + elif ( + col.private_data.get("version_dropped", 0) != 0 + or col.is_hidden_from_user + ): if col.name in disk_data_parsed: disk_data_parsed.pop(col.name) elif col.is_virtual or col.generation_expression_utf8 != "": @@ -318,6 +337,22 @@ def get_first_leaf_page(self, stream, primary_cols): next_index_page = MIndexPage.parse_stream(stream) return next_index_page.get_first_leaf_page(stream, primary_cols) + def list_records(self, f, value_parser): + childrens = [] + for data in self.iterate_record_header(f, value_parser=value_parser): + if isinstance(data, tuple): # node pointer + next_page_no, data = data + f.seek(next_page_no * const.PAGE_SIZE) + next_page = MIndexPage.parse_stream(f) + childrens.append( + treeascii.TreeNode( + (next_page_no, data), children=next_page.list_records(f, value_parser) + ) + ) + else: + childrens.append(treeascii.TreeNode(data)) + return childrens + def iterate_record_header(self, f, value_parser=None, garbage=False, **ctx): page_no = self.fil.offset result = [] diff --git a/src/pyinnodb/disk_struct/rollback.py b/src/pyinnodb/disk_struct/rollback.py index 0720bc2..1261a73 100644 --- a/src/pyinnodb/disk_struct/rollback.py +++ b/src/pyinnodb/disk_struct/rollback.py @@ -11,6 +11,7 @@ SPATIAL_STATUS_SHIFT = 12 SPATIAL_STATUS_MASK = 3 << SPATIAL_STATUS_SHIFT + class History: def __init__(self, final): self.final = final @@ -40,10 +41,9 @@ def show(self): else: print("insert record") last = h - -class HistoryVersion: +class HistoryVersion: def __init__(self, trx_id, rollptr, upd): self.trx_id = trx_id self.field = [] diff --git a/src/pyinnodb/sdi/column.py b/src/pyinnodb/sdi/column.py index e7c6169..2101e62 100644 --- a/src/pyinnodb/sdi/column.py +++ b/src/pyinnodb/sdi/column.py @@ -37,6 +37,7 @@ column_type_size = re.compile("[^(]*[(]([^)]*)[)]") + @modify_init @dataclass(eq=False) class IndexElement: @@ -46,12 +47,14 @@ class IndexElement: hidden: bool = False column_opx: int = 0 + @modify_init @dataclass(eq=False) class ColumnElement: name: str = "" ## BINARY VARBINARY index: int = 0 + NewDecimalSize = namedtuple( "NewDecimalSize", "intg frac intg0 intg0x frac0 frac0x total" ) @@ -110,7 +113,7 @@ def dfield(self): kw_only = False default = dataclasses.MISSING if self.pytype == nop: - kw_only = True + kw_only = True default = None return field( default=default, @@ -122,9 +125,7 @@ def index_prefix(self, ie: IndexElement): if ie.length == const.FFFFFFFF: return 0, False varlen, prekey_len = 1, 0 - if DDColumnType.is_var( - self.type - ): ## TODO: judge prefix key + if DDColumnType.is_var(self.type): ## TODO: judge prefix key if self.collation_id == 255: varlen = 4 elif DDColumnType(self.type) in [ @@ -195,8 +196,8 @@ def private_data(self): def varchar_size(self): s = column_type_size.match(self.column_type_utf8) if s is not None: - return int(int(s.group(1))/2 - 1) - return int(self.char_length/2) - 1 + return int(int(s.group(1)) / 2 - 1) + return int(self.char_length / 2) - 1 def __post_init__(self): ce: typing.List[ColumnElement] = [ColumnElement(**e) for e in self.elements] @@ -494,6 +495,7 @@ def read_data(self, stream, size=None, quick=True): logging.debug("geometry data is %s, size is %d", data, dsize) return data + @modify_init @dataclass(eq=False) class Index: diff --git a/src/pyinnodb/sdi/table.py b/src/pyinnodb/sdi/table.py index 2e1decf..b86cc06 100644 --- a/src/pyinnodb/sdi/table.py +++ b/src/pyinnodb/sdi/table.py @@ -16,7 +16,7 @@ from dataclasses import dataclass from datetime import date, datetime, timedelta -from .. import const +from .. import const, treeascii from ..const.dd_column_type import DDColumnType, DDColConf, rand_none from ..disk_struct.data import MGeo, MTime2 from ..disk_struct.index import MIndexPage @@ -39,7 +39,6 @@ def __str__(self): return f"" - @modify_init @dataclass(eq=False) class CheckCons: @@ -52,8 +51,6 @@ def gen(self): return f"CONSTRAINT `{self.name}` CHECK ({self.check_clause_utf8})" - - @modify_init @dataclass(eq=False) class ForeignElement: @@ -169,7 +166,9 @@ class Table: # def gen_ddl(self, schema): - table_name = f"`{self.schema_ref}`.`{self.name}`" if schema else f"`{self.name}`" + table_name = ( + f"`{self.schema_ref}`.`{self.name}`" if schema else f"`{self.name}`" + ) column_desc = [] for c in self.columns: if c.hidden == const.column_hidden_type.ColumnHiddenType.HT_HIDDEN_SE.value: @@ -185,11 +184,7 @@ def gen_ddl(self, schema): parts = self.gen_sql_for_partition() collation = const.get_collation_by_id(self.collation_id) desc = f"ENGINE={self.engine} DEFAULT CHARSET={collation.CHARACTER_SET_NAME} COLLATE={collation.COLLATION_NAME}" - comment = ( - "\nCOMMENT '" + self.comment + "'" - if self.comment - else "" - ) + comment = "\nCOMMENT '" + self.comment + "'" if self.comment else "" return f"CREATE TABLE {table_name} ({column_desc}) {desc}{parts}{comment}" def update_with_frm(self, frm): @@ -197,7 +192,9 @@ def update_with_frm(self, frm): frm_header = mfrm.MFrm.parse_stream(f) self.columns = [] for i, col in enumerate(frm_header.cols): - self.columns.append(col.to_dd_column(col.name, i, frm_header.column_labels)) + self.columns.append( + col.to_dd_column(col.name, i, frm_header.column_labels) + ) keys, key_name, key_comment = frm_header.keys[0] @@ -210,7 +207,6 @@ def update_with_frm(self, frm): self.indexes = [idx] - @property @cache def private_data(self): @@ -231,20 +227,25 @@ def DataClassHiddenCol(self): return namedtuple(self.name, " ".join(cols)) def keys(self, no_primary=False, for_rand=False): - v = [f.name for f in dataclasses.fields(self.DataClass)] + v = [f.name for f in dataclasses.fields(self.DataClass)] if not no_primary and not for_rand: - return v + return v primary_key_name = [f.name for f in self.get_primary_key_col()] v = [f for f in v if f not in primary_key_name] if not for_rand: return v - target = [f.name for f in dataclasses.fields(self.DataClass) if DDColConf.get_col_type_conf(f.metadata['col'].type).rand_func != rand_none] + target = [ + f.name + for f in dataclasses.fields(self.DataClass) + if DDColConf.get_col_type_conf(f.metadata["col"].type).rand_func + != rand_none + ] return [f for f in v if f in target] def gen_rand_data_sql(self, size, rand_primary_key=False, varsize=None): rand_key = self.keys(for_rand=True) - rand_datas = self.gen_rand_data(size,varsize=varsize) + rand_datas = self.gen_rand_data(size, varsize=varsize) if rand_primary_key: primary_key_name = [f.name for f in self.get_primary_key_col()] @@ -255,7 +256,7 @@ def gen_rand_data_sql(self, size, rand_primary_key=False, varsize=None): for f in dataclasses.fields(self.DataClass): if f.name != pk: continue - pytype = DDColConf.get_col_type_conf(f.metadata['col'].type).pytype + pytype = DDColConf.get_col_type_conf(f.metadata["col"].type).pytype if pytype != int: print("not int primary key is not support") break @@ -279,12 +280,15 @@ def gen_rand_data(self, size, varsize=None): for f in dataclasses.fields(self.DataClass): if f.name not in keys: continue - func = DDColConf.get_col_type_conf(f.metadata['col'].type).rand_func + func = DDColConf.get_col_type_conf(f.metadata["col"].type).rand_func if func: - if varsize is not None and DDColumnType(f.metadata['col'].type) == DDColumnType.VARCHAR: - v.append(func(f.metadata['col'], varsize)) + if ( + varsize is not None + and DDColumnType(f.metadata["col"].type) == DDColumnType.VARCHAR + ): + v.append(func(f.metadata["col"], varsize)) else: - v.append(func(f.metadata['col'])) + v.append(func(f.metadata["col"])) vs.append(v) return vs @@ -296,7 +300,9 @@ def tf(rh, dc, **ctx): if with_page: result.append(ctx.get("page", None)) if with_key: - data = [{col.name: getattr(dc, col.name, None)} for col in primary_key_col] + data = [ + {col.name: getattr(dc, col.name, None)} for col in primary_key_col + ] else: data = [getattr(dc, col.name, None) for col in primary_key_col] if len(data) == 1: @@ -304,14 +310,15 @@ def tf(rh, dc, **ctx): result.append(data) result.append(rh) return result + return tf def trans_record_header(self, rh, dc, **ctx): return rh - + def wrap_transfer(self, rh, dc, **ctx): return self.transfer(dc) - + def transfer(self, dc, keys=None): vs = [] if keys is None: @@ -328,22 +335,18 @@ def transfer(self, dc, keys=None): vs.append("NULL") elif isinstance(f, MTime2): vs.append(f.to_str()) - elif ( - isinstance(f, date) - or isinstance(f, datetime) - ): + elif isinstance(f, date) or isinstance(f, datetime): vs.append(f"'{str(f)}'") elif isinstance(f, MGeo): d = f.build().hex() # .zfill(50) vs.append("0x" + d) elif isinstance(f, bytes): - vs.append("0x"+f.hex()) + vs.append("0x" + f.hex()) elif isinstance(f, decimal.Decimal): vs.append(str(f)) else: vs.append(repr(f)) return vs - @property @cache @@ -430,7 +433,9 @@ def get_disk_data_layout(self): data_layout_col = [] for i, c in enumerate(self.columns): data_layout_col.append((c, c_l.get(i, const.FFFFFFFF))) - data_layout_col.sort(key=lambda c: int(c[0].private_data.get("physical_pos", 0))) + data_layout_col.sort( + key=lambda c: int(c[0].private_data.get("physical_pos", 0)) + ) return data_layout_col data_layout_col = [] @@ -506,7 +511,8 @@ def search(self, f, primary_key, hidden_col): else: primary_key = self.build_primary_key_bytes((primary_key,)) value_parser = MIndexPage.default_value_parser( - self, hidden_col=hidden_col, # transfter=lambda rh, data: data, + self, + hidden_col=hidden_col, # transfter=lambda rh, data: data, quick=False, ) @@ -579,9 +585,21 @@ def search(self, f, primary_key, hidden_col): start_rh = MRecordHeader.parse_stream(f) logging.debug("index_page.fil.next_page is %s", index_page.fil.next_page) - if first_leaf_page == const.FFFFFFFF and index_page.fil.next_page != const.FFFFFFFF: + if ( + first_leaf_page == const.FFFFFFFF + and index_page.fil.next_page != const.FFFFFFFF + ): first_leaf_page = index_page.fil.next_page + def tree_view(self, f): + root_page_no = int(self.indexes[0].private_data.get("root", 4)) + f.seek(root_page_no * const.PAGE_SIZE) + root_index_page = MIndexPage.parse_stream(f) + vp = MIndexPage.value_parser_with_primary_key_only(self) + return treeascii.TreeNode( + root_page_no, children=root_index_page.list_records(f, vp) + ) + def iter_record(self, f, hidden_col=False, garbage=False, transfer=None): root_page_no = int(self.indexes[0].private_data.get("root", 4)) f.seek(root_page_no * const.PAGE_SIZE) @@ -602,7 +620,9 @@ def iter_record(self, f, hidden_col=False, garbage=False, transfer=None): index_page = MIndexPage.parse_stream(f) result.extend( index_page.iterate_record_header( - f, value_parser=default_value_parser, garbage=garbage, + f, + value_parser=default_value_parser, + garbage=garbage, page=first_leaf_page, ) ) @@ -679,9 +699,15 @@ def gen_sql_for_partition(self) -> str: parts = ",\n ".join(parts) + "\n" return "\n" + f"{p}{parts})*/" elif pt == const.partition.PartitionType.PT_HASH: - return "\n" + f"/*!50100 PARTITION BY HASH ({self.partition_expression_utf8}) PARTITIONS ({len(self.partitions)})*/" + return ( + "\n" + + f"/*!50100 PARTITION BY HASH ({self.partition_expression_utf8}) PARTITIONS ({len(self.partitions)})*/" + ) elif pt == const.partition.PartitionType.PT_KEY_55: - return "\n" + f"/*!50100 PARTITION BY KEY ({self.partition_expression_utf8}) PARTITIONS ({len(self.partitions)})*/" + return ( + "\n" + + f"/*!50100 PARTITION BY KEY ({self.partition_expression_utf8}) PARTITIONS ({len(self.partitions)})*/" + ) elif pt == const.partition.PartitionType.PT_LIST: p = f"/*!50100 PARTITION BY LIST ({self.partition_expression_utf8}) (\n " parts = [] diff --git a/src/pyinnodb/sdi/util.py b/src/pyinnodb/sdi/util.py index 8cb34c8..468e52b 100644 --- a/src/pyinnodb/sdi/util.py +++ b/src/pyinnodb/sdi/util.py @@ -1,5 +1,6 @@ import dataclasses + def modify_init(cls): old_init = cls.__init__ field_names = [f.name for f in dataclasses.fields(cls)] @@ -18,4 +19,3 @@ def __init__(self, **kwargs): cls.__init__ = __init__ return cls - From 44ca958daf3187e27f75114bac058f64d3e1fcbd Mon Sep 17 00:00:00 2001 From: WinChua Date: Thu, 23 Oct 2025 10:37:03 +0800 Subject: [PATCH 05/10] feat(dp): option for random primary-key when generate data --- devtools/deploy_mysqld.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/devtools/deploy_mysqld.py b/devtools/deploy_mysqld.py index 2db13cf..aff86c2 100644 --- a/devtools/deploy_mysqld.py +++ b/devtools/deploy_mysqld.py @@ -125,6 +125,7 @@ def connect(version, sql): else: os.system(deploy_container.get(version).cmd + f" -e '{sql}'") + @main.command() @click.option("--version", type=click.STRING, default="") @click.option("--sql", type=click.STRING, default="") @@ -140,7 +141,7 @@ def exec(version, sql, file): with open(file, "r") as f: sql = f.read() with engine.connect() as conn: - #result = conn.exec_driver_sql(sql) + # result = conn.exec_driver_sql(sql) result = conn.execute(text(sql)) conn.commit() if result.rowcount == 0: @@ -150,14 +151,16 @@ def exec(version, sql, file): print(r) else: print("执行成功,影响行数:", result.rowcount) - + @main.command() @click.option("--version", type=click.STRING, default="") @click.option("--table", type=click.STRING, default="") @click.option("--size", type=click.INT, default=100) @click.option("--idx", type=click.INT, default=-1) -@click.option("--random-primary-key/--no-random-primary-key", type=click.BOOL, default=False) +@click.option( + "--random-primary-key/--no-random-primary-key", type=click.BOOL, default=False +) @click.option("--varsize", type=click.INT, default=None, help="up size of varchar") def rand_data(version, table, size, idx, random_primary_key, varsize): deploy_container = load_deploy() @@ -177,20 +180,26 @@ def rand_data(version, table, size, idx, random_primary_key, varsize): return f.seek(fsp.sdi_page_no * const.PAGE_SIZE) sdi_page = MSDIPage.parse_stream(f) - all_tables = [d for d in sdi_page.iterate_sdi_record(f) if d["dd_object_type"] == "Table"] + all_tables = [ + d for d in sdi_page.iterate_sdi_record(f) if d["dd_object_type"] == "Table" + ] if len(all_tables) > 1 and idx == -1: print("these is more than one table, please use --idx to specify one") return elif len(all_tables) == 1: idx = 0 dd_object = Table(**all_tables[idx]["dd_object"]) - sql = dd_object.gen_rand_data_sql(size, rand_primary_key=random_primary_key, varsize=varsize) + sql = dd_object.gen_rand_data_sql( + size, rand_primary_key=random_primary_key, varsize=varsize + ) engine = create_engine(deploy_container.get(version).url) with engine.connect() as conn: conn.exec_driver_sql(sql) conn.commit() - print(f"insert {size} record randomly into {dd_object.schema_ref}.{dd_object.name}") - + print( + f"insert {size} record randomly into {dd_object.schema_ref}.{dd_object.name}" + ) + if __name__ == "__main__": main() From 766904c466fa33edb753cc1c640fff63ce9cb403 Mon Sep 17 00:00:00 2001 From: WinChua Date: Mon, 27 Oct 2025 15:06:55 +0800 Subject: [PATCH 06/10] refa: ellip => hidden --- src/pyinnodb/cli/iter_record.py | 8 ++++---- src/pyinnodb/treeascii.py | 24 ++++++++++++------------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/pyinnodb/cli/iter_record.py b/src/pyinnodb/cli/iter_record.py index 867841d..fcd5f04 100644 --- a/src/pyinnodb/cli/iter_record.py +++ b/src/pyinnodb/cli/iter_record.py @@ -92,16 +92,16 @@ def value_parser(rh: MRecordHeader, f): @main.command() @click.pass_context -@click.option("--ellip-leaf/--no-ellip-leaf", type=click.BOOL, default=True) -@click.option("--ellip-all/--no-ellip-all", type=click.BOOL, default=True) -def tree_view(ctx, ellip_leaf=True, ellip_all=True): +@click.option("--hidden-leaf/--no-hidden-leaf", type=click.BOOL, default=True) +@click.option("--hidden-all/--no-hidden-all", type=click.BOOL, default=True) +def tree_view(ctx, hidden_leaf=True, hidden_all=True): f = ctx.obj["fn"] fsp_page: MFspPage = ctx.obj["fsp_page"] f.seek(fsp_page.sdi_page_no * const.PAGE_SIZE) sdi_page = MSDIPage.parse_stream(f) dd_object = Table(**sdi_page.ddl(f, 0)["dd_object"]) tree = dd_object.tree_view(f) - print(tree.build_block(ellip_leaf=ellip_leaf, ellip_all=ellip_all).text) + print(tree.build_block(hidden_leaf=hidden_leaf, hidden_all=hidden_all).text) @main.command() diff --git a/src/pyinnodb/treeascii.py b/src/pyinnodb/treeascii.py index 47b1076..e6f18e7 100644 --- a/src/pyinnodb/treeascii.py +++ b/src/pyinnodb/treeascii.py @@ -95,27 +95,27 @@ def __init__(self, text: str, children=None): def is_leaf(self) -> bool: return len(self.children) == 0 - def build_block(self, ellip_leaf=False, ellip_all=False) -> TextBlock: + def build_block(self, hidden_leaf=False, hidden_all=False) -> TextBlock: if self.is_leaf: return TextBlock(self.text) blocks = [] - ellip_leaf_cnt = 0 + hidden_leaf_cnt = 0 for i, c in enumerate(self.children): - if ellip_leaf and len(self.children) > 3: + if hidden_leaf and len(self.children) > 3: if i != 0 and i != len(self.children) - 1: - if c.is_leaf or ellip_all: - ellip_leaf_cnt += 1 + if c.is_leaf or hidden_all: + hidden_leaf_cnt += 1 else: - if ellip_leaf_cnt != 0: - blocks.append(TextBlock(f"({ellip_leaf_cnt} ellip)")) - blocks.append(c.build_block(ellip_leaf=ellip_leaf, ellip_all=ellip_all)) - ellip_leaf_cnt = 0 + if hidden_leaf_cnt != 0: + blocks.append(TextBlock(f"({hidden_leaf_cnt} hidden)")) + blocks.append(c.build_block(hidden_leaf=hidden_leaf, hidden_all=hidden_all)) + hidden_leaf_cnt = 0 continue if i == len(self.children) - 1: - if ellip_leaf_cnt != 0: - blocks.append(TextBlock(f"({ellip_leaf_cnt} ellip)")) - blocks.append(c.build_block(ellip_leaf=ellip_leaf, ellip_all=ellip_all)) + if hidden_leaf_cnt != 0: + blocks.append(TextBlock(f"({hidden_leaf_cnt} hidden)")) + blocks.append(c.build_block(hidden_leaf=hidden_leaf, hidden_all=hidden_all)) max_lines = max(block.size[0] for block in blocks) tops = [] From 690fa21ced3aaa1e89271a3661cc7704d47c9b52 Mon Sep 17 00:00:00 2001 From: WinChua Date: Tue, 28 Oct 2025 11:23:17 +0800 Subject: [PATCH 07/10] fix(treeascii): out of index --- src/pyinnodb/cli/static_usage.py | 2 ++ src/pyinnodb/treeascii.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pyinnodb/cli/static_usage.py b/src/pyinnodb/cli/static_usage.py index 057987e..353be27 100644 --- a/src/pyinnodb/cli/static_usage.py +++ b/src/pyinnodb/cli/static_usage.py @@ -42,6 +42,8 @@ def list_page(ctx, kind): color.ratio_matrix_width_high( ratios, 64, int((len(ratios) / 64) + 1), "Page NO." ) + fratios = [s for s in ratios if s > 0] + print(f"use {len(fratios)} index page, avg ratio: {sum(fratios)/len(fratios)}") @main.command() diff --git a/src/pyinnodb/treeascii.py b/src/pyinnodb/treeascii.py index e6f18e7..c6eb11c 100644 --- a/src/pyinnodb/treeascii.py +++ b/src/pyinnodb/treeascii.py @@ -137,7 +137,7 @@ def build_block(self, hidden_leaf=False, hidden_all=False) -> TextBlock: parent_text = TextBlock(parent_text.ljust(joined.size[1], " ")) l, m, r = parent_text.loc p_mid = m - r_text = uni_invt if top_line[p_mid] == uni_h else uni_ten + r_text = uni_invt if p_mid < len(top_line) and top_line[p_mid] == uni_h else uni_ten r_text = uni_v if len(blocks) == 1 else r_text top_line = top_line[:p_mid] + r_text + top_line[p_mid + 1 :] return TextBlock("\n".join([parent_text.text, top_line, joined.text])) From 01735768f4bd39ac8dca2bb77670e2660ebdcd04 Mon Sep 17 00:00:00 2001 From: WinChua Date: Wed, 29 Oct 2025 08:59:45 +0800 Subject: [PATCH 08/10] feat(sdi): guess the page number of sdi --- src/pyinnodb/cli/iter_record.py | 6 +++--- src/pyinnodb/cli/sdi.py | 2 +- src/pyinnodb/cli/sql.py | 13 ++++++++++--- src/pyinnodb/cli/systab.py | 2 +- src/pyinnodb/cli/undo.py | 2 +- src/pyinnodb/disk_struct/fsp.py | 12 ++++++++++++ src/pyinnodb/sdi/column.py | 2 ++ 7 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/pyinnodb/cli/iter_record.py b/src/pyinnodb/cli/iter_record.py index fcd5f04..c249b7a 100644 --- a/src/pyinnodb/cli/iter_record.py +++ b/src/pyinnodb/cli/iter_record.py @@ -39,7 +39,7 @@ def search(ctx, primary_key, pageno, hidden_col, with_hist, datadir): f: t.IO[t.Any] = ctx.obj["fn"] # print("search start cost:", time.time() - ctx.obj["start_time"]) fsp_page: MFspPage = ctx.obj["fsp_page"] - f.seek(fsp_page.sdi_page_no * const.PAGE_SIZE) + f.seek(fsp_page.get_sdi_page_no_with_guess(f) * const.PAGE_SIZE) sdi_page = MSDIPage.parse_stream(f) dd_object = Table(**sdi_page.ddl(f, 0)["dd_object"]) @@ -97,7 +97,7 @@ def value_parser(rh: MRecordHeader, f): def tree_view(ctx, hidden_leaf=True, hidden_all=True): f = ctx.obj["fn"] fsp_page: MFspPage = ctx.obj["fsp_page"] - f.seek(fsp_page.sdi_page_no * const.PAGE_SIZE) + f.seek(fsp_page.get_sdi_page_no_with_guess(f) * const.PAGE_SIZE) sdi_page = MSDIPage.parse_stream(f) dd_object = Table(**sdi_page.ddl(f, 0)["dd_object"]) tree = dd_object.tree_view(f) @@ -131,7 +131,7 @@ def iter_record(ctx, garbage, hidden_col, pageno, sdi_idx, header=0): """ f = ctx.obj["fn"] fsp_page: MFspPage = ctx.obj["fsp_page"] - f.seek(fsp_page.sdi_page_no * const.PAGE_SIZE) + f.seek(fsp_page.get_sdi_page_no_with_guess(f) * const.PAGE_SIZE) sdi_page = MSDIPage.parse_stream(f) dd_object = Table(**sdi_page.ddl(f, sdi_idx)["dd_object"]) diff --git a/src/pyinnodb/cli/sdi.py b/src/pyinnodb/cli/sdi.py index f7179c0..5e32887 100644 --- a/src/pyinnodb/cli/sdi.py +++ b/src/pyinnodb/cli/sdi.py @@ -17,7 +17,7 @@ def sdi(ctx, pageno, idx): print("there is no SDI info in this file") return - f.seek(fsp_page.sdi_page_no * const.PAGE_SIZE) + f.seek(fsp_page.get_sdi_page_no_with_guess(f) * const.PAGE_SIZE) sdi_page = MSDIPage.parse_stream(f) all_sdi_record = list(sdi_page.iterate_sdi_record(f)) diff --git a/src/pyinnodb/cli/sql.py b/src/pyinnodb/cli/sql.py index 5e10ad0..6689ca6 100644 --- a/src/pyinnodb/cli/sql.py +++ b/src/pyinnodb/cli/sql.py @@ -17,7 +17,8 @@ @click.option("--sdi-idx", type=click.INT, default=0) @click.option("--sdi-name", type=click.STRING, default=None) @click.option("--schema/--no-schema", default=True) -def tosql(ctx, mode, sdi_idx, sdi_name, schema): +@click.option("--sdi-page-no", type=click.INT, default=None) +def tosql(ctx, mode, sdi_idx, sdi_name, schema, sdi_page_no): """dump the ddl/dml/sdi of the ibd table ddl) output the create table ddl; @@ -30,8 +31,12 @@ def tosql(ctx, mode, sdi_idx, sdi_name, schema): fsp_page = ctx.obj["fsp_page"] logger.debug("fsp header is %s", fsp_page.fsp_header) logger.debug("fsp page is %s", fsp_page.fil) - if fsp_page.sdi_version == 1: - f.seek(fsp_page.sdi_page_no * const.PAGE_SIZE) + page_no = fsp_page.get_sdi_page_no_with_guess(f) + logger.debug("fsp sdi guess is %d", fsp_page.get_sdi_page_no_with_guess(f)) + if page_no is not None or sdi_page_no is not None: + if page_no is None: + page_no = sdi_page_no + f.seek(page_no * const.PAGE_SIZE) sdi_page = MSDIPage.parse_stream(f) dd_obj = sdi_page.ddl(f, sdi_idx)["dd_object"] table_object = Table(**dd_obj) @@ -45,6 +50,8 @@ def tosql(ctx, mode, sdi_idx, sdi_name, schema): else: dump_ibd(table_object, f) return + else: + print("the file may not generated by mysql 8, if you're sure that, you can specify --sdi-page-no 3") def dump_ibd(table_object, f, oneline=True, in_json=False): diff --git a/src/pyinnodb/cli/systab.py b/src/pyinnodb/cli/systab.py index ea058d6..72a7bdf 100644 --- a/src/pyinnodb/cli/systab.py +++ b/src/pyinnodb/cli/systab.py @@ -21,7 +21,7 @@ def sys_tablespace(ctx): print("there is no SDI info in this file") return - f.seek(fsp_page.sdi_page_no * const.PAGE_SIZE) + f.seek(fsp_page.get_sdi_page_no_with_guess(f) * const.PAGE_SIZE) sdi_page = MSDIPage.parse_stream(f) diff --git a/src/pyinnodb/cli/undo.py b/src/pyinnodb/cli/undo.py index d272ad0..8e76250 100644 --- a/src/pyinnodb/cli/undo.py +++ b/src/pyinnodb/cli/undo.py @@ -39,7 +39,7 @@ def undo_record(ctx, pageno, offset, insert, rsegid): """show the history version of an RollbackPointer""" f = ctx.obj["fn"] fsp_page: MFspPage = ctx.obj["fsp_page"] - f.seek(fsp_page.sdi_page_no * const.PAGE_SIZE) + f.seek(fsp_page.get_sdi_page_no_with_guess(f) * constPAGE_SIZE) sdi_page = MSDIPage.parse_stream(f) dd_object = Table(**sdi_page.ddl["dd_object"]) undo_001 = open("datadir/undo_001", "rb") diff --git a/src/pyinnodb/disk_struct/fsp.py b/src/pyinnodb/disk_struct/fsp.py index b51b651..4c79f26 100644 --- a/src/pyinnodb/disk_struct/fsp.py +++ b/src/pyinnodb/disk_struct/fsp.py @@ -45,3 +45,15 @@ def iter_page(self, f, iter_func=None): f.seek(pn * const.PAGE_SIZE) if iter_func is not None: iter_func(f) + + def get_sdi_page_no_with_guess(self, f): + if self.sdi_version == 1: + return self.sdi_page_no + + for pn in range(self.fsp_header.highest_page_number): + f.seek(pn *const.PAGE_SIZE) + fil = MFil.parse_stream(f) + if const.PageType(fil.page_type) == const.PageType.SDI: + return fil.offset + + return None diff --git a/src/pyinnodb/sdi/column.py b/src/pyinnodb/sdi/column.py index 2101e62..6769aba 100644 --- a/src/pyinnodb/sdi/column.py +++ b/src/pyinnodb/sdi/column.py @@ -156,6 +156,8 @@ def version_valid(self, data_schema_version) -> bool: @property @cache def is_hidden_from_user(self): + if isinstance(self.hidden, bool): + return self.hidden return ( const.column_hidden_type.ColumnHiddenType(self.hidden) != const.column_hidden_type.ColumnHiddenType.HT_VISIBLE From deb041f63fc57573ed42ab790b655b56391d001f Mon Sep 17 00:00:00 2001 From: WinChua Date: Fri, 31 Oct 2025 15:52:49 +0800 Subject: [PATCH 09/10] feat(poe): support pass relate file path && run task everywhere --- devtools/deploy_mysqld.py | 19 ++++++++++++++----- pyproject.toml | 2 +- src/pyinnodb/cli/__main__.py | 4 ++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/devtools/deploy_mysqld.py b/devtools/deploy_mysqld.py index aff86c2..2f94230 100644 --- a/devtools/deploy_mysqld.py +++ b/devtools/deploy_mysqld.py @@ -3,6 +3,7 @@ import click import json from pprint import pprint +from pathlib import Path from dataclasses import dataclass, asdict from testcontainers.mysql import MySqlContainer @@ -18,6 +19,8 @@ c.ryuk_disabled = True +def get_project_root(): + return Path(__file__).parent.parent @click.group() def main(): @@ -29,6 +32,8 @@ def tlist(): data = load_deploy() pprint(data) +DEPLOY_MYSQLD_PATH=get_project_root() / ".deploy_mysqld" +DATADIR_BASE=get_project_root() / "datadir" @main.command() @click.option("--version", type=click.STRING) @@ -47,7 +52,7 @@ def clean(version): shutil.rmtree(deploy.datadir) del data[version] - with open(".deploy_mysqld", "w") as f: + with open(DEPLOY_MYSQLD_PATH, "w") as f: dump_deploy(data, f) @@ -60,8 +65,8 @@ class Instance: def load_deploy(): - if os.path.exists(".deploy_mysqld"): - with open(".deploy_mysqld", "r") as f: + if os.path.exists(DEPLOY_MYSQLD_PATH): + with open(DEPLOY_MYSQLD_PATH, "r") as f: try: data = json.load(f) for k, v in data.items(): @@ -90,12 +95,12 @@ def mDeploy(version): return mContainer = MySqlContainer(f"mysql:{version}") - datadir = os.getcwd() + f"/datadir/{version}" + datadir = DATADIR_BASE / f"/datadir/{version}" mContainer.with_volume_mapping(datadir, "/var/lib/mysql", "rw") os.makedirs(datadir) mContainer.with_kwargs(remove=True, user=os.getuid(), userns_mode="host") mysql = mContainer.start() - with open(".deploy_mysqld", "w") as f: + with open(DEPLOY_MYSQLD_PATH, "w") as f: deploy_container[version] = Instance( url=mysql.get_connection_url().replace("localhost", "127.0.0.1"), container_id=f"{mysql._container.short_id}", @@ -138,6 +143,10 @@ def exec(version, sql, file): url = deploy_container.get(version).url engine = create_engine(url) if file != "": + if not os.path.isabs(file): + poe_cwd = os.getenv("POE_CWD") + if poe_cwd: + file = os.path.join(poe_cwd, file) with open(file, "r") as f: sql = f.read() with engine.connect() as conn: diff --git a/pyproject.toml b/pyproject.toml index 2bb6803..c97ecf4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,6 @@ of = ["of:init", "of:req", "of:cp", "of:clean", "of:zip", "of:patch", "of:ex"] "of:zip" = "python -m zipapp target/ -m pyinnodb.cli:main -o pyinnodb.sh" "of:patch" = 'sed -i "1i\#!/usr/bin/env python3" pyinnodb.sh' "of:ex" = 'chmod a+x pyinnodb.sh' -"dp" = "python devtools/deploy_mysqld.py" +"dp" = "python ${POE_ROOT}/devtools/deploy_mysqld.py" "td" = "tar cvzf tests/test_data.tgz tests/mysql5/ tests/mysql8" bp.shell = "uv version --bump patch && git add -u . && git commit -m \"version release: `uv version --short`\" && git push" diff --git a/src/pyinnodb/cli/__main__.py b/src/pyinnodb/cli/__main__.py index 37edf5b..692344b 100644 --- a/src/pyinnodb/cli/__main__.py +++ b/src/pyinnodb/cli/__main__.py @@ -1,4 +1,8 @@ from .main import * +import os if __name__ == "__main__": + poe_cwd = os.getenv("POE_CWD") + if poe_cwd: + os.chdir(poe_cwd) main() From 022861c4de5aaf77267573f60728faad07fca906 Mon Sep 17 00:00:00 2001 From: WinChua Date: Fri, 31 Oct 2025 16:03:03 +0800 Subject: [PATCH 10/10] version release: 0.0.29 --- data/make_data.py | 4 +++- devtools/create_data.py | 5 +++-- pyproject.toml | 2 +- tests/context.py | 3 ++- tests/test_parse.py | 2 +- uv.lock | 2 +- 6 files changed, 11 insertions(+), 7 deletions(-) diff --git a/data/make_data.py b/data/make_data.py index 6c9256d..5f65dde 100644 --- a/data/make_data.py +++ b/data/make_data.py @@ -15,6 +15,8 @@ for i in range(100): values = [] for j in range(col): - values.append(f"({i*col+j},\"{''.join(random.choices(alphabet,k=10))}\")") + values.append( + f'({i * col + j},"{"".join(random.choices(alphabet, k=10))}")' + ) conn.exec_driver_sql(f"insert into test.t1 value{','.join(values)}") conn.commit() diff --git a/devtools/create_data.py b/devtools/create_data.py index 9d2a66d..be75648 100644 --- a/devtools/create_data.py +++ b/devtools/create_data.py @@ -32,14 +32,15 @@ class User(Base): ids = iter(random.sample(range(0, 2 * 1000 * 1000), 1000 * 1000)) for i in range(1000): session.bulk_insert_mappings( - User, [{"id": next(ids), "name": "a"*5000, "age": 10} for j in range(1000)] + User, + [{"id": next(ids), "name": "a" * 5000, "age": 10} for j in range(1000)], ) elif len(sys.argv) > 1 and sys.argv[1] == "drop": User.__table__.drop(bind=engine) else: for i in range(1000): session.bulk_insert_mappings( - User, [{"name": "a"*5000, "age": 10} for i in range(1000)] + User, [{"name": "a" * 5000, "age": 10} for i in range(1000)] ) session.commit() diff --git a/pyproject.toml b/pyproject.toml index c97ecf4..09020e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyinnodb" -version = "0.0.28" +version = "0.0.29" description = "A parser for InnoDB file formats, in Python" authors = [ { name = "WinChua", email = "winchua@foxmail.com" } diff --git a/tests/context.py b/tests/context.py index f966875..671c72f 100644 --- a/tests/context.py +++ b/tests/context.py @@ -4,7 +4,8 @@ import pathlib import pytest from collections import namedtuple -#ruff: noqa + +# ruff: noqa from pyinnodb import const cur_file = pathlib.Path(__file__) diff --git a/tests/test_parse.py b/tests/test_parse.py index 9f1fdc1..5c18f2e 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -35,7 +35,7 @@ def test_parse_mysql8(mysqlfile: MysqlFile): REAL=1092.892, SMALLINT=981, TEXT="TEXT", - TIME=MTime2(bin_data=b'\x801\x00'), + TIME=MTime2(bin_data=b"\x801\x00"), TIMESTAMP=datetime.datetime.strptime("2024-07-24 09:05:28", timeformat).replace( tzinfo=datetime.timezone.utc ), diff --git a/uv.lock b/uv.lock index 8453ecc..23f8fef 100644 --- a/uv.lock +++ b/uv.lock @@ -609,7 +609,7 @@ wheels = [ [[package]] name = "pyinnodb" -version = "0.0.28" +version = "0.0.29" source = { editable = "." } dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },