diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..808fd4f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + pull_request: + +env: + PY_COLORS: "1" + +jobs: + ci: + name: Tests on Python ${{ matrix.python-version }} + runs-on: windows-latest + + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v8.3.2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync + + - name: Run type checks + run: uv run poe typecheck + + - name: Run core tests + run: uv run pytest tests/core + + - name: Run wx integration tests + run: uv run pytest tests/wx_integration --cov-append + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v7 + with: + name: coverage-report-py${{ matrix.python-version }} + path: htmlcov/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 1548a1f..224589c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,7 +26,6 @@ // // Misc /////////////////////////////////////////////////////////////////// "files.exclude": { - ".venv/": true, "**/__pycache__": true, "*.egg-info": true, ".pytest_cache": true diff --git a/README.md b/README.md index 6537dd0..d7eb52e 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,6 @@ The preferred way to installation is via PyPI: pip install construct-editor ``` -## Tests -Unittests are in development and will be added by PR #40. - -The following static type checkers are fully supported: -- [mypy](https://github.com/python/mypy) -- [pyright](https://github.com/microsoft/pyright) -- [ty](https://github.com/astral-sh/ty) (experimental, since ty itself is still in development) - ## Development This project uses `uv` as a project management tool. To set up your development environment, run the following command: @@ -42,7 +34,6 @@ To run the unit tests, run: ```bash uv run poe test ``` -Note: Unittests are in development and will be added by PR #40. For now, this command is a placeholder and does nothing. To run the linter/code formatter (including auto fix), run: @@ -50,7 +41,7 @@ To run the linter/code formatter (including auto fix), run: uv run poe lint ``` -To run all supported type checkers, run: +To run all supported type checkers (currently [mypy](https://github.com/python/mypy), [pyright](https://github.com/microsoft/pyright) and [ty](https://github.com/astral-sh/ty)), run: ```bash uv run poe typecheck diff --git a/construct_editor/core/context_menu.py b/construct_editor/core/context_menu.py index 0cdfaa8..c57d351 100644 --- a/construct_editor/core/context_menu.py +++ b/construct_editor/core/context_menu.py @@ -4,12 +4,13 @@ import dataclasses import typing as t -import construct_editor.core.construct_editor as construct_editor -import construct_editor.core.entries as entries -from construct_editor.core.model import ( - ConstructEditorModel, - IntegerFormat, -) +from construct_editor.core.integer_format import IntegerFormat +from construct_editor.core.path import create_path_str + +if t.TYPE_CHECKING: + import construct_editor.core.construct_editor as construct_editor + import construct_editor.core.entries as entries + from construct_editor.core.model import ConstructEditorModel COPY_LABEL = "Copy" PASTE_LABEL = "Paste" @@ -163,7 +164,7 @@ def _init_list_viewed_entries(self): def on_remove_list_viewed_item(checked: bool): self.parent.disable_list_view(e) - label = entries.create_path_str(e.path) + label = create_path_str(e.path) submenu.subitems.append( CheckboxMenuItem( label, diff --git a/construct_editor/core/custom.py b/construct_editor/core/custom.py index 6b12c3a..9e69ae8 100644 --- a/construct_editor/core/custom.py +++ b/construct_editor/core/custom.py @@ -8,6 +8,7 @@ import construct_editor.core.entries as entries import construct_editor.core.model as model import construct_editor.core.preprocessor as preprocessor +from construct_editor.core.path import NameType def add_custom_transparent_subconstruct( @@ -34,7 +35,7 @@ def __init__( model: model.ConstructEditorModel, parent: entries.EntryConstruct | None, construct: cs.Compressed[t.Any, t.Any], - name: entries.NameType, + name: NameType, docs: str, ): super().__init__(model, parent, construct, name, docs) @@ -67,7 +68,7 @@ def __init__( model: model.ConstructEditorModel, parent: entries.EntryConstruct | None, construct: cs.Subconstruct[t.Any, t.Any, t.Any, t.Any], - name: entries.NameType, + name: NameType, docs: str, ): super().__init__(model, parent, construct, name, docs) diff --git a/construct_editor/core/entries.py b/construct_editor/core/entries.py index fb655e4..d655999 100644 --- a/construct_editor/core/entries.py +++ b/construct_editor/core/entries.py @@ -9,28 +9,38 @@ import construct as cs import construct_typed as cst -import construct_editor.core.model as model from construct_editor.core.context_menu import ( ButtonMenuItem, CheckboxMenuItem, ContextMenu, SeparatorMenuItem, ) +from construct_editor.core.integer_format import IntegerFormat +from construct_editor.core.path import ( + ListIndexName, + NameExcludedFromPath, + NameType, + PathType, + create_path_str, +) from construct_editor.core.preprocessor import ( GuiMetaData, IncludeGuiMetaData, get_gui_metadata, ) +if t.TYPE_CHECKING: + from construct_editor.core.model import ConstructEditorModel + def evaluate(param, context): return param(context) if callable(param) else param -def int_to_str(integer_format: model.IntegerFormat, val: int) -> str: +def int_to_str(integer_format: IntegerFormat, val: int) -> str: if isinstance(val, str): return val # tolerate string - if integer_format is model.IntegerFormat.Hex: + if integer_format is IntegerFormat.Hex: return f"0x{val:X}" return f"{val}" @@ -113,17 +123,13 @@ def _convert_restreamed(stream: cs.RestreamedBytesIO) -> io.BytesIO: def reset_substream_recursively(stream: io.BytesIO | cs.RestreamedBytesIO): if isinstance(stream, cs.RestreamedBytesIO): if stream.substream is None: - raise RuntimeError( - "stream.substream has to be io.BytesIO or cs.RestreamedBytesIO" - ) + raise RuntimeError("stream.substream has to be io.BytesIO or cs.RestreamedBytesIO") return reset_substream_recursively(stream.substream) else: stream.seek(0) # check if there is already a cached version - bytes_io_stream: io.BytesIO | None = getattr( - stream, "_construct_bytes_io", None - ) + bytes_io_stream: io.BytesIO | None = getattr(stream, "_construct_bytes_io", None) if bytes_io_stream is None: # reset substream recursively, so that the whole RestreamedBytesIO can be read again @@ -162,40 +168,16 @@ class StreamInfo: bitstream: bool -class NameExcludedFromPath(str): - pass - - -class ListIndexName(str): - pass - - -NameType = str | NameExcludedFromPath | ListIndexName - -PathType = t.List[str | ListIndexName] - - -def create_path_str(path: PathType) -> str: - path_str = "" - for p in path: - if isinstance(p, ListIndexName): - path_str += f"{p}" - else: - path_str += f".{p}" - if path_str.startswith("."): - path_str = path_str[1:] - return path_str - - # ##################################################################################################################### # Construct Entries ################################################################################################### # ##################################################################################################################### + # EntryConstruct ###################################################################################################### class EntryConstruct(object): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.Construct[Any, Any], name: NameType | None, @@ -366,9 +348,7 @@ def path(self) -> PathType: return path - def get_stream_infos( - self, child_stream: t.BinaryIO | None = None - ) -> t.List[StreamInfo]: + def get_stream_infos(self, child_stream: t.BinaryIO | None = None) -> t.List[StreamInfo]: """ Get infos about the current and parent streams. """ @@ -412,7 +392,7 @@ def get_stream_infos( class EntrySubconstruct(EntryConstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.Subconstruct[Any, Any, Any, Any], name: NameType | None, @@ -420,9 +400,7 @@ def __init__( ): super().__init__(model, parent, construct, name, docs) - self.subentry = create_entry_from_construct( - model, self, construct.subcon, None, "" - ) + self.subentry = create_entry_from_construct(model, self, construct.subcon, None, "") # pass throught "obj_str" to subentry ##################################### @property @@ -453,7 +431,7 @@ def modify_context_menu(self, menu: ContextMenu): class EntryStruct(EntryConstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.Struct, name: NameType | None, @@ -519,7 +497,7 @@ def on_collapse_children_clicked(): class EntryArray(EntrySubconstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.Array[Any, Any] | cs.GreedyRange[Any, Any], name: NameType | None, @@ -539,9 +517,7 @@ def subentries(self) -> List[EntryConstruct] | None: try: array_len = len(self.obj) except Exception: - if isinstance(self.construct, cs.Array) and isinstance( - self.construct.count, int - ): + if isinstance(self.construct, cs.Array) and isinstance(self.construct.count, int): array_len = self.construct.count else: array_len = 1 @@ -606,9 +582,7 @@ def on_collapse_children_clicked(): ) # If the subentry has no subentries itself, it makes no sense to create a list view. - temp_subentry = create_entry_from_construct( - self.model, self, self.construct.subcon, None, "" - ) + temp_subentry = create_entry_from_construct(self.model, self, self.construct.subcon, None, "") if temp_subentry.subentries is None: return @@ -634,7 +608,7 @@ def on_menu_item_clicked(checked: bool): class EntryIfThenElse(EntryConstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.IfThenElse[Any, Any], name: NameType | None, @@ -729,7 +703,7 @@ def modify_context_menu(self, menu: ContextMenu): class EntrySwitch(EntryConstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.Switch[Any, Any], name: NameType | None, @@ -875,7 +849,7 @@ class EntryFormatField(EntryConstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.FormatField[Any, Any], name: NameType | None, @@ -923,7 +897,7 @@ def typ_str(self) -> str: class EntryBytesInteger(EntryConstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.BytesInteger, name: NameType | None, @@ -971,7 +945,7 @@ def obj_view_settings(self) -> ObjViewSettings: class EntryBitsInteger(EntryConstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.BitsInteger, name: NameType | None, @@ -1011,7 +985,7 @@ def obj_view_settings(self) -> ObjViewSettings: class EntryStringEncoded(EntrySubconstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.StringEncoded, name: NameType | None, @@ -1032,7 +1006,7 @@ def obj_view_settings(self) -> ObjViewSettings: class EntryBytes(EntryConstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.Bytes | cs.Construct[bytes, bytes], name: NameType | None, @@ -1097,7 +1071,7 @@ def on_ascii_view_clicked(checked: bool): class EntryTell(EntryConstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.Construct[Any, Any], name: NameType | None, @@ -1114,7 +1088,7 @@ def typ_str(self) -> str: class EntrySeek(EntryConstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.Seek, name: NameType | None, @@ -1139,7 +1113,7 @@ def obj_str(self) -> str: class EntryPass(EntryConstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.Construct[None, None], name: NameType | None, @@ -1160,7 +1134,7 @@ def obj_str(self) -> str: class EntryConst(EntrySubconstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.Const[Any, Any], name: NameType | None, @@ -1177,7 +1151,7 @@ def obj_view_settings(self) -> ObjViewSettings: class EntryComputed(EntryConstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.Computed[Any], name: NameType | None, @@ -1201,7 +1175,7 @@ def obj_str(self) -> str: class EntryDefault(EntrySubconstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.Subconstruct[Any, Any, Any, Any], name: NameType | None, @@ -1230,7 +1204,7 @@ def on_default_clicked(): class EntryFocusedSeq(EntryConstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.FocusedSeq, name: NameType | None, @@ -1324,7 +1298,7 @@ def modify_context_menu(self, menu: ContextMenu): class EntrySelect(EntryConstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.Select, name: NameType | None, @@ -1414,7 +1388,7 @@ def modify_context_menu(self, menu: ContextMenu): class EntryTimestamp(EntrySubconstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.TimestampAdapter[Any, Any], name: NameType | None, @@ -1435,7 +1409,7 @@ def obj_view_settings(self) -> ObjViewSettings: class EntryTransparentSubcon(EntrySubconstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.Subconstruct[Any, Any, Any, Any], name: NameType | None, @@ -1448,7 +1422,7 @@ def __init__( class EntryNullStripped(EntrySubconstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.NullStripped[Any, Any], name: NameType | None, @@ -1469,7 +1443,7 @@ def typ_str(self) -> str: class EntryNullTerminated(EntrySubconstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.NullTerminated[Any, Any], name: NameType | None, @@ -1490,7 +1464,7 @@ def typ_str(self) -> str: class EntryChecksumSubcon(EntrySubconstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.Checksum[Any, Any, Any], name: NameType | None, @@ -1500,16 +1474,14 @@ def __init__( # So we call directly the parents parent __init__() method EntryConstruct.__init__(self, model, parent, construct, name, docs) - self.subentry = create_entry_from_construct( - model, self, construct.checksumfield, None, "" - ) + self.subentry = create_entry_from_construct(model, self, construct.checksumfield, None, "") # EntryCompressed ##################################################################################################### class EntryCompressed(EntrySubconstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.Compressed[Any, Any], name: NameType | None, @@ -1526,7 +1498,7 @@ def typ_str(self) -> str: class EntryPeek(EntrySubconstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.Peek[Any, Any], name: NameType | None, @@ -1543,7 +1515,7 @@ def construct(self) -> cs.Peek[Any, Any]: class EntryRawCopy(EntrySubconstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.RawCopy[Any, Any], name: NameType | None, @@ -1558,7 +1530,7 @@ def __init__( class EntryDataclassStruct(EntrySubconstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cst.DataclassStruct[Any], name: NameType | None, @@ -1586,7 +1558,7 @@ def typ_str(self) -> str: class EntryFlag(EntryConstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.FormatField[Any, Any], name: NameType | None, @@ -1616,7 +1588,7 @@ def typ_str(self) -> str: class EntryEnum(EntrySubconstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.Enum, name: NameType | None, @@ -1656,9 +1628,7 @@ def get_enum_item_from_obj(self) -> EnumItem: obj = self.obj if isinstance(obj, int): if obj in self.construct.decmapping: - return EnumItem( - name=str(self.construct.decmapping[obj]), value=int(obj) - ) + return EnumItem(name=str(self.construct.decmapping[obj]), value=int(obj)) else: return EnumItem(name=str(obj), value=int(obj)) else: @@ -1684,7 +1654,7 @@ def conv_str_to_obj(self, s: str) -> Any: class EntryFlagsEnum(EntrySubconstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cs.FlagsEnum, name: NameType | None, @@ -1726,9 +1696,7 @@ def get_flagsenum_items_from_obj(self) -> t.List[FlagsEnumItem]: flags = self.construct.flags obj = self.obj for flag in flags.keys(): - items.append( - FlagsEnumItem(name=str(flag), value=flags[flag], checked=obj[flag]) - ) + items.append(FlagsEnumItem(name=str(flag), value=flags[flag], checked=obj[flag])) return items def conv_flagsenum_items_to_obj(self, items: t.List[FlagsEnumItem]) -> Any: @@ -1750,7 +1718,7 @@ def get_enum_name(e: cst.EnumBase): class EntryTEnum(EntrySubconstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cst.TEnum[Any], name: NameType | None, @@ -1783,9 +1751,7 @@ def get_enum_items(self) -> t.List[EnumItem]: items: t.List[EnumItem] = [] enum_type: t.Type[cst.EnumBase] = self.construct.enum_type for e in enum_type: - items.append( - EnumItem(name=get_enum_name(e), value=e.value) - ) + items.append(EnumItem(name=get_enum_name(e), value=e.value)) return items def get_enum_item_from_obj(self) -> EnumItem: @@ -1810,7 +1776,7 @@ def conv_str_to_obj(self, s: str) -> Any: class EntryTFlagsEnum(EntrySubconstruct): def __init__( self, - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, construct: cst.TFlagsEnum[Any], name: NameType | None, @@ -1990,7 +1956,7 @@ def conv_flagsenum_items_to_obj(self, items: t.List[FlagsEnumItem]) -> Any: def create_entry_from_construct( - model: model.ConstructEditorModel, + model: ConstructEditorModel, parent: EntryConstruct | None, subcon: cs.Construct[Any, Any], name: NameType | None, diff --git a/construct_editor/core/integer_format.py b/construct_editor/core/integer_format.py new file mode 100644 index 0000000..8d8404b --- /dev/null +++ b/construct_editor/core/integer_format.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +import enum + + +class IntegerFormat(enum.Enum): + Dec = enum.auto() + Hex = enum.auto() diff --git a/construct_editor/core/model.py b/construct_editor/core/model.py index bfe2aae..dfd07d3 100644 --- a/construct_editor/core/model.py +++ b/construct_editor/core/model.py @@ -4,14 +4,12 @@ import enum import typing as t -import construct_editor.core.entries as entries from construct_editor.core.commands import Command, CommandProcessor +from construct_editor.core.integer_format import IntegerFormat from construct_editor.core.preprocessor import add_gui_metadata, get_gui_metadata - -class IntegerFormat(enum.Enum): - Dec = enum.auto() - Hex = enum.auto() +if t.TYPE_CHECKING: + from construct_editor.core.entries import EntryConstruct class ConstructEditorColumn(enum.IntEnum): @@ -22,7 +20,7 @@ class ConstructEditorColumn(enum.IntEnum): class ChangeValueCmd(Command): def __init__( - self, entry: entries.EntryConstruct, old_value: t.Any, new_value: t.Any + self, entry: EntryConstruct, old_value: t.Any, new_value: t.Any ) -> None: super().__init__(True, f"Value '{entry.path[-1]}' changed") self.entry = entry @@ -50,7 +48,7 @@ class ConstructEditorModel: """ def __init__(self): - self.root_entry: entries.EntryConstruct | None = None + self.root_entry: EntryConstruct | None = None self.root_obj: t.Any | None = None # Modelwide flag, if hidden entries should be shown (hidden means starting with an underscore) @@ -60,18 +58,18 @@ def __init__(self): self.integer_format = IntegerFormat.Dec # List with all entries that have the list view enabled - self.list_viewed_entries: t.List[entries.EntryConstruct] = [] + self.list_viewed_entries: t.List[EntryConstruct] = [] self.command_processor = CommandProcessor(max_commands=10) @abc.abstractmethod - def on_value_changed(self, entry: entries.EntryConstruct): + def on_value_changed(self, entry: EntryConstruct): """Implement this in the derived class""" ... def get_children( - self, entry: entries.EntryConstruct | None - ) -> t.List[entries.EntryConstruct]: + self, entry: EntryConstruct | None + ) -> t.List[EntryConstruct]: """ Get all children of an entry """ @@ -99,15 +97,15 @@ def get_children( subentry.visible_row = True return children - def is_container(self, entry: entries.EntryConstruct) -> bool: + def is_container(self, entry: EntryConstruct) -> bool: """ Check if an entry is a container (contains children) """ return entry.subentries is not None def get_parent( - self, entry: entries.EntryConstruct | None - ) -> entries.EntryConstruct | None: + self, entry: EntryConstruct | None + ) -> EntryConstruct | None: """ Get the parent of an entry """ @@ -128,7 +126,7 @@ def get_parent( # get the visible row entry of the parent return parent.get_visible_row_entry() - def get_value(self, entry: entries.EntryConstruct, column: int): + def get_value(self, entry: EntryConstruct, column: int): """ Return the value to be displayed for this entry in a specific column. """ @@ -145,7 +143,7 @@ def get_value(self, entry: entries.EntryConstruct, column: int): # flatten the hierarchical structure to a list column = column - len(ConstructEditorColumn) - flat_subentry_list: t.List[entries.EntryConstruct] = [] + flat_subentry_list: t.List[EntryConstruct] = [] flat_subentry_list = self.create_flat_subentry_list(entry) if len(flat_subentry_list) > column: return flat_subentry_list[column].obj_str @@ -153,7 +151,7 @@ def get_value(self, entry: entries.EntryConstruct, column: int): return "" def set_value( - self, new_value: t.Any, entry: entries.EntryConstruct, column: int + self, new_value: t.Any, entry: EntryConstruct, column: int ) -> None: """ Set the value of an entry. @@ -173,12 +171,12 @@ def set_value( self.command_processor.submit(cmd) def create_flat_subentry_list( - self, entry: entries.EntryConstruct - ) -> t.List[entries.EntryConstruct]: + self, entry: EntryConstruct + ) -> t.List[EntryConstruct]: """ Create a flat list with all subentires, recursively. """ - flat_subentry_list: t.List[entries.EntryConstruct] = [] + flat_subentry_list: t.List[EntryConstruct] = [] childs = self.get_children(entry) diff --git a/construct_editor/core/path.py b/construct_editor/core/path.py new file mode 100644 index 0000000..c0fcfb0 --- /dev/null +++ b/construct_editor/core/path.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import typing as t + + +class NameExcludedFromPath(str): + pass + + +class ListIndexName(str): + pass + + +NameType = str | NameExcludedFromPath | ListIndexName + +PathType = t.List[str | ListIndexName] + + +def create_path_str(path: PathType) -> str: + path_str = "" + for p in path: + if isinstance(p, ListIndexName): + path_str += f"{p}" + else: + path_str += f".{p}" + if path_str.startswith("."): + path_str = path_str[1:] + return path_str diff --git a/construct_editor/wx_widgets/wx_clipboard.py b/construct_editor/wx_widgets/wx_clipboard.py new file mode 100644 index 0000000..69f763c --- /dev/null +++ b/construct_editor/wx_widgets/wx_clipboard.py @@ -0,0 +1,42 @@ +"""Thin wrapper around wx.TheClipboard. + +Kept as small, free functions (rather than inlined wx.TheClipboard.Open()/ +Close() calls) so tests can mock `get_text`/`set_text` instead of touching +the real OS clipboard. +""" + +from __future__ import annotations + +import wx + + +def get_text() -> str | None: + """Return the current clipboard text. + + Returns None if the clipboard couldn't be opened (a warning is shown in + that case) or if it doesn't currently hold text data (e.g. an image) - + that's not an error, so no warning is shown for it. + """ + if not wx.TheClipboard.Open(): + wx.MessageBox("Can't open the clipboard", "Warning") + return None + try: + data = wx.TextDataObject() + if not wx.TheClipboard.GetData(data): + return None + return data.GetText() + finally: + wx.TheClipboard.Close() + + +def set_text(text: str) -> bool: + """Set the clipboard text. Return False if the clipboard couldn't be + opened (a warning is shown in that case).""" + if not wx.TheClipboard.Open(): + wx.MessageBox("Can't open the clipboard", "Warning") + return False + try: + wx.TheClipboard.SetData(wx.TextDataObject(text)) + return True + finally: + wx.TheClipboard.Close() diff --git a/construct_editor/wx_widgets/wx_hex_editor.py b/construct_editor/wx_widgets/wx_hex_editor.py index 27ef523..cbe89c9 100644 --- a/construct_editor/wx_widgets/wx_hex_editor.py +++ b/construct_editor/wx_widgets/wx_hex_editor.py @@ -11,6 +11,7 @@ from wx.grid import GridCellAttr from construct_editor.core.callbacks import CallbackList +from construct_editor.wx_widgets import wx_clipboard logger = logging.getLogger("my-logger") logger.propagate = False @@ -109,7 +110,6 @@ def remove_range(self, idx: int, length: int): class Cmd(wx.Command): def __init__(self): super().__init__(True, f"Remove Range (Index: {idx}, Length: {length})") - super().__init__(True, "Overwrite Range") def Do(self): self._range_backup = obj._binary[idx : idx + length] @@ -928,12 +928,7 @@ def _copy_selection(self) -> bool: byts = self._binary_data.get_range(sel0, length) - if wx.TheClipboard.Open(): - byts_str = byts.hex(" ") - wx.TheClipboard.SetData(wx.TextDataObject(byts_str)) - wx.TheClipboard.Close() - else: - wx.MessageBox("Can't open the clipboard", "Warning") + if not wx_clipboard.set_text(byts.hex(" ")): return False return True @@ -961,14 +956,12 @@ def _paste(self, overwrite: bool = False, insert: bool = False) -> bool: ) return False - # get data from clipboard - if not wx.TheClipboard.Open(): - wx.MessageBox("Can't open the clipboard", "Warning") + # get data from clipboard (None = clipboard couldn't be opened, or it + # doesn't currently hold text data, e.g. an image - either way there's + # nothing to paste) + clipboard_txt = wx_clipboard.get_text() + if clipboard_txt is None: return False - clipboard = wx.TextDataObject() - wx.TheClipboard.GetData(clipboard) - wx.TheClipboard.Close() - clipboard_txt: str = clipboard.GetText() byts = self.string_to_byts(clipboard_txt) if not byts: return False diff --git a/pyproject.toml b/pyproject.toml index 369f073..f600475 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ dependencies = [ "construct>=2.10.68", "construct-typing>=0.8.1,<0.9.0", "typing-extensions>=4.12.0", - "wrapt>=1.14.0", + "wrapt>=2.2.2", "wxPython>=4.2.2", ] @@ -69,6 +69,9 @@ dev = [ "pyright>=1.1.411", "ruff>=0.15.21", "ty>=0.0.59", + "pytest>=9.1.1", + "pytest-mock>=3.15.1", + "pytest-cov>=7.1.0", "cryptography", # optional "extra" from construct that the user may or may not have installed "cloudpickle", # optional "extra" from construct that the user may or may not have installed "lz4", # optional "extra" from construct that the user may or may not have installed @@ -87,6 +90,30 @@ include = [ [tool.setuptools.package-data] construct_editor = ["py.typed"] +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v --cov=construct_editor --cov-report=term-missing --cov-report=html" + +# Coverage is measured purely for informational purposes, to see which code +# still needs tests. There is intentionally no `fail_under` threshold - a low +# coverage percentage should never fail the build. +[tool.coverage.run] +source = ["construct_editor"] +branch = true + +[tool.coverage.report] +show_missing = true +skip_covered = false +exclude_also = [ + "if TYPE_CHECKING:", + "raise NotImplementedError", + "pass", + "\\.\\.\\.", +] + +[tool.coverage.html] +directory = "htmlcov" + [tool.mypy] strict = true # These errors should be activated in the future, but for now we don't want to refactor the entire codebase right now. @@ -176,9 +203,7 @@ ignore = [ ] [tool.poe.tasks.test] -# Tests are not yet implemented and will be added with PR #40. -# cmd = "pytest $POE_EXTRA_ARGS" -sequence = [] +cmd = "pytest $POE_EXTRA_ARGS" executor = {type = "uv", isolated = true} [tool.poe.tasks.lint] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/test_callbacks.py b/tests/core/test_callbacks.py new file mode 100644 index 0000000..3d37677 --- /dev/null +++ b/tests/core/test_callbacks.py @@ -0,0 +1,70 @@ +"""Tests for construct_editor.core.callbacks — CallbackList.""" + +import pytest + +from construct_editor.core.callbacks import CallbackList + + +def test_fire_calls_appended_callback() -> None: + """A callback that was appended is invoked when fire() is called.""" + calls: list[int] = [] + cb = CallbackList[[int]]() + cb.append(lambda x: calls.append(x)) + + cb.fire(42) + + assert calls == [42] + + +def test_fire_calls_multiple_callbacks_in_order() -> None: + """All appended callbacks are called in insertion order.""" + order: list[str] = [] + cb = CallbackList() + cb.append(lambda: order.append("first")) + cb.append(lambda: order.append("second")) + + cb.fire() + + assert order == ["first", "second"] + + +def test_remove_prevents_future_invocation() -> None: + """A removed callback must not be called on subsequent fire() calls.""" + calls: list[int] = [] + + def handler(x: int) -> None: + calls.append(x) + + cb = CallbackList() + cb.append(handler) + cb.remove(handler) + + cb.fire(1) + + assert calls == [] + + +def test_clear_removes_all_callbacks() -> None: + """clear() empties the callback list so no handlers are invoked.""" + calls: list[int] = [] + cb = CallbackList[[int]]() + cb.append(lambda x: calls.append(x)) + cb.append(lambda x: calls.append(x + 10)) + + cb.clear() + cb.fire(5) + + assert calls == [] + + +def test_fire_empty_list_does_not_raise() -> None: + """Firing an empty CallbackList must not raise any exception.""" + cb = CallbackList() + cb.fire() # should not raise + + +def test_remove_non_existing_raises() -> None: + """Removing a callback that was never appended should raise ValueError.""" + cb = CallbackList() + with pytest.raises(ValueError, match="not in list"): + cb.remove(lambda: None) diff --git a/tests/core/test_commands.py b/tests/core/test_commands.py new file mode 100644 index 0000000..53514e9 --- /dev/null +++ b/tests/core/test_commands.py @@ -0,0 +1,119 @@ +"""Tests for construct_editor.core.commands — Command / CommandProcessor.""" + + +from construct_editor.core.commands import Command, CommandProcessor + +# --------------------------------------------------------------------------- +# Minimal concrete Command for testing +# --------------------------------------------------------------------------- + + +class _IncrementCommand(Command): + """Increments/decrements a shared counter.""" + + def __init__(self, counter: list[int], amount: int = 1) -> None: + super().__init__(can_undo=True, name="Increment") + self._counter = counter + self._amount = amount + + def do(self) -> bool: + self._counter[0] += self._amount + return True + + def undo(self) -> bool: + self._counter[0] -= self._amount + return True + + +# --------------------------------------------------------------------------- +# CommandProcessor tests +# --------------------------------------------------------------------------- + + +def _make_processor() -> CommandProcessor: + return CommandProcessor(max_commands=100) + + +def test_submit_executes_command() -> None: + """submit() must call do() on the command.""" + counter = [0] + proc = _make_processor() + proc.submit(_IncrementCommand(counter)) + assert counter[0] == 1 + + +def test_can_undo_after_submit() -> None: + """can_undo() returns True after at least one command has been submitted.""" + proc = _make_processor() + assert not proc.can_undo() + proc.submit(_IncrementCommand([0])) + assert proc.can_undo() + + +def test_can_redo_after_undo() -> None: + """can_redo() returns True after an undo operation.""" + proc = _make_processor() + proc.submit(_IncrementCommand([0])) + assert not proc.can_redo() + proc.undo() + assert proc.can_redo() + assert not proc.can_undo() + + +def test_undo_reverses_command() -> None: + """undo() must call the command's undo() method, restoring the previous state.""" + counter = [0] + proc = _make_processor() + proc.submit(_IncrementCommand(counter)) + proc.undo() + assert counter[0] == 0 + + +def test_redo_re_executes_command() -> None: + """redo() must re-apply the previously undone command.""" + counter = [0] + proc = _make_processor() + proc.submit(_IncrementCommand(counter)) + proc.undo() + proc.redo() + assert counter[0] == 1 + + +def test_submit_after_undo_clears_redo_history() -> None: + """Submitting a new command after undo must discard the redo stack.""" + counter = [0] + proc = _make_processor() + proc.submit(_IncrementCommand(counter)) + proc.undo() + proc.submit(_IncrementCommand(counter, amount=5)) + assert not proc.can_redo() + + +def test_clear_commands_resets_state() -> None: + """clear_commands() empties the history so undo/redo are no longer possible.""" + proc = _make_processor() + proc.submit(_IncrementCommand([0])) + proc.clear_commands() + assert not proc.can_undo() + assert not proc.can_redo() + + +def test_multiple_undo_redo_cycle() -> None: + """Multiple submit/undo/redo operations must stay consistent.""" + counter = [0] + proc = _make_processor() + proc.submit(_IncrementCommand(counter, 1)) + proc.submit(_IncrementCommand(counter, 2)) + assert counter[0] == 3 + + proc.undo() + assert counter[0] == 1 + + proc.undo() + assert counter[0] == 0 + + proc.redo() + assert counter[0] == 1 + + proc.redo() + assert counter[0] == 3 diff --git a/tests/core/test_construct_editor.py b/tests/core/test_construct_editor.py new file mode 100644 index 0000000..fccac07 --- /dev/null +++ b/tests/core/test_construct_editor.py @@ -0,0 +1,25 @@ +"""Tests for construct_editor.core.construct_editor — ConstructEditor (abstract base). + +ConstructEditor is an abstract base class that requires a UI-framework-specific +subclass. These tests exercise only the framework-agnostic logic (parse/build +cycle, expand/collapse helpers, etc.) by using a minimal in-process stub. +""" + +import pytest + +# TODO: Implement a minimal stub of ConstructEditor (without wx) to test the +# framework-agnostic methods once the concrete dependencies are clearer. +# +# Intended test scenarios: +# - change_construct() triggers a re-parse +# - parse() populates the model +# - build() round-trips parsed data back to bytes +# - change_hide_protected() toggles visibility of protected fields +# - expand_all() / collapse_all() flip row_expanded on every entry +# - expand_level(n) expands entries up to depth n +# - copy/paste clipboard helpers delegate to _put_to_clipboard / _get_from_clipboard + + +@pytest.mark.skip(reason="Stub for ConstructEditor not yet implemented") +def test_placeholder() -> None: + pass diff --git a/tests/core/test_context_menu.py b/tests/core/test_context_menu.py new file mode 100644 index 0000000..c003d22 --- /dev/null +++ b/tests/core/test_context_menu.py @@ -0,0 +1,74 @@ +"""Tests for construct_editor.core.context_menu — menu item data types and ContextMenu. + +ContextMenu is abstract; these tests cover the pure-data menu item dataclasses +and the logic of the concrete init helpers without requiring a wx runtime. +""" + +from construct_editor.core.context_menu import ( + ButtonMenuItem, + CheckboxMenuItem, + RadioGroupMenuItems, + SeparatorMenuItem, + SubmenuItem, +) + + +def test_separator_menu_item_instantiation() -> None: + item = SeparatorMenuItem() + assert item is not None + + +def test_button_menu_item_fields() -> None: + callback_called: list[bool] = [] + item = ButtonMenuItem( + label="Copy", + shortcut="Ctrl+C", + enabled=True, + callback=lambda: callback_called.append(True), + ) + assert item.label == "Copy" + item.callback() + assert callback_called == [True] + + +def test_checkbox_menu_item_checked_state() -> None: + item = CheckboxMenuItem( + label="Show protected", + shortcut=None, + enabled=True, + checked=True, + callback=lambda v: None, + ) + assert item.label == "Show protected" + assert item.checked is True + + +def test_checkbox_menu_item_unchecked_state() -> None: + item = CheckboxMenuItem( + label="Show protected", + shortcut=None, + enabled=True, + checked=False, + callback=lambda v: None, + ) + assert item.checked is False + + +def test_radio_group_menu_items_fields() -> None: + item = RadioGroupMenuItems( + labels=["Dec", "Hex"], + checked_label="Dec", + callback=lambda label: None, + ) + assert item.labels == ["Dec", "Hex"] + assert item.checked_label == "Dec" + + +def test_submenu_item_fields() -> None: + item = SubmenuItem(label="Integer format", subitems=[SeparatorMenuItem()]) + assert item.label == "Integer format" + assert len(item.subitems) == 1 + + +# TODO: Add tests for ContextMenu._init_copy_paste, _init_undo_redo etc. once +# a concrete non-wx stub of ContextMenu is available. diff --git a/tests/core/test_custom.py b/tests/core/test_custom.py new file mode 100644 index 0000000..2136f13 --- /dev/null +++ b/tests/core/test_custom.py @@ -0,0 +1,74 @@ +"""Tests for construct_editor.core.custom — custom construct registration API. + +Covers add_custom_transparent_subconstruct, add_custom_tunnel, and +add_custom_adapter without any wx dependency. +""" + +import construct_typed as cst + +from construct_editor.core.custom import ( + add_custom_adapter, + add_custom_transparent_subconstruct, + add_custom_tunnel, +) + +# --------------------------------------------------------------------------- +# add_custom_transparent_subconstruct +# --------------------------------------------------------------------------- + + +def test_add_custom_transparent_subconstruct_registration_does_not_raise() -> None: + """Calling add_custom_transparent_subconstruct must not raise.""" + + class MySubconstruct(cst.Subconstruct[bytes, bytes, bytes, bytes]): + pass + + # Should not raise even if called multiple times + add_custom_transparent_subconstruct(MySubconstruct) + + +# TODO: Verify that include_metadata correctly wraps the custom subconstruct +# after registration and that parse/build still round-trips correctly. + + +# --------------------------------------------------------------------------- +# add_custom_tunnel +# --------------------------------------------------------------------------- + + +def test_add_custom_tunnel_registration_does_not_raise() -> None: + """Calling add_custom_tunnel must not raise.""" + + class MyTunnel(cst.Tunnel[bytes, bytes]): + def _decode(self, data, context, path): # type: ignore[override] + return data + + def _encode(self, data, context, path): # type: ignore[override] + return data + + add_custom_tunnel(MyTunnel, type_str="MyTunnel") + + +# TODO: Verify that byte_range metadata is propagated through the tunnel. + + +# --------------------------------------------------------------------------- +# add_custom_adapter +# --------------------------------------------------------------------------- + + +def test_add_custom_adapter_registration_does_not_raise() -> None: + """Calling add_custom_adapter must not raise.""" + from construct_editor.core.custom import AdapterObjEditorType + + class MyAdapter(cst.Adapter[bytes, bytes, bytes, bytes]): + def _decode(self, obj, context, path): # type: ignore[override] + return obj + + def _encode(self, obj, context, path): # type: ignore[override] + return obj + + add_custom_adapter(MyAdapter, type_str="MyAdapter", obj_editor_type=AdapterObjEditorType.Default) + + +# TODO: Verify correct ObjViewSettings are chosen for the adapter after registration. diff --git a/tests/core/test_entries.py b/tests/core/test_entries.py new file mode 100644 index 0000000..fd0494f --- /dev/null +++ b/tests/core/test_entries.py @@ -0,0 +1,81 @@ +"""Tests for construct_editor.core.entries. + +Covers helper utilities (int_to_str / str_to_int / str_to_bytes), +the EntryConstruct tree, and ObjViewSettings dataclasses. +The tests are UI-framework agnostic — no wx import is required. +""" + +import pytest + +from construct_editor.core.entries import ( + create_path_str, + int_to_str, + str_to_bytes, + str_to_int, +) +from construct_editor.core.model import IntegerFormat + +# --------------------------------------------------------------------------- +# int_to_str helpers +# --------------------------------------------------------------------------- + + +def test_int_to_str_decimal_format() -> None: + assert int_to_str(IntegerFormat.Dec, 255) == "255" + + +def test_int_to_str_hex_format() -> None: + assert int_to_str(IntegerFormat.Hex, 255) == "0xFF" + + +def test_int_to_str_zero_decimal() -> None: + assert int_to_str(IntegerFormat.Dec, 0) == "0" + + +def test_int_to_str_negative_decimal() -> None: + assert int_to_str(IntegerFormat.Dec, -1) == "-1" + + +def test_str_to_int_parse_decimal() -> None: + assert str_to_int("42") == 42 + + +def test_str_to_int_parse_hex_0x_prefix() -> None: + assert str_to_int("0xFF") == 255 + + +def test_str_to_int_parse_hex_lower() -> None: + assert str_to_int("0xff") == 255 + + +def test_str_to_int_invalid_string_raises() -> None: + with pytest.raises(ValueError, match="invalid literal for int"): + str_to_int("not_a_number") + + +def test_str_to_bytes_parse_hex_bytes() -> None: + result = str_to_bytes("01 02 03") + assert result == b"\x01\x02\x03" + + +def test_str_to_bytes_parse_empty_string() -> None: + result = str_to_bytes("") + assert result == b"" + + +@pytest.mark.parametrize("value", ["zz", "0xf", "f"]) +def test_str_to_bytes_invalid_hex_raises(value: str) -> None: + with pytest.raises(ValueError): + str_to_bytes(value) + + +def test_create_path_str_single_name() -> None: + result = create_path_str(["root"]) + assert "root" in result + + +def test_create_path_str_nested_path() -> None: + result = create_path_str(["root", "child", "leaf"]) + assert "root" in result + assert "child" in result + assert "leaf" in result diff --git a/tests/core/test_model.py b/tests/core/test_model.py new file mode 100644 index 0000000..5c462e5 --- /dev/null +++ b/tests/core/test_model.py @@ -0,0 +1,34 @@ +"""Tests for construct_editor.core.model. + +Covers IntegerFormat, ConstructEditorColumn enumerations +and the abstract ConstructEditorModel contract. +""" + + +from construct_editor.core.model import ConstructEditorColumn, IntegerFormat + + +def test_integer_format_members_exist() -> None: + assert IntegerFormat.Dec is not None + assert IntegerFormat.Hex is not None + + +def test_integer_format_distinct_values() -> None: + assert IntegerFormat.Dec != IntegerFormat.Hex + + +def test_construct_editor_column_name_column_index() -> None: + assert ConstructEditorColumn.Name == 0 + + +def test_construct_editor_column_type_column_index() -> None: + assert ConstructEditorColumn.Type == 1 + + +def test_construct_editor_column_value_column_index() -> None: + assert ConstructEditorColumn.Value == 2 + + +def test_construct_editor_column_all_columns_distinct() -> None: + cols = list(ConstructEditorColumn) + assert len(cols) == len(set(cols)) diff --git a/tests/core/test_preprocessor.py b/tests/core/test_preprocessor.py new file mode 100644 index 0000000..cdd26af --- /dev/null +++ b/tests/core/test_preprocessor.py @@ -0,0 +1,73 @@ +"""Tests for construct_editor.core.preprocessor. + +Covers the metadata-instrumentation layer (IncludeGuiMetaData, GuiMetaData, +metadata-carrier subclasses, include_metadata / get_gui_metadata helpers). +""" + + +import construct as cs + +from construct_editor.core.preprocessor import ( + get_gui_metadata, + include_metadata, +) + + +def test_include_metadata_returns_construct() -> None: + """include_metadata() must return a construct object.""" + wrapped = include_metadata(cs.Byte) + assert wrapped is not None + + +def test_parse_attaches_gui_metadata() -> None: + """Parsed values should carry GuiMetaData after instrumentation.""" + wrapped = include_metadata(cs.Byte) + result = wrapped.parse(b"\x2a") + meta = get_gui_metadata(result) + assert meta is not None + + +def test_gui_metadata_contains_byte_range() -> None: + """GuiMetaData must record the byte range of the parsed value.""" + wrapped = include_metadata(cs.Byte) + result = wrapped.parse(b"\x01") + meta = get_gui_metadata(result) + assert meta is not None + assert "byte_range" in meta + + +def test_struct_fields_have_independent_metadata() -> None: + """Each field in a Struct should have its own metadata with the correct byte range.""" + struct = cs.Struct("a" / cs.Byte, "b" / cs.Byte) + wrapped = include_metadata(struct) + result = wrapped.parse(b"\x01\x02") + meta_a = get_gui_metadata(result.a) + meta_b = get_gui_metadata(result.b) + assert meta_a is not None + assert meta_b is not None + # 'a' starts at byte 0, 'b' starts at byte 1 + assert meta_a["byte_range"][0] == 0 + assert meta_b["byte_range"][0] == 1 + + +def test_include_metadata_nested_struct() -> None: + """Metadata must be attached recursively for nested constructs.""" + inner = cs.Struct("x" / cs.Byte) + outer = cs.Struct("inner" / inner, "y" / cs.Byte) + wrapped = include_metadata(outer) + result = wrapped.parse(b"\x01\x02") + meta_x = get_gui_metadata(result.inner.x) + assert meta_x is not None + + +def test_get_gui_metadata_returns_none_for_plain_int() -> None: + """get_gui_metadata() returns None for values without attached metadata.""" + assert get_gui_metadata(42) is None + + +def test_get_gui_metadata_returns_none_for_plain_bytes() -> None: + assert get_gui_metadata(b"\x00") is None + + +def test_get_gui_metadata_returns_none_for_none() -> None: + assert get_gui_metadata(None) is None diff --git a/tests/wx_integration/__init__.py b/tests/wx_integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/wx_integration/conftest.py b/tests/wx_integration/conftest.py new file mode 100644 index 0000000..158c36e --- /dev/null +++ b/tests/wx_integration/conftest.py @@ -0,0 +1,75 @@ +"""Shared pytest fixtures for wx integration tests. + +All tests in this package require a running wx.App instance. +The session-scoped fixture below creates exactly one App for the entire +test session and tears it down afterwards. + +tests/wx_integration/ and tests/core/ are run as two separate pytest +invocations (see ci.yml), so wx can safely be imported at module level here +without pulling it into tests/core/ runs. +""" + +import typing as t + +import pytest +import wx + +from tests.wx_integration.wx_test_helpers import WxAppAndUiSim + +_BANNER_TEXT = ( + "wx UI test in progress\n" + "Simulated mouse/keyboard input is running for the test session.\n" + "Avoid touching the mouse/keyboard until it finishes." +) + + +def show_input_warning_banner() -> wx.Frame: + banner = wx.Frame( + None, + title="wx UI test in progress", + style=wx.STAY_ON_TOP | wx.CAPTION | wx.FRAME_NO_TASKBAR, + ) + text = wx.StaticText(banner, label=_BANNER_TEXT, style=wx.ALIGN_CENTER) + text.SetForegroundColour(wx.Colour(200, 0, 0)) + sizer = wx.BoxSizer(wx.VERTICAL) + sizer.Add(text, flag=wx.ALL, border=12) + banner.SetSizerAndFit(sizer) + banner.CentreOnScreen() + banner.Show(True) + banner.Raise() + return banner + + +@pytest.fixture(scope="session") +def wx_app_and_ui_sim() -> t.Generator[WxAppAndUiSim, None, None]: + """Session-scoped wx.App + wx.UIActionSimulator (plus its advisory + input-warning banner), required by all wx widget tests. + + Owns the actual setup/teardown for both, so the destroy order between + them is explicit in one place: the banner is destroyed first, then the + wx.App — see input_warning_banner.py. + """ + app = wx.App(False) + banner = show_input_warning_banner() + simulator = wx.UIActionSimulator() + try: + yield WxAppAndUiSim(app=app, ui_simulator=simulator) + finally: + # Close the banner. That should be the last Top-Level-Window. When this is closed, + # the wx.App will exit its MainLoop and return control to pytest. + banner.Close() + app.MainLoop() + + +@pytest.fixture +def wx_harness(wx_app_and_ui_sim: WxAppAndUiSim): + """Function-scoped WxTestHarness (wx.App + a top-level wx.Frame + a + wx.UIActionSimulator) used as the parent for tested widgets. + + The frame is created fresh for each test and destroyed afterwards to + avoid state leakage. + """ + from tests.wx_integration.wx_test_helpers import WxTestHarness + + with WxTestHarness.create(wx_app_and_ui_sim.app, wx_app_and_ui_sim.ui_simulator) as harness: + yield harness diff --git a/tests/wx_integration/test_wx_construct_editor.py b/tests/wx_integration/test_wx_construct_editor.py new file mode 100644 index 0000000..7cd5b85 --- /dev/null +++ b/tests/wx_integration/test_wx_construct_editor.py @@ -0,0 +1,18 @@ +"""Tests for construct_editor.wx_widgets.wx_construct_editor.WxConstructEditor. + +Covers the WxConstructEditor composite panel. +""" + + + +def test_placeholder(wx_app_and_ui_sim) -> None: + pass + + +# TODO: Tests to add: +# - Widget can be constructed without exceptions +# - change_construct() with a simple cs.Struct parses successfully +# - build() round-trips parsed bytes correctly +# - Selecting an entry fires the on_entry_selected callback +# - expand_all() / collapse_all() update the tree + diff --git a/tests/wx_integration/test_wx_construct_editor_model.py b/tests/wx_integration/test_wx_construct_editor_model.py new file mode 100644 index 0000000..588e7d5 --- /dev/null +++ b/tests/wx_integration/test_wx_construct_editor_model.py @@ -0,0 +1,16 @@ +"""Tests for construct_editor.wx_widgets.wx_construct_editor.WxConstructEditorModel. + +WxConstructEditorModel is the wx DataView model adapter. +""" + + + +def test_placeholder(wx_app_and_ui_sim) -> None: + pass + + +# TODO: Instantiate WxConstructEditorModel with a simple construct and verify: +# - GetChildren() returns the correct number of rows for a flat Struct +# - IsContainer() returns True for Struct entries, False for leaf entries +# - GetValue() returns expected strings for Name, Type, Value columns +# - SetValue() triggers on_value_changed callback diff --git a/tests/wx_integration/test_wx_construct_hex_editor.py b/tests/wx_integration/test_wx_construct_hex_editor.py new file mode 100644 index 0000000..8aa865a --- /dev/null +++ b/tests/wx_integration/test_wx_construct_hex_editor.py @@ -0,0 +1,18 @@ +"""Tests for construct_editor.wx_widgets.wx_construct_hex_editor.WxConstructHexEditor. + +Covers the composite WxConstructHexEditor widget. +""" + + + +def test_placeholder(wx_app_and_ui_sim) -> None: + pass + + +# TODO: Tests to add: +# - Widget can be constructed and shown without errors +# - Setting binary data triggers a parse pass +# - Editing a struct field via the construct editor updates the hex view +# - toggle_hex_visibility() hides/shows the hex editor pane +# - round-trip: parse bytes -> build -> compare to original bytes + diff --git a/tests/wx_integration/test_wx_construct_hex_editor_panel.py b/tests/wx_integration/test_wx_construct_hex_editor_panel.py new file mode 100644 index 0000000..69df635 --- /dev/null +++ b/tests/wx_integration/test_wx_construct_hex_editor_panel.py @@ -0,0 +1,15 @@ +"""Tests for construct_editor.wx_widgets.wx_construct_hex_editor.HexEditorPanel. + +Covers the HexEditorPanel splitter widget. +""" + + + +def test_placeholder(wx_app_and_ui_sim) -> None: + pass + + +# TODO: Tests to add: +# - Panel can be instantiated without exceptions +# - create_sub_panel() returns a valid child panel +# - clear_sub_panels() removes all child panels diff --git a/tests/wx_integration/test_wx_context_menu.py b/tests/wx_integration/test_wx_context_menu.py new file mode 100644 index 0000000..c99f4db --- /dev/null +++ b/tests/wx_integration/test_wx_context_menu.py @@ -0,0 +1,21 @@ +"""Tests for construct_editor.wx_widgets.wx_context_menu. + +Covers WxContextMenu — the concrete wx.Menu implementation of the abstract +ContextMenu base class. +""" + + + +def test_placeholder(wx_app_and_ui_sim) -> None: + pass + + +# TODO: Tests to add: +# - Menu can be instantiated without exceptions given a ConstructEditor stub +# - Separator items are added as wx.ITEM_SEPARATOR entries +# - Button items are added as wx.ITEM_NORMAL entries with correct labels +# - Checkbox items reflect the initial checked state +# - RadioGroup items create a radio group with the correct selected index +# - Submenu items create a nested wx.Menu +# - Clicking a button item invokes the provided callback + diff --git a/tests/wx_integration/test_wx_exception_dialog.py b/tests/wx_integration/test_wx_exception_dialog.py new file mode 100644 index 0000000..961693b --- /dev/null +++ b/tests/wx_integration/test_wx_exception_dialog.py @@ -0,0 +1,15 @@ +"""Tests for construct_editor.wx_widgets.wx_exception_dialog.WxExceptionDialog.""" + + + +def test_placeholder(wx_app_and_ui_sim) -> None: + pass + + +# TODO: Tests to add: +# - Dialog can be instantiated with an ExceptionInfo without raising +# - Exception type name is shown in the dialog text +# - Traceback text is non-empty +# - Dialog can be closed without errors + + diff --git a/tests/wx_integration/test_wx_exception_info.py b/tests/wx_integration/test_wx_exception_info.py new file mode 100644 index 0000000..c7290e2 --- /dev/null +++ b/tests/wx_integration/test_wx_exception_info.py @@ -0,0 +1,18 @@ +"""Tests for construct_editor.wx_widgets.wx_exception_dialog.ExceptionInfo (no wx required).""" + +from construct_editor.wx_widgets.wx_exception_dialog import ExceptionInfo + + +def test_fields_stored_correctly() -> None: + """ExceptionInfo must store etype, value, and trace.""" + try: + raise ValueError("boom") + except ValueError as exc: + import sys + + etype, value, trace = sys.exc_info() + assert etype is not None + assert value is not None + info = ExceptionInfo(etype=etype, value=value, trace=trace) + assert info.etype is ValueError + assert info.value is exc diff --git a/tests/wx_integration/test_wx_hex_editor.py b/tests/wx_integration/test_wx_hex_editor.py new file mode 100644 index 0000000..08eca9e --- /dev/null +++ b/tests/wx_integration/test_wx_hex_editor.py @@ -0,0 +1,83 @@ +"""Tests for construct_editor.wx_widgets.wx_hex_editor.WxHexEditor. + +Widget-level tests drive the grid via real, simulated OS input +(wx.UIActionSimulator) against a visible, focused frame, so the actual +event-handling code path (cell selection, cell editing, key bindings) is +exercised rather than just the public API. +""" + +import wx + +from construct_editor.wx_widgets.wx_hex_editor import WxHexEditor +from tests.wx_integration.wx_test_helpers import ( + editor_grid, + editor_table, + grid_cell_screen_point, +) + + +def test_instantiates_without_exceptions(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03") + + assert editor.binary == b"\x01\x02\x03" + + +def test_setting_binary_replaces_displayed_data(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02") + + editor.binary = b"\xaa\xbb\xcc" + + assert editor.binary == b"\xaa\xbb\xcc" + table = editor_table(editor) + assert table.GetValue(0, 0) == "aa" + assert table.GetValue(0, 1) == "bb" + assert table.GetValue(0, 2) == "cc" + + +def test_refresh_does_not_raise(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02") + + editor.refresh() + + +def test_setting_invalid_hex_via_table_does_not_change_binary(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02") + + editor_table(editor).SetValue(0, 0, "zz") + + assert editor.binary == b"\x01\x02" + + +def test_colorise_highlights_range_via_table_attr(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03") + + editor.colorise(1, 3) + + table = editor_table(editor) + assert table.GetAttr(0, 1, 0).GetBackgroundColour() == wx.Colour( + 200, 200, 200 + ) + assert table.GetAttr(0, 0, 0).GetBackgroundColour() == wx.WHITE + + +def test_editing_cell_via_simulated_keyboard_fires_on_binary_changed( + wx_harness, mocker +) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x00\x00") + wx_harness.frame.Layout() + + callback = mocker.Mock() + editor.on_binary_changed.append(callback) + + grid = editor_grid(editor) + point = grid_cell_screen_point(grid, 0, 0) + + # click the first cell, type "FF" into it, then commit with Enter + wx_harness.move_mouse_to(point) + wx_harness.click() + wx_harness.type_text("FF") + wx_harness.key_press(wx.WXK_RETURN) + + callback.assert_called() + assert editor.binary[0] == 0xFF + diff --git a/tests/wx_integration/test_wx_hex_editor_binary_data.py b/tests/wx_integration/test_wx_hex_editor_binary_data.py new file mode 100644 index 0000000..ee5f4d1 --- /dev/null +++ b/tests/wx_integration/test_wx_hex_editor_binary_data.py @@ -0,0 +1,82 @@ +"""Tests for construct_editor.wx_widgets.wx_hex_editor.HexEditorBinaryData. + +HexEditorBinaryData is the observable bytearray driven directly through its +public API (no UI simulation) — it's a plain data/command-pattern class, not +a widget. It still needs a running wx.App because it uses +wx.Command/wx.CommandProcessor. +""" + +from construct_editor.wx_widgets.wx_hex_editor import HexEditorBinaryData + + +def test_overwrite_all_replaces_entire_buffer(wx_app_and_ui_sim) -> None: + data = HexEditorBinaryData(b"\x01\x02\x03") + data.overwrite_all(b"\xaa\xbb") + assert data.get_bytes() == b"\xaa\xbb" + + +def test_overwrite_range_changes_only_specified_slice(wx_app_and_ui_sim) -> None: + data = HexEditorBinaryData(b"\x00\x00\x00\x00") + data.overwrite_range(1, b"\xff\xff") + assert data.get_bytes() == b"\x00\xff\xff\x00" + + +def test_overwrite_range_with_unchanged_bytes_is_a_noop(wx_app_and_ui_sim, mocker) -> None: + data = HexEditorBinaryData(b"\x00\x00\x00\x00") + callback = mocker.Mock() + data.on_binary_changed.append(callback) + + data.overwrite_range(1, b"\x00\x00") + + assert data.get_bytes() == b"\x00\x00\x00\x00" + callback.assert_not_called() + + +def test_insert_range_grows_buffer(wx_app_and_ui_sim) -> None: + data = HexEditorBinaryData(b"\x01\x02") + data.insert_range(1, b"\xaa\xbb") + assert data.get_bytes() == b"\x01\xaa\xbb\x02" + + +def test_remove_range_shrinks_buffer(wx_app_and_ui_sim) -> None: + data = HexEditorBinaryData(b"\x01\x02\x03\x04") + data.remove_range(1, 2) + assert data.get_bytes() == b"\x01\x04" + + +def test_remove_range_uses_descriptive_undo_command_name(wx_app_and_ui_sim) -> None: + data = HexEditorBinaryData(b"\x01\x02\x03\x04") + data.remove_range(1, 2) + + command_name = data.command_processor.GetCurrentCommand().GetName() + assert command_name == "Remove Range (Index: 1, Length: 2)" + + +def test_on_binary_changed_fires_after_mutation(wx_app_and_ui_sim, mocker) -> None: + data = HexEditorBinaryData(b"\x00") + callback = mocker.Mock() + data.on_binary_changed.append(callback) + + data.overwrite_all(b"\x01") + + callback.assert_called_once_with(data) + + +def test_undo_reverts_last_mutation(wx_app_and_ui_sim) -> None: + data = HexEditorBinaryData(b"\x01\x02\x03") + data.overwrite_range(0, b"\xff") + assert data.get_bytes() == b"\xff\x02\x03" + + data.command_processor.Undo() + + assert data.get_bytes() == b"\x01\x02\x03" + + +def test_redo_reapplies_undone_mutation(wx_app_and_ui_sim) -> None: + data = HexEditorBinaryData(b"\x01\x02\x03") + data.overwrite_range(0, b"\xff") + data.command_processor.Undo() + + data.command_processor.Redo() + + assert data.get_bytes() == b"\xff\x02\x03" diff --git a/tests/wx_integration/test_wx_hex_editor_clipboard.py b/tests/wx_integration/test_wx_hex_editor_clipboard.py new file mode 100644 index 0000000..2dc4118 --- /dev/null +++ b/tests/wx_integration/test_wx_hex_editor_clipboard.py @@ -0,0 +1,171 @@ +"""Tests for HexEditorGrid's clipboard/mutation-on-selection methods: +`_cut_selection`, `_copy_selection`, `_paste`, `_remove_selection`, +`_insert_byte_at_selection`. + +These use a real WxHexEditor via `wx_harness` (same rationale as +test_wx_hex_editor_grid.py). `wx_clipboard.get_text`/`set_text` are mocked +(via `mocker.patch`) instead of touching the real OS clipboard, so these +tests don't clobber whatever the developer/CI actually has copied. +""" + +from construct_editor.wx_widgets import wx_clipboard +from construct_editor.wx_widgets.wx_hex_editor import WxHexEditor +from tests.wx_integration.wx_test_helpers import ( + copy_selection, + cut_selection, + editor_grid, + grid_selection, + insert_byte_at_selection, + paste_at_selection, + remove_selection, +) + + +def test_copy_selection_writes_hex_string_to_clipboard(wx_harness, mocker) -> None: + set_text = mocker.patch.object(wx_clipboard, "set_text", return_value=True) + editor = WxHexEditor(wx_harness.frame, binary=b"\xab\xcd\xef") + grid = editor_grid(editor) + grid.select_range(1, 2) + + result = copy_selection(grid) + + assert result is True + set_text.assert_called_once_with("cd ef") + + +def test_copy_selection_returns_false_when_clipboard_unavailable( + wx_harness, mocker +) -> None: + mocker.patch.object(wx_clipboard, "set_text", return_value=False) + mocker.patch("wx.MessageBox") + editor = WxHexEditor(wx_harness.frame, binary=b"\xab\xcd\xef") + grid = editor_grid(editor) + grid.select_range(0, 0) + + assert copy_selection(grid) is False + + +def test_copy_selection_returns_false_when_nothing_selected(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\xab\xcd\xef") + + assert copy_selection(editor_grid(editor)) is False + + +def test_remove_selection_deletes_selected_range(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03\x04") + grid = editor_grid(editor) + grid.select_range(1, 2) + + result = remove_selection(grid) + + assert result is True + assert editor.binary == b"\x01\x04" + # _remove_selection() moves the grid cursor afterwards, which fires + # EVT_GRID_SELECT_CELL and collapses the selection to that single cell + # (rather than leaving it at (None, None)). + assert grid_selection(grid) == (0, None) + + +def test_remove_selection_returns_false_when_read_only(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03\x04", read_only=True) + grid = editor_grid(editor) + grid.select_range(1, 2) + + assert remove_selection(grid) is False + assert editor.binary == b"\x01\x02\x03\x04" + + +def test_insert_byte_at_selection_inserts_zero_byte(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02") + grid = editor_grid(editor) + grid.select_range(1, 1) + + result = insert_byte_at_selection(grid) + + assert result is True + assert editor.binary == b"\x01\x00\x02" + + +def test_cut_selection_copies_then_removes(wx_harness, mocker) -> None: + set_text = mocker.patch.object(wx_clipboard, "set_text", return_value=True) + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03") + grid = editor_grid(editor) + grid.select_range(1, 2) + + result = cut_selection(grid) + + assert result is True + set_text.assert_called_once_with("02 03") + assert editor.binary == b"\x01" + + +def test_cut_selection_returns_false_when_read_only(wx_harness, mocker) -> None: + set_text = mocker.patch.object(wx_clipboard, "set_text", return_value=True) + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03", read_only=True) + grid = editor_grid(editor) + grid.select_range(1, 2) + + assert cut_selection(grid) is False + set_text.assert_not_called() + assert editor.binary == b"\x01\x02\x03" + + +def test_paste_overwrite_replaces_bytes_at_selection(wx_harness, mocker) -> None: + mocker.patch.object(wx_clipboard, "get_text", return_value="aa bb") + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03\x04") + grid = editor_grid(editor) + grid.select_range(1, 1) + + result = paste_at_selection(grid, overwrite=True) + + assert result is True + assert editor.binary == b"\x01\xaa\xbb\x04" + + +def test_paste_insert_grows_binary_at_selection(wx_harness, mocker) -> None: + mocker.patch.object(wx_clipboard, "get_text", return_value="aa bb") + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02") + grid = editor_grid(editor) + grid.select_range(1, 1) + + result = paste_at_selection(grid, insert=True) + + assert result is True + assert editor.binary == b"\x01\xaa\xbb\x02" + + +def test_paste_returns_false_when_nothing_selected(wx_harness, mocker) -> None: + get_text = mocker.patch.object(wx_clipboard, "get_text", return_value="aa bb") + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02") + + assert paste_at_selection(editor_grid(editor), overwrite=True) is False + get_text.assert_not_called() + + +def test_paste_returns_false_when_both_overwrite_and_insert_requested( + wx_harness, mocker +) -> None: + mocker.patch("wx.MessageBox") + get_text = mocker.patch.object(wx_clipboard, "get_text", return_value="aa bb") + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02") + grid = editor_grid(editor) + grid.select_range(0, 0) + + assert paste_at_selection(grid, overwrite=True, insert=True) is False + get_text.assert_not_called() + + +def test_paste_returns_false_when_clipboard_text_is_unparseable( + wx_harness, mocker +) -> None: + # A lone incomplete "\x" escape fails all three string_to_byts fallbacks + # (bytes.fromhex, the hex-digit regex, and the unicode-escape round trip). + mocker.patch.object(wx_clipboard, "get_text", return_value="\\x") + message_box = mocker.patch("wx.MessageBox") + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02") + grid = editor_grid(editor) + grid.select_range(0, 0) + + assert paste_at_selection(grid, overwrite=True) is False + assert editor.binary == b"\x01\x02" + message_box.assert_called_once() diff --git a/tests/wx_integration/test_wx_hex_editor_context_menu.py b/tests/wx_integration/test_wx_hex_editor_context_menu.py new file mode 100644 index 0000000..c3d7511 --- /dev/null +++ b/tests/wx_integration/test_wx_hex_editor_context_menu.py @@ -0,0 +1,141 @@ +"""Tests for HexEditorGrid.build_context_menu and _on_cell_right_click. + +`PopupMenu` (native, modal-ish and blocking until dismissed) is mocked out +via `mocker.patch.object(grid, "PopupMenu")` — actually showing a +real popup would hang the test until a human dismisses it. +""" + +import wx + +from construct_editor.wx_widgets.wx_hex_editor import WxHexEditor +from tests.wx_integration.wx_test_helpers import ( + editor_binary_data, + editor_grid, + editor_table, + remove_selection, + trigger_cell_right_click, +) + + +def test_build_context_menu_returns_cut_copy_paste_undo_redo_items(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02") + + items = editor_grid(editor).build_context_menu() + + ids = [item.wx_id for item in items if item is not None] + assert ids == [ + wx.ID_CUT, + wx.ID_COPY, + wx.ID_PASTE, + wx.ID_PASTE, + wx.ID_UNDO, + wx.ID_REDO, + ] + assert items[4] is None # separator before Undo/Redo + + +def test_build_context_menu_disables_cut_and_paste_when_read_only(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02", read_only=True) + + items = { + item.wx_id: item + for item in editor_grid(editor).build_context_menu() + if item is not None + } + + assert items[wx.ID_CUT].enabled is False + assert items[wx.ID_PASTE].enabled is False + assert items[wx.ID_COPY].enabled is True # copy is always allowed + + +def test_build_context_menu_undo_redo_reflect_command_processor_state( + wx_harness, +) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03\x04") + grid = editor_grid(editor) + items_by_id = { + item.wx_id: item + for item in grid.build_context_menu() + if item is not None + } + assert items_by_id[wx.ID_UNDO].enabled is False + assert items_by_id[wx.ID_REDO].enabled is False + + grid.select_range(0, 0) + remove_selection(grid) + + items_by_id = { + item.wx_id: item + for item in grid.build_context_menu() + if item is not None + } + assert items_by_id[wx.ID_UNDO].enabled is True + assert items_by_id[wx.ID_REDO].enabled is False + + editor_binary_data(editor).command_processor.Undo() + + items_by_id = { + item.wx_id: item + for item in grid.build_context_menu() + if item is not None + } + assert items_by_id[wx.ID_UNDO].enabled is False + assert items_by_id[wx.ID_REDO].enabled is True + + +def test_right_click_outside_selection_moves_cursor_to_clicked_cell( + wx_harness, mocker +) -> None: + editor = WxHexEditor(wx_harness.frame, binary=bytes(20)) + grid = editor_grid(editor) + mocker.patch.object(grid, "PopupMenu") + grid.SetGridCursor(0, 0) + grid.select_range(0, 15) # selects all of row 0 + + row, col = editor_table(editor).get_byte_rowcol(16) # row 1, outside the selection + event = mocker.Mock( + GetRow=mocker.Mock(return_value=row), + GetCol=mocker.Mock(return_value=col), + GetPosition=mocker.Mock(return_value=wx.Point(0, 0)), + ) + + trigger_cell_right_click(grid, event) + + assert grid.GetGridCursorCoords() == (row, col) + + +def test_right_click_inside_selection_keeps_cursor_unchanged( + wx_harness, mocker +) -> None: + editor = WxHexEditor(wx_harness.frame, binary=bytes(20)) + grid = editor_grid(editor) + mocker.patch.object(grid, "PopupMenu") + grid.SetGridCursor(0, 0) + grid.select_range(0, 15) # selects all of row 0 + + row, col = editor_table(editor).get_byte_rowcol(5) # inside the selection + event = mocker.Mock( + GetRow=mocker.Mock(return_value=row), + GetCol=mocker.Mock(return_value=col), + GetPosition=mocker.Mock(return_value=wx.Point(0, 0)), + ) + + trigger_cell_right_click(grid, event) + + assert grid.GetGridCursorCoords() == (0, 0) + + +def test_right_click_shows_popup_menu(wx_harness, mocker) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02") + grid = editor_grid(editor) + popup_menu = mocker.patch.object(grid, "PopupMenu") + event = mocker.Mock( + GetRow=mocker.Mock(return_value=0), + GetCol=mocker.Mock(return_value=0), + GetPosition=mocker.Mock(return_value=wx.Point(3, 4)), + ) + + trigger_cell_right_click(grid, event) + + popup_menu.assert_called_once() + assert popup_menu.call_args.args[1] == wx.Point(3, 4) diff --git a/tests/wx_integration/test_wx_hex_editor_format_and_readonly.py b/tests/wx_integration/test_wx_hex_editor_format_and_readonly.py new file mode 100644 index 0000000..5ee5cb2 --- /dev/null +++ b/tests/wx_integration/test_wx_hex_editor_format_and_readonly.py @@ -0,0 +1,90 @@ +"""Tests for WxHexEditor's `format` setter, `scroll_to_idx`, `colorise`, the +`binary` setter, and `read_only` mode. + +`colorise` already has a dedicated case in test_wx_hex_editor.py +(`test_colorise_highlights_range_via_table_attr`) — not duplicated here. +""" + +import dataclasses + +from construct_editor.wx_widgets.wx_hex_editor import HexEditorFormat, WxHexEditor +from tests.wx_integration.wx_test_helpers import ( + editor_binary_data, + editor_grid, + editor_table, + insert_byte_at_selection, + remove_selection, +) + + +def test_format_setter_updates_grid_column_count(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=bytes(32)) + grid = editor_grid(editor) + assert grid.GetNumberCols() == 16 # default width + + editor.format = dataclasses.replace(editor.format, width=8) + + assert grid.GetNumberCols() == 8 + # 32 bytes / 8 per row = 4 rows exactly, plus one trailing row (see + # HexEditorTable.refresh_rows_cols: an extra row is added whenever the + # binary exactly fills the last row). + assert grid.GetNumberRows() == 5 + + +def test_format_getter_returns_current_format(wx_harness) -> None: + fmt = HexEditorFormat(width=4) + editor = WxHexEditor(wx_harness.frame, binary=bytes(8), format=fmt) + + assert editor.format is fmt + + +def test_scroll_to_idx_makes_target_cell_visible(wx_harness, mocker) -> None: + editor = WxHexEditor(wx_harness.frame, binary=bytes(64)) + make_cell_visible = mocker.patch.object(editor_grid(editor), "MakeCellVisible") + + editor.scroll_to_idx(20) + + row, col = editor_table(editor).get_byte_rowcol(20) + make_cell_visible.assert_called_once_with(row, col) + + +def test_binary_setter_replaces_data_and_clears_selection_and_undo_history( + wx_harness, +) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02") + grid = editor_grid(editor) + grid.select_range(0, 1) + remove_selection(grid) # put a command on the undo stack + assert editor_binary_data(editor).command_processor.CanUndo() is True + + editor.binary = b"\xaa\xbb\xcc" + + assert editor.binary == b"\xaa\xbb\xcc" + assert editor_binary_data(editor).command_processor.CanUndo() is False + assert editor_table(editor).selections == [(0, 0)] + + +def test_read_only_grid_disables_editing(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02", read_only=True) + + grid = editor_grid(editor) + assert grid.read_only is True + assert grid.IsEditable() is False + + +def test_read_only_grid_rejects_mutating_operations(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03", read_only=True) + grid = editor_grid(editor) + grid.select_range(0, 1) + + assert remove_selection(grid) is False + assert insert_byte_at_selection(grid) is False + assert editor.binary == b"\x01\x02\x03" + + +def test_not_read_only_grid_enables_editing(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02", read_only=False) + + grid = editor_grid(editor) + assert grid.read_only is False + assert grid.IsEditable() is True diff --git a/tests/wx_integration/test_wx_hex_editor_grid.py b/tests/wx_integration/test_wx_hex_editor_grid.py new file mode 100644 index 0000000..fa3fee1 --- /dev/null +++ b/tests/wx_integration/test_wx_hex_editor_grid.py @@ -0,0 +1,132 @@ +"""Tests for construct_editor.wx_widgets.wx_hex_editor.HexEditorGrid selection logic. + +HexEditorGrid needs a real wx parent window (its constructor both parents +the Grid.Grid and stores the parent as `self._editor`), so these tests build +a real WxHexEditor via `wx_harness` and call grid methods directly +(`grid.select_range(...)`, `trigger_range_selecting_keyboard(...)`). +No `wx.UIActionSimulator` is used here — that's reserved for tests that must +prove real keyboard/mouse event wiring (see test_wx_hex_editor.py), and for +`_on_range_selecting_keyboard` specifically: driving it via a raw +`SetGridCursor()` call (instead of real Shift+Arrow input) fires +`EVT_GRID_SELECT_CELL` as a side effect, which collapses the very range +selection under test — so that one case uses `wx.UIActionSimulator` instead. +""" + +import wx + +from construct_editor.wx_widgets.wx_hex_editor import WxHexEditor +from tests.wx_integration.wx_test_helpers import ( + editor_grid, + grid_cell_screen_point, + grid_selection, + trigger_range_selecting_keyboard, + trigger_select_cell, +) + + +def test_select_range_within_single_row_fires_selection_changed( + wx_harness, mocker +) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03\x04") + callback = mocker.Mock() + editor.on_selection_changed.append(callback) + + grid = editor_grid(editor) + grid.select_range(1, 2) + + callback.assert_called_once_with(1, 2) + assert grid_selection(grid) == (1, 2) + + +def test_select_range_swaps_indices_when_reversed(wx_harness, mocker) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03\x04") + callback = mocker.Mock() + editor.on_selection_changed.append(callback) + + grid = editor_grid(editor) + grid.select_range(2, 1) + + callback.assert_called_once_with(1, 2) + assert grid_selection(grid) == (1, 2) + + +def test_select_range_clamps_indices_to_binary_length(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02") + + grid = editor_grid(editor) + grid.select_range(0, 100) + + assert grid_selection(grid) == (0, 1) + + +def test_select_range_ignored_when_either_index_negative(wx_harness, mocker) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02") + callback = mocker.Mock() + editor.on_selection_changed.append(callback) + + grid = editor_grid(editor) + grid.select_range(-1, 1) + + callback.assert_not_called() + assert grid_selection(grid) == (None, None) + + +def test_select_range_across_multiple_rows_spans_first_middle_last(wx_harness) -> None: + editor = WxHexEditor( + wx_harness.frame, binary=bytes(48), format=None + ) + editor.format = editor.format.__class__(width=16) + + grid = editor_grid(editor) + grid.select_range(0, 33) # rows 0..2 at width 16 + + assert grid_selection(grid) == (0, 33) + assert grid.IsInSelection(1, 5) # a cell in the "body" row + + +def test_on_range_selecting_keyboard_is_noop_when_nothing_selected( + wx_harness, mocker +) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03\x04") + callback = mocker.Mock() + editor.on_selection_changed.append(callback) + + grid = editor_grid(editor) + trigger_range_selecting_keyboard(grid, col_diff=1) + + callback.assert_not_called() + assert grid_selection(grid) == (None, None) + + +def test_on_range_selecting_keyboard_extends_selection_via_simulated_shift_arrow( + wx_harness, mocker +) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03\x04") + wx_harness.frame.Layout() + callback = mocker.Mock() + editor.on_selection_changed.append(callback) + + grid = editor_grid(editor) + point = grid_cell_screen_point(grid, 0, 1) # click byte idx 1 + wx_harness.move_mouse_to(point) + wx_harness.click() + wx_harness.key_press(wx.WXK_RIGHT, wx.MOD_SHIFT) # extend to byte idx 2 + + callback.assert_called_with(1, 2) + assert grid_selection(grid) == (1, 2) + + +def test_on_select_cell_replaces_range_selection_with_single_cell( + wx_harness, mocker +) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03\x04") + grid = editor_grid(editor) + grid.select_range(0, 2) + callback = mocker.Mock() + editor.on_selection_changed.append(callback) + + event = mocker.Mock(GetRow=mocker.Mock(return_value=0), GetCol=mocker.Mock(return_value=3)) + trigger_select_cell(grid, event) + + callback.assert_called_once_with(3, None) + assert grid_selection(grid) == (3, None) diff --git a/tests/wx_integration/test_wx_hex_editor_key_dispatch.py b/tests/wx_integration/test_wx_hex_editor_key_dispatch.py new file mode 100644 index 0000000..b7f82e4 --- /dev/null +++ b/tests/wx_integration/test_wx_hex_editor_key_dispatch.py @@ -0,0 +1,147 @@ +"""Tests for HexEditorGrid._on_key_down's key dispatch table. + +Unlike test_wx_hex_editor_grid.py/test_wx_hex_editor_clipboard.py (which +mostly call the private handler methods directly), these tests drive the +actual keyboard shortcuts through `wx.UIActionSimulator`, so they also prove +`_on_key_down`'s key/modifier matching itself (not just the methods it +delegates to). A cell is clicked first to give the grid real OS focus, since +UIActionSimulator posts real OS-level input. + +Preconditions for each shortcut (e.g. an existing selection, or an undoable +command already on the command_processor stack) are set up via direct calls +to grid methods rather than simulated input, since that setup isn't what's +under test here. +""" + +import wx + +from construct_editor.wx_widgets import wx_clipboard +from construct_editor.wx_widgets.wx_hex_editor import WxHexEditor +from tests.wx_integration.wx_test_helpers import ( + editor_binary_data, + editor_grid, + grid_cell_screen_point, + grid_selection, + remove_selection, +) + + +def _focus_grid(wx_harness, grid) -> None: + wx_harness.frame.Layout() + # Column 0 is a narrow gutter-ish column that doesn't reliably receive + # real OS clicks; column 1 is the pattern already proven to work in + # test_wx_hex_editor_grid.py's shift+arrow test. + point = grid_cell_screen_point(grid, 0, 1) + wx_harness.move_mouse_to(point) + wx_harness.click() + + +def test_delete_key_removes_selection(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03\x04") + grid = editor_grid(editor) + _focus_grid(wx_harness, grid) + grid.select_range(1, 2) + + wx_harness.key_press(wx.WXK_DELETE) + + assert editor.binary == b"\x01\x04" + + +def test_insert_key_inserts_byte_at_selection(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02") + grid = editor_grid(editor) + _focus_grid(wx_harness, grid) + grid.select_range(1, 1) + + wx_harness.key_press(wx.WXK_INSERT) + + assert editor.binary == b"\x01\x00\x02" + + +def test_ctrl_z_undoes_last_command(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03\x04") + grid = editor_grid(editor) + _focus_grid(wx_harness, grid) + grid.select_range(1, 2) + remove_selection(grid) + assert editor.binary == b"\x01\x04" + + wx_harness.key_press(ord("Z"), wx.MOD_CONTROL) + + assert editor.binary == b"\x01\x02\x03\x04" + + +def test_ctrl_y_redoes_last_undone_command(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03\x04") + grid = editor_grid(editor) + _focus_grid(wx_harness, grid) + grid.select_range(1, 2) + remove_selection(grid) + editor_binary_data(editor).command_processor.Undo() + assert editor.binary == b"\x01\x02\x03\x04" + + wx_harness.key_press(ord("Y"), wx.MOD_CONTROL) + + assert editor.binary == b"\x01\x04" + + +def test_ctrl_x_cuts_selection(wx_harness, mocker) -> None: + set_text = mocker.patch.object(wx_clipboard, "set_text", return_value=True) + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03") + grid = editor_grid(editor) + _focus_grid(wx_harness, grid) + grid.select_range(1, 2) + + wx_harness.key_press(ord("X"), wx.MOD_CONTROL) + + set_text.assert_called_once_with("02 03") + assert editor.binary == b"\x01" + + +def test_ctrl_c_copies_selection(wx_harness, mocker) -> None: + set_text = mocker.patch.object(wx_clipboard, "set_text", return_value=True) + editor = WxHexEditor(wx_harness.frame, binary=b"\xab\xcd\xef") + grid = editor_grid(editor) + _focus_grid(wx_harness, grid) + grid.select_range(1, 2) + + wx_harness.key_press(ord("C"), wx.MOD_CONTROL) + + set_text.assert_called_once_with("cd ef") + assert editor.binary == b"\xab\xcd\xef" + + +def test_ctrl_v_pastes_overwrite(wx_harness, mocker) -> None: + mocker.patch.object(wx_clipboard, "get_text", return_value="aa bb") + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03\x04") + grid = editor_grid(editor) + _focus_grid(wx_harness, grid) + grid.select_range(1, 1) + + wx_harness.key_press(ord("V"), wx.MOD_CONTROL) + + assert editor.binary == b"\x01\xaa\xbb\x04" + + +def test_ctrl_shift_v_pastes_insert(wx_harness, mocker) -> None: + mocker.patch.object(wx_clipboard, "get_text", return_value="aa bb") + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02") + grid = editor_grid(editor) + _focus_grid(wx_harness, grid) + grid.select_range(1, 1) + + wx_harness.key_press(ord("V"), wx.MOD_CONTROL | wx.MOD_SHIFT) + + assert editor.binary == b"\x01\xaa\xbb\x02" + + +def test_ctrl_a_selects_entire_binary(wx_harness, mocker) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"\x01\x02\x03\x04") + grid = editor_grid(editor) + _focus_grid(wx_harness, grid) + callback = mocker.Mock() + editor.on_selection_changed.append(callback) + + wx_harness.key_press(ord("A"), wx.MOD_CONTROL) + + assert grid_selection(grid) == (0, 3) diff --git a/tests/wx_integration/test_wx_hex_editor_string_to_byts.py b/tests/wx_integration/test_wx_hex_editor_string_to_byts.py new file mode 100644 index 0000000..c0b2c2d --- /dev/null +++ b/tests/wx_integration/test_wx_hex_editor_string_to_byts.py @@ -0,0 +1,97 @@ +"""Tests for HexEditorGrid.string_to_byts's paste-string parsing, covering +each of the accepted formats documented in its docstring. + +Uses pytest.mark.parametrize since there are many equivalent-but-differently +-formatted input strings that should all parse to the same bytes — a single +parametrized test communicates "these are all valid spellings of the same +data" far better than a dozen near-identical individually named test +functions would. Formats that parse to genuinely different expected bytes +(the "0x.." list and the `b'...'` literal) get their own small test each. +""" + +import pytest + +from construct_editor.wx_widgets.wx_hex_editor import WxHexEditor +from tests.wx_integration.wx_test_helpers import editor_grid + +_EXPECTED_1_TO_15 = bytes(range(1, 16)) + + +@pytest.mark.parametrize( + "byts_str", + [ + "0102030405060708090a0b0c0d0e0f", + "01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f", + "01.02.03.04.05.06.07.08.09.0a.0b.0c.0d.0e.0f", + "01-02-03-04-05-06-07-08-09-0a-0b-0c-0d-0e-0f", + "1,2,3,4,5,6,7,8,9,a,b,c,d,e,f", + "1 2 3 4 5 6 7 8 9 a b c d e f", + "'0102030405060708090a0b0c0d0e0f'", + ], + ids=[ + "packed-hex", + "space-separated-pairs", + "dot-separated-pairs", + "dash-separated-pairs", + "comma-separated-nibbles", + "space-separated-nibbles", + "single-quoted-packed-hex", + ], +) +def test_string_to_byts_parses_equivalent_formats_to_same_bytes( + byts_str, wx_harness +) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"") + + assert editor_grid(editor).string_to_byts(byts_str) == _EXPECTED_1_TO_15 + + +@pytest.mark.parametrize( + "byts_str", + [ + '"71 20 98 00 c0 7a 3e 6a 8d 7c c4 0d 04 10 02 f2 00"', + '("71 20 98 00 c0 7a 3e 6a 8d 7c c4 0d 04 10 02 f2 00")', + 'bytes.fromhex("71 20 98 00 c0 7a 3e 6a 8d 7c c4 0d 04 10 02 f2 00")', + ], + ids=["quoted", "parenthesized-quoted", "bytes-fromhex-call"], +) +def test_string_to_byts_strips_surrounding_quotes_and_code( + byts_str, wx_harness +) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"") + + result = editor_grid(editor).string_to_byts(byts_str) + + assert result == bytes.fromhex( + "71 20 98 00 c0 7a 3e 6a 8d 7c c4 0d 04 10 02 f2 00" + ) + + +def test_string_to_byts_parses_0x_prefixed_comma_separated_list(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"") + + result = editor_grid(editor).string_to_byts("0x12, 0x23, 0x45,") + + assert result == b"\x12\x23\x45" + + +def test_string_to_byts_parses_python_bytes_repr_literal(wx_harness) -> None: + editor = WxHexEditor(wx_harness.frame, binary=b"") + + result = editor_grid(editor).string_to_byts( + r"b'\x00\x00\x00\xa4\xc18\xe1\x81_\x00\xcc#b\x0c\r\x15'" + ) + + assert result == b"\x00\x00\x00\xa4\xc18\xe1\x81_\x00\xcc#b\x0c\r\x15" + + +def test_string_to_byts_returns_none_and_shows_message_box_when_unparseable( + wx_harness, mocker +) -> None: + message_box = mocker.patch("wx.MessageBox") + editor = WxHexEditor(wx_harness.frame, binary=b"") + + result = editor_grid(editor).string_to_byts("\\x") + + assert result is None + message_box.assert_called_once() diff --git a/tests/wx_integration/test_wx_hex_editor_table.py b/tests/wx_integration/test_wx_hex_editor_table.py new file mode 100644 index 0000000..a223f89 --- /dev/null +++ b/tests/wx_integration/test_wx_hex_editor_table.py @@ -0,0 +1,132 @@ +"""Tests for construct_editor.wx_widgets.wx_hex_editor.HexEditorTable. + +HexEditorTable is exercised in isolation here — it only needs an object +exposing `.format` (the real WxHexEditor also has one), not a full widget +tree — so these tests don't need `wx_harness`/a visible frame, just `wx_app_and_ui_sim` +for wx.GridCellAttr/wx.Font construction. +""" + +import wx + +from construct_editor.wx_widgets.wx_hex_editor import ( + HexEditorBinaryData, + HexEditorFormat, + HexEditorTable, +) + + +class _FakeEditor: + """Minimal stand-in for WxHexEditor: HexEditorTable only reads `.format`.""" + + def __init__(self, format: HexEditorFormat | None = None) -> None: + self.format = format or HexEditorFormat() + + +def _make_table(binary: bytes, format: HexEditorFormat | None = None) -> HexEditorTable: + binary_data = HexEditorBinaryData(binary) + table = HexEditorTable(_FakeEditor(format), binary_data) + table.refresh_rows_cols() + return table + + +def test_get_value_formats_byte_as_two_digit_lowercase_hex(wx_app_and_ui_sim) -> None: + table = _make_table(b"\xab") + + assert table.GetValue(0, 0) == "ab" + + +def test_get_value_beyond_binary_length_is_empty_string(wx_app_and_ui_sim) -> None: + table = _make_table(b"\x01") + + assert table.GetValue(0, 1) == "" + + +def test_set_value_with_valid_hex_updates_binary(wx_app_and_ui_sim) -> None: + binary_data = HexEditorBinaryData(b"\x00") + table = HexEditorTable(_FakeEditor(), binary_data) + table.refresh_rows_cols() + + table.SetValue(0, 0, "ff") + + assert binary_data.get_bytes() == b"\xff" + + +def test_set_value_with_invalid_hex_is_silently_ignored(wx_app_and_ui_sim) -> None: + binary_data = HexEditorBinaryData(b"\x00") + table = HexEditorTable(_FakeEditor(), binary_data) + table.refresh_rows_cols() + + table.SetValue(0, 0, "zz") + + assert binary_data.get_bytes() == b"\x00" + + +def test_set_value_empty_string_beyond_binary_length_is_noop(wx_app_and_ui_sim) -> None: + binary_data = HexEditorBinaryData(b"\x00") + table = HexEditorTable(_FakeEditor(), binary_data) + table.refresh_rows_cols() + + table.SetValue(0, 1, "") + + assert binary_data.get_bytes() == b"\x00" + + +def test_is_empty_cell_reflects_binary_length(wx_app_and_ui_sim) -> None: + table = _make_table(b"\x01\x02") + + assert table.IsEmptyCell(0, 0) is False + assert table.IsEmptyCell(0, 1) is False + assert table.IsEmptyCell(0, 2) is True + + +def test_get_attr_uses_selected_background_within_selections(wx_app_and_ui_sim) -> None: + table = _make_table(b"\x01\x02\x03") + table.selections = [(1, 3)] + + attr = table.GetAttr(0, 1, 0) + + assert attr.GetBackgroundColour() == wx.Colour(200, 200, 200) + + +def test_get_attr_uses_default_background_outside_selections(wx_app_and_ui_sim) -> None: + table = _make_table(b"\x01\x02\x03") + table.selections = [(1, 3)] + + attr = table.GetAttr(0, 0, 0) + + assert attr.GetBackgroundColour() == wx.WHITE + + +def test_get_next_cursor_rowcol_advances_by_one_byte(wx_app_and_ui_sim) -> None: + table = _make_table(b"\x01\x02\x03", HexEditorFormat(width=2)) + + assert table.get_next_cursor_rowcol(0, 0) == (0, 1) + + +def test_get_next_cursor_rowcol_stays_put_at_end_of_binary(wx_app_and_ui_sim) -> None: + table = _make_table(b"\x01\x02", HexEditorFormat(width=2)) + + # idx 2 == len(binary): one-past-the-end is allowed, but not further + assert table.get_next_cursor_rowcol(0, 1) == (1, 0) + assert table.get_next_cursor_rowcol(1, 0) == (1, 0) + + +def test_get_prev_cursor_rowcol_retreats_by_one_byte(wx_app_and_ui_sim) -> None: + table = _make_table(b"\x01\x02\x03", HexEditorFormat(width=2)) + + assert table.get_prev_cursor_rowcol(1, 0) == (0, 1) + + +def test_get_prev_cursor_rowcol_stays_put_at_start(wx_app_and_ui_sim) -> None: + table = _make_table(b"\x01\x02", HexEditorFormat(width=2)) + + assert table.get_prev_cursor_rowcol(0, 0) == (0, 0) + + +def test_refresh_rows_cols_adds_trailing_row_when_binary_exactly_fills_last_row( + wx_app_and_ui_sim, +) -> None: + table = _make_table(b"\x01\x02", HexEditorFormat(width=2)) + + assert table.GetNumberRows() == 2 # one full row + trailing empty row + assert table.GetNumberCols() == 2 diff --git a/tests/wx_integration/test_wx_hex_editor_text_ctrl.py b/tests/wx_integration/test_wx_hex_editor_text_ctrl.py new file mode 100644 index 0000000..e4ee42f --- /dev/null +++ b/tests/wx_integration/test_wx_hex_editor_text_ctrl.py @@ -0,0 +1,157 @@ +"""Tests for HexTextCtrl (the in-cell hex editing control) and +HexCellEditor's IsAcceptedKey/BeginEdit/EndEdit wiring. + +`mode="char"` is intentionally NOT covered here: `HexCellEditor.BeginEdit` +always hardcodes `mode = "hex"`, so the "char" branch in +`HexTextCtrl.set_mode`/`insert_first_key` is unreachable through any current +production code path. Treating it as dead code per explicit instruction +rather than writing tests for an unreachable branch. + +`HexTextCtrl` is built directly (not through a full `HexEditorGrid`/ +`HexCellEditor`), with a `mocker.Mock()` standing in for `parentgrid` — it +only needs `_advance_cursor`/`_abort_edit` to exist as callables. Key events +are constructed with `mocker.Mock()` rather than real KeyEvents/ +UIActionSimulator, since `on_key_down`'s branching is pure logic once given +a keycode/modifiers. +""" + +import wx +from pytest_mock import MockerFixture + +from construct_editor.wx_widgets.wx_hex_editor import HexCellEditor, HexTextCtrl + + +def _key_event(keycode, mocker: MockerFixture, control=False, alt=False, shift=False): + return mocker.Mock( + GetKeyCode=mocker.Mock(return_value=keycode), + ControlDown=mocker.Mock(return_value=control), + AltDown=mocker.Mock(return_value=alt), + ShiftDown=mocker.Mock(return_value=shift), + ) + + +def test_set_mode_hex_limits_length_and_autoadvance(wx_harness, mocker) -> None: + tc = HexTextCtrl(wx_harness.frame, wx.ID_ANY, mocker.Mock()) + + tc.set_mode("hex") + + assert tc.autoadvance == 2 + assert tc.userpressed is False + + +def test_editing_new_cell_sets_value_and_selects_text(wx_harness, mocker) -> None: + tc = HexTextCtrl(wx_harness.frame, wx.ID_ANY, mocker.Mock()) + + tc.editing_new_cell("ab", mode="hex") + + assert tc.GetValue() == "ab" + assert tc.startValue == "ab" + assert tc.mode == "hex" + + +def test_insert_first_key_accepts_valid_hex_digit(wx_harness, mocker) -> None: + tc = HexTextCtrl(wx_harness.frame, wx.ID_ANY, mocker.Mock()) + tc.set_mode("hex") + + result = tc.insert_first_key(ord("A")) + + assert result is True + assert tc.GetValue() == "A" + assert tc.userpressed is True + + +def test_insert_first_key_rejects_non_hex_digit(wx_harness, mocker) -> None: + tc = HexTextCtrl(wx_harness.frame, wx.ID_ANY, mocker.Mock()) + tc.set_mode("hex") + + result = tc.insert_first_key(ord("Z")) + + assert result is False + assert tc.GetValue() == "" + assert tc.userpressed is False + + +def test_on_key_down_backspace_resets_value_to_start_value(wx_harness, mocker) -> None: + tc = HexTextCtrl(wx_harness.frame, wx.ID_ANY, mocker.Mock()) + tc.editing_new_cell("ab", mode="hex") + tc.SetValue("a") + + tc.on_key_down(_key_event(wx.WXK_BACK, mocker=mocker)) + + assert tc.GetValue() == "" + + +def test_on_key_down_tab_schedules_advance_cursor(wx_harness, mocker) -> None: + parentgrid = mocker.Mock() + tc = HexTextCtrl(wx_harness.frame, wx.ID_ANY, parentgrid) + tc.editing_new_cell("ab", mode="hex") + + tc.on_key_down(_key_event(wx.WXK_TAB, mocker=mocker)) + wx.GetApp().Yield() + + parentgrid._advance_cursor.assert_called_once() + + +def test_on_key_down_escape_resets_value_and_schedules_abort_edit( + wx_harness, mocker +) -> None: + parentgrid = mocker.Mock() + tc = HexTextCtrl(wx_harness.frame, wx.ID_ANY, parentgrid) + tc.editing_new_cell("ab", mode="hex") + tc.SetValue("c") + + tc.on_key_down(_key_event(wx.WXK_ESCAPE, mocker=mocker)) + wx.GetApp().Yield() + + assert tc.GetValue() == "ab" + parentgrid._abort_edit.assert_called_once() + + +def test_on_key_down_valid_hex_digit_flags_userpressed_and_skips_event( + wx_harness, mocker +) -> None: + tc = HexTextCtrl(wx_harness.frame, wx.ID_ANY, mocker.Mock()) + tc.set_mode("hex") + event = _key_event(ord("B"), mocker=mocker) + + tc.on_key_down(event) + + assert tc.userpressed is True + event.Skip.assert_called_once() + + +def test_on_key_down_invalid_hex_digit_is_swallowed(wx_harness, mocker) -> None: + tc = HexTextCtrl(wx_harness.frame, wx.ID_ANY, mocker.Mock()) + tc.set_mode("hex") + event = _key_event(ord("Z"), mocker=mocker) + + tc.on_key_down(event) + + assert tc.userpressed is False + event.Skip.assert_not_called() + + +def test_on_text_advances_cursor_once_max_length_reached(wx_harness, mocker) -> None: + parentgrid = mocker.Mock() + tc = HexTextCtrl(wx_harness.frame, wx.ID_ANY, parentgrid) + tc.editing_new_cell("00", mode="hex") + + tc.insert_first_key(ord("A")) # 1 char, autoadvance=2 -> not yet + parentgrid._advance_cursor.assert_not_called() + + tc.SetValue("AB") # userpressed still True from insert_first_key + tc.SetInsertionPointEnd() + tc.on_text(mocker.Mock(GetString=mocker.Mock(return_value="AB"))) + wx.GetApp().Yield() + + parentgrid._advance_cursor.assert_called_once() + + +def test_is_accepted_key_rejects_control_and_alt_modifiers(wx_harness, mocker) -> None: + grid = mocker.Mock() + editor = HexCellEditor(grid) + + assert editor.IsAcceptedKey(_key_event(ord("A"), control=True, mocker=mocker)) is False + assert editor.IsAcceptedKey(_key_event(ord("A"), alt=True, mocker=mocker)) is False + assert editor.IsAcceptedKey(_key_event(wx.WXK_SHIFT, mocker=mocker)) is False + assert editor.IsAcceptedKey(_key_event(ord("A"), mocker=mocker)) is True diff --git a/tests/wx_integration/test_wx_obj_editor_default.py b/tests/wx_integration/test_wx_obj_editor_default.py new file mode 100644 index 0000000..44e8c8d --- /dev/null +++ b/tests/wx_integration/test_wx_obj_editor_default.py @@ -0,0 +1,10 @@ +"""Tests for construct_editor.wx_widgets.wx_obj_view.WxObjEditor_Default (read-only fallback).""" + + + +def test_placeholder(wx_app_and_ui_sim) -> None: + pass + + +# TODO: Tests to add: +# - get_new_obj() returns the original value unchanged (read-only) diff --git a/tests/wx_integration/test_wx_obj_editor_enum.py b/tests/wx_integration/test_wx_obj_editor_enum.py new file mode 100644 index 0000000..1911c99 --- /dev/null +++ b/tests/wx_integration/test_wx_obj_editor_enum.py @@ -0,0 +1,11 @@ +"""Tests for construct_editor.wx_widgets.wx_obj_view.WxObjEditor_Enum.""" + + + +def test_placeholder(wx_app_and_ui_sim) -> None: + pass + + +# TODO: Tests to add: +# - ComboBox is populated with enum label strings +# - Selecting an entry updates get_new_obj() diff --git a/tests/wx_integration/test_wx_obj_editor_flags_enum.py b/tests/wx_integration/test_wx_obj_editor_flags_enum.py new file mode 100644 index 0000000..4c9163e --- /dev/null +++ b/tests/wx_integration/test_wx_obj_editor_flags_enum.py @@ -0,0 +1,11 @@ +"""Tests for construct_editor.wx_widgets.wx_obj_view.WxObjEditor_FlagsEnum (ComboCtrl with popup checklist).""" + + + +def test_placeholder(wx_app_and_ui_sim) -> None: + pass + + +# TODO: Tests to add: +# - Popup lists all flag names +# - Checked flags are reflected in get_new_obj() diff --git a/tests/wx_integration/test_wx_obj_editor_integer.py b/tests/wx_integration/test_wx_obj_editor_integer.py new file mode 100644 index 0000000..26f7a95 --- /dev/null +++ b/tests/wx_integration/test_wx_obj_editor_integer.py @@ -0,0 +1,12 @@ +"""Tests for construct_editor.wx_widgets.wx_obj_view.WxObjEditor_Integer.""" + + + +def test_placeholder(wx_app_and_ui_sim) -> None: + pass + + +# TODO: Tests to add: +# - Accepts valid integer strings in Dec and Hex format +# - get_new_obj() returns the parsed integer +# - Invalid input does not raise unhandled exceptions diff --git a/tests/wx_integration/test_wx_obj_editor_string.py b/tests/wx_integration/test_wx_obj_editor_string.py new file mode 100644 index 0000000..90d55e3 --- /dev/null +++ b/tests/wx_integration/test_wx_obj_editor_string.py @@ -0,0 +1,11 @@ +"""Tests for construct_editor.wx_widgets.wx_obj_view.WxObjEditor_String.""" + + + +def test_placeholder(wx_app_and_ui_sim) -> None: + pass + + +# TODO: Tests to add: +# - Displays the initial string value +# - get_new_obj() returns the edited string diff --git a/tests/wx_integration/test_wx_obj_view_factory.py b/tests/wx_integration/test_wx_obj_view_factory.py new file mode 100644 index 0000000..0c1d6ad --- /dev/null +++ b/tests/wx_integration/test_wx_obj_view_factory.py @@ -0,0 +1,16 @@ +"""Tests for construct_editor.wx_widgets.wx_obj_view.create_obj_editor() factory.""" + + + +def test_placeholder(wx_app_and_ui_sim) -> None: + pass + + +# TODO: Tests to add: +# - ObjViewSettings_Default -> WxObjEditor_Default +# - ObjViewSettings_String -> WxObjEditor_String +# - ObjViewSettings_Integer -> WxObjEditor_Integer +# - ObjViewSettings_Bytes -> WxObjEditor_Bytes +# - ObjViewSettings_Enum -> WxObjEditor_Enum +# - ObjViewSettings_FlagsEnum -> WxObjEditor_FlagsEnum +# - ObjViewSettings_Timestamp -> WxObjEditor_Timestamp diff --git a/tests/wx_integration/test_wx_python_code_editor.py b/tests/wx_integration/test_wx_python_code_editor.py new file mode 100644 index 0000000..34ffd59 --- /dev/null +++ b/tests/wx_integration/test_wx_python_code_editor.py @@ -0,0 +1,17 @@ +"""Tests for construct_editor.wx_widgets.wx_python_code_editor. + +Covers the WxPythonCodeEditor widget. +""" + + + +def test_placeholder(wx_app_and_ui_sim) -> None: + pass + + +# TODO: Tests to add: +# - Widget can be instantiated with a parent frame without exceptions +# - Setting code text via the widget API stores it correctly +# - Getting code text returns the previously set value +# - Empty content does not raise on read + diff --git a/tests/wx_integration/wx_test_helpers.py b/tests/wx_integration/wx_test_helpers.py new file mode 100644 index 0000000..fb49682 --- /dev/null +++ b/tests/wx_integration/wx_test_helpers.py @@ -0,0 +1,235 @@ +"""Plain (non-fixture) helper functions for wx integration tests. + +These have no fixture dependencies of their own, so they are simple functions +rather than pytest fixtures — callers just import and call them directly. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import typing as t + +import wx +import wx.grid as Grid + +from construct_editor.wx_widgets.wx_hex_editor import ( + HexEditorBinaryData, + HexEditorGrid, + HexEditorTable, + WxHexEditor, +) + + +@dataclasses.dataclass +class WxAppAndUiSim: + """Bundles the session-scoped wx.App with the shared + wx.UIActionSimulator, so tests that need either (or both) can depend on + a single fixture.""" + + app: wx.App + ui_simulator: wx.UIActionSimulator + + +@dataclasses.dataclass +class WxTestHarness: + """Bundles the running wx.App with a widget's parent wx.Frame, and a + wx.UIActionSimulator, for a test. + + Also provides `move_mouse_to`, `click`, `key_press` and `type_text` for + driving simulated OS-level input via a real wx.MainLoop. + """ + + app: wx.App + frame: wx.Frame + ui_simulator: wx.UIActionSimulator + + @classmethod + @contextlib.contextmanager + def create( + cls, app: wx.App, ui_simulator: wx.UIActionSimulator + ) -> t.Generator[WxTestHarness, None, None]: + """Create a fresh top-level frame for a test, wrap it with `app` and + `ui_simulator`, and tear it down again once the `with` block exits. + + The frame is shown, raised, and focused (instead of hidden) so that + wx.UIActionSimulator — which drives real OS-level mouse/keyboard + input — can reliably target it. + """ + frame = wx.Frame(None) + frame.Show(True) + frame.Raise() + frame.SetFocus() + app.Yield() + try: + yield cls(app=app, frame=frame, ui_simulator=ui_simulator) + finally: + frame.Destroy() + app.ProcessPendingEvents() + + def move_mouse_to(self, point: wx.Point, delay_ms: int = 150) -> None: + """Move the simulated mouse cursor to an absolute screen point.""" + sim = self.ui_simulator + self._run_steps([lambda: sim.MouseMove(point.x, point.y)], delay_ms) + + def click(self, delay_ms: int = 150) -> None: + """Simulate a left mouse click at the current cursor position.""" + self._run_steps([self.ui_simulator.MouseClick], delay_ms) + + def key_press( + self, + keycode: int, + modifiers: int = wx.MOD_NONE, + delay_ms: int = 150, + ) -> None: + """Simulate a single key press (down + up) of `keycode`, optionally + with modifier keys (e.g. wx.MOD_SHIFT) held down.""" + sim = self.ui_simulator + self._run_steps([lambda: sim.Char(keycode, modifiers)], delay_ms) + + def type_text(self, text: str, delay_ms: int = 150) -> None: + """Simulate typing `text` one character at a time, in a single + wx.MainLoop run. + + Uppercase letters are typed with the Shift modifier held down; + everything else (digits, lowercase letters) is typed plain. + """ + sim = self.ui_simulator + steps: list[t.Callable[[], t.Any]] = [ + ( + lambda ch=ch: sim.Char( + ord(ch.upper()), wx.MOD_SHIFT if ch.isupper() else wx.MOD_NONE + ) + ) + for ch in text + ] + self._run_steps(steps, delay_ms) + + def _run_steps( + self, + steps: t.Sequence[t.Callable[[], t.Any]], + delay_ms: int, + ) -> None: + """Run a sequence of zero-arg callables inside a real wx.MainLoop, one + after another, spaced apart in time via wx.CallLater. + + wx.UIActionSimulator posts real OS-level input events (SendInput on + Windows). These are only reliably dispatched to widgets while an + actual MainLoop is running — manually pumping with + wx.Yield()/ProcessPendingEvents is not sufficient (verified: + cell-edit-start events were silently dropped under manual + Yield-polling, but processed correctly under a real MainLoop). + + Each step typically does one simulated input action (e.g. a mouse + click or a keystroke); the delay between steps gives wx time to fully + process the resulting events (focus changes, cell editor creation, + ...) before the next step runs. + """ + app = self.app + pending = list(steps) + + def _run_next_step() -> None: + if pending: + step = pending.pop(0) + step() + wx.CallLater(delay_ms, _run_next_step) + else: + app.ExitMainLoop() + + wx.CallLater(delay_ms, _run_next_step) + app.MainLoop() + + +def grid_cell_screen_point(grid: Grid.Grid, row: int, col: int) -> wx.Point: + """Convert a wx.grid.Grid cell to an absolute screen point. + + Used to target wx.UIActionSimulator mouse actions at a specific cell. + """ + rect = grid.CellToRect(row, col) + position = rect.GetPosition() + unscrolled_center = wx.Point( + position.x + rect.GetWidth() // 2, + position.y + rect.GetHeight() // 2, + ) + client_point = grid.CalcScrolledPosition(unscrolled_center) + return grid.GetGridWindow().ClientToScreen(client_point) + + +# --------------------------------------------------------------------------- +# Private-member accessors. +# +# Tests intentionally reach into WxHexEditor/HexEditorGrid internals that +# aren't part of the public API, to exercise/assert on internal behavior. +# Rather than sprinkling a `# pyright: ignore[reportPrivateUsage]` (or +# disabling the rule project-wide), every such access is funneled through one +# of these small typed wrapper functions, each carrying exactly one ignore +# comment at its single point of definition. Call sites just call the +# wrapper, so they stay fully type-checked otherwise. +# --------------------------------------------------------------------------- + + +def editor_grid(editor: WxHexEditor) -> HexEditorGrid: + """Access WxHexEditor's private `_grid`.""" + return editor._grid # pyright: ignore[reportPrivateUsage] + + +def editor_table(editor: WxHexEditor) -> HexEditorTable: + """Access WxHexEditor's private `_table`.""" + return editor._table # pyright: ignore[reportPrivateUsage] + + +def editor_binary_data(editor: WxHexEditor) -> HexEditorBinaryData: + """Access WxHexEditor's private `_binary_data`.""" + return editor._binary_data # pyright: ignore[reportPrivateUsage] + + +def grid_selection(grid: HexEditorGrid) -> t.Tuple[int | None, int | None]: + """Access HexEditorGrid's private `_selection`.""" + return grid._selection # pyright: ignore[reportPrivateUsage] + + +def copy_selection(grid: HexEditorGrid) -> bool: + """Call HexEditorGrid's private `_copy_selection()`.""" + return grid._copy_selection() # pyright: ignore[reportPrivateUsage] + + +def cut_selection(grid: HexEditorGrid) -> bool: + """Call HexEditorGrid's private `_cut_selection()`.""" + return grid._cut_selection() # pyright: ignore[reportPrivateUsage] + + +def remove_selection(grid: HexEditorGrid) -> bool: + """Call HexEditorGrid's private `_remove_selection()`.""" + return grid._remove_selection() # pyright: ignore[reportPrivateUsage] + + +def insert_byte_at_selection(grid: HexEditorGrid) -> bool: + """Call HexEditorGrid's private `_insert_byte_at_selection()`.""" + return grid._insert_byte_at_selection() # pyright: ignore[reportPrivateUsage] + + +def paste_at_selection( + grid: HexEditorGrid, overwrite: bool = False, insert: bool = False +) -> bool: + """Call HexEditorGrid's private `_paste(...)`.""" + return grid._paste(overwrite=overwrite, insert=insert) # pyright: ignore[reportPrivateUsage] + + +def trigger_cell_right_click(grid: HexEditorGrid, event: Grid.GridEvent) -> None: + """Call HexEditorGrid's private `_on_cell_right_click(event)`.""" + grid._on_cell_right_click(event) # pyright: ignore[reportPrivateUsage] + + +def trigger_select_cell(grid: HexEditorGrid, event: Grid.GridEvent) -> None: + """Call HexEditorGrid's private `_on_select_cell(event)`.""" + grid._on_select_cell(event) # pyright: ignore[reportPrivateUsage] + + +def trigger_range_selecting_keyboard( + grid: HexEditorGrid, row_diff: int = 0, col_diff: int = 0 +) -> None: + """Call HexEditorGrid's private `_on_range_selecting_keyboard(...)`.""" + grid._on_range_selecting_keyboard( # pyright: ignore[reportPrivateUsage] + row_diff=row_diff, col_diff=col_diff + ) + diff --git a/uv.lock b/uv.lock index 159a9f9..e86f903 100644 --- a/uv.lock +++ b/uv.lock @@ -181,6 +181,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, ] +[[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" @@ -214,6 +223,9 @@ dev = [ { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "poethepoet" }, { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, { name = "ruamel-yaml" }, { name = "ruff" }, { name = "ty" }, @@ -225,7 +237,7 @@ requires-dist = [ { name = "construct", specifier = ">=2.10.68" }, { name = "construct-typing", specifier = ">=0.8.1,<0.9.0" }, { name = "typing-extensions", specifier = ">=4.12.0" }, - { name = "wrapt", specifier = ">=1.14.0" }, + { name = "wrapt", specifier = ">=2.2.2" }, { name = "wxpython", specifier = ">=4.2.2" }, ] @@ -238,6 +250,9 @@ dev = [ { name = "numpy", specifier = ">=1.20.0" }, { name = "poethepoet", specifier = ">=0.48.0" }, { name = "pyright", specifier = ">=1.1.411" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "pytest-mock", specifier = ">=3.15.1" }, { name = "ruamel-yaml" }, { name = "ruff", specifier = ">=0.15.21" }, { name = "ty", specifier = ">=0.0.59" }, @@ -257,6 +272,109 @@ 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 = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/060ce69008ac97bbc01b1411b3e55b61f6f015659400b46749b662107831/coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d", size = 221284, upload-time = "2026-07-15T18:53:29.52Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a3/d936e8b53edd9684100a6aefaf3fcabaa54728fe33324436c8d279c047aa/coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846", size = 221799, upload-time = "2026-07-15T18:53:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a3/ca234b06aec7ee28226f11d39a696b4481fe5eddfce8e03bf39979bb8ffb/coverage-7.15.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf", size = 248544, upload-time = "2026-07-15T18:53:33.212Z" }, + { url = "https://files.pythonhosted.org/packages/2b/89/dda79527bb7573ba91828b2fb91b3105d87378d6a2749ca0c0924ce0addd/coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376", size = 250374, upload-time = "2026-07-15T18:53:34.683Z" }, + { url = "https://files.pythonhosted.org/packages/67/c6/c33755a34572f81f49a8c0cdf6b622f35ccb3238b136e1909daf0cdd4319/coverage-7.15.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd", size = 252239, upload-time = "2026-07-15T18:53:36.205Z" }, + { url = "https://files.pythonhosted.org/packages/b9/6f/dc341741b375be53a5baeee5b4bf0f0e525d38caed428f7932d23bb7bcb1/coverage-7.15.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb", size = 254150, upload-time = "2026-07-15T18:53:37.863Z" }, + { url = "https://files.pythonhosted.org/packages/e9/8d/966a18a5b195cb4e77b14c53f5f3dce22b5da05e6de7fafd1e08f2d2067a/coverage-7.15.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1", size = 249234, upload-time = "2026-07-15T18:53:39.394Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/8b2e367496ab48484d48e79984fec76cdc1b7cb5d3a00ee799a5602e3ec9/coverage-7.15.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9", size = 250276, upload-time = "2026-07-15T18:53:41.027Z" }, + { url = "https://files.pythonhosted.org/packages/63/92/1199318a200eb6c8c6ce0192c892c8710ac791abbe0f35099294620bbfda/coverage-7.15.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a", size = 248283, upload-time = "2026-07-15T18:53:42.557Z" }, + { url = "https://files.pythonhosted.org/packages/56/da/be284a55c5619bda891a89c27dfd59324a2c6a14d755cf6aac6960ceebeb/coverage-7.15.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287", size = 252093, upload-time = "2026-07-15T18:53:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/d4/53/ee112da833ddd77b73c6d781a98029b45b584b136615b4900ed0569f887e/coverage-7.15.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89", size = 248552, upload-time = "2026-07-15T18:53:45.7Z" }, + { url = "https://files.pythonhosted.org/packages/82/6a/802cfc802e9113494c80bf3f284cd4d72faeb1f24e244f61046af364f2ca/coverage-7.15.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88", size = 249154, upload-time = "2026-07-15T18:53:47.256Z" }, + { url = "https://files.pythonhosted.org/packages/2c/65/529808e91d651147edae408fd9e894abc3b8cad7f3e594bbc36719a3e13a/coverage-7.15.2-cp310-cp310-win32.whl", hash = "sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443", size = 223334, upload-time = "2026-07-15T18:53:48.768Z" }, + { url = "https://files.pythonhosted.org/packages/68/0f/0e1829d7001130876dfbc0b4e1c737ea7c155b809e3e4a98a0aa268e2369/coverage-7.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629", size = 223959, upload-time = "2026-07-15T18:53:50.429Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, + { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, + { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "cryptography" version = "49.0.0" @@ -314,6 +432,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[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 = "librt" version = "0.13.0" @@ -737,6 +876,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, ] +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + [[package]] name = "pastel" version = "0.2.1" @@ -755,6 +903,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[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 = "poethepoet" version = "0.48.0" @@ -778,6 +935,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + [[package]] name = "pyright" version = "1.1.411" @@ -791,6 +957,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +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 = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"