diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dfb8edc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + pull_request: + +jobs: + ci: + name: Tests on Python ${{ matrix.python-version }} + runs-on: windows-latest + + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync + + - name: Run type checks + run: uv run poe typecheck + + - name: Run tests + run: uv run pytest diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000..bb74b78 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,41 @@ +# Contributing + +Thank you for your interest in contributing to construct-editor! + +## Project Setup + +This project uses [uv](https://docs.astral.sh/uv/) for dependency management. Make sure you have `uv` installed before proceeding. + +Clone the repository and install all dependencies (including dev dependencies) with: + +```bash +git clone https://github.com/timrid/construct-editor.git +cd construct-editor +uv sync +``` + +This will create a virtual environment and install all required packages automatically. + +## Running the Example GUI + +To launch the example GUI, run: + +```bash +uv run construct-editor +``` + +## Running Tests + +To run the test suite with [pytest](https://docs.pytest.org/): + +```bash +uv run pytest +``` + +## Running Type Checks Locally + +Type checks are run via [poethepoet](https://github.com/nat-n/poethepoet) and include [ruff](https://docs.astral.sh/ruff/), [ty](https://github.com/astral-sh/ty), and [pyright](https://github.com/microsoft/pyright): + +```bash +uv run poe typecheck +``` diff --git a/construct_editor/core/entries.py b/construct_editor/core/entries.py index 895dd82..60c5ef2 100644 --- a/construct_editor/core/entries.py +++ b/construct_editor/core/entries.py @@ -9,12 +9,7 @@ import construct_typed as cst import construct_editor.core.model as model -from construct_editor.core.context_menu import ( - ButtonMenuItem, - CheckboxMenuItem, - ContextMenu, - SeparatorMenuItem, -) +import construct_editor.core.context_menu as context_menu from construct_editor.core.preprocessor import ( GuiMetaData, IncludeGuiMetaData, @@ -344,7 +339,7 @@ def obj_view_settings(self) -> ObjViewSettings: return ObjViewSettings_Default(self) # default "modify_context_menu" ########################################### - def modify_context_menu(self, menu: ContextMenu): + def modify_context_menu(self, menu: "context_menu.ContextMenu"): """This method is called, when the user right clicks an entry and a ContextMenu is created""" pass @@ -444,7 +439,7 @@ def obj_view_settings(self) -> ObjViewSettings: return self.subentry.obj_view_settings # pass throught "modify_context_menu" to subentry ######################### - def modify_context_menu(self, menu: ContextMenu): + def modify_context_menu(self, menu: "context_menu.ContextMenu"): return self.subentry.modify_context_menu(menu) @@ -486,16 +481,16 @@ def obj_str(self) -> str: def obj_view_settings(self) -> ObjViewSettings: return ObjViewSettings_Default(self) # TODO: create panel for cs.Struct - def modify_context_menu(self, menu: ContextMenu): + def modify_context_menu(self, menu: "context_menu.ContextMenu"): def on_expand_children_clicked(): menu.parent.expand_children(self) def on_collapse_children_clicked(): menu.parent.collapse_children(self) - menu.add_menu_item(SeparatorMenuItem()) + menu.add_menu_item(context_menu.SeparatorMenuItem()) menu.add_menu_item( - ButtonMenuItem( + context_menu.ButtonMenuItem( "Expand Children", None, True, @@ -503,7 +498,7 @@ def on_collapse_children_clicked(): ) ) menu.add_menu_item( - ButtonMenuItem( + context_menu.ButtonMenuItem( "Collapse Children", None, True, @@ -579,16 +574,16 @@ def obj_str(self) -> str: def obj_view_settings(self) -> ObjViewSettings: return ObjViewSettings_Default(self) # TODO: create panel for cs.Array - def modify_context_menu(self, menu: ContextMenu): + def modify_context_menu(self, menu: "context_menu.ContextMenu"): def on_expand_children_clicked(): menu.parent.expand_children(self) def on_collapse_children_clicked(): menu.parent.collapse_children(self) - menu.add_menu_item(SeparatorMenuItem()) + menu.add_menu_item(context_menu.SeparatorMenuItem()) menu.add_menu_item( - ButtonMenuItem( + context_menu.ButtonMenuItem( "Expand Children", None, True, @@ -596,7 +591,7 @@ def on_collapse_children_clicked(): ) ) menu.add_menu_item( - ButtonMenuItem( + context_menu.ButtonMenuItem( "Collapse Children", None, True, @@ -617,9 +612,9 @@ def on_menu_item_clicked(checked: bool): else: menu.parent.enable_list_view(self) - menu.add_menu_item(SeparatorMenuItem()) + menu.add_menu_item(context_menu.SeparatorMenuItem()) menu.add_menu_item( - CheckboxMenuItem( + context_menu.CheckboxMenuItem( "Enable List View", None, True, @@ -714,7 +709,7 @@ def obj_view_settings(self) -> ObjViewSettings: else: return subentry.obj_view_settings - def modify_context_menu(self, menu: ContextMenu): + def modify_context_menu(self, menu: "context_menu.ContextMenu"): subentry = self._get_subentry() if subentry is None: return @@ -810,7 +805,7 @@ def obj_view_settings(self) -> ObjViewSettings: else: return subentry.obj_view_settings - def modify_context_menu(self, menu: ContextMenu): + def modify_context_menu(self, menu: "context_menu.ContextMenu"): subentry = self._get_subentry() if subentry is None: return @@ -1062,14 +1057,14 @@ def typ_str(self) -> str: def obj_view_settings(self) -> ObjViewSettings: return ObjViewSettings_Bytes(self) - def modify_context_menu(self, menu: ContextMenu): + def modify_context_menu(self, menu: "context_menu.ContextMenu"): def on_ascii_view_clicked(checked: bool): self.ascii_view = not self.ascii_view menu.parent.reload() - menu.add_menu_item(SeparatorMenuItem()) + menu.add_menu_item(context_menu.SeparatorMenuItem()) menu.add_menu_item( - CheckboxMenuItem( + context_menu.CheckboxMenuItem( "ASCII View", None, True, @@ -1193,15 +1188,15 @@ def __init__( ): super().__init__(model, parent, construct, name, docs) - def modify_context_menu(self, menu: ContextMenu): + def modify_context_menu(self, menu: "context_menu.ContextMenu"): def on_default_clicked(): # TODO: This is not working correctly... self.obj = None menu.parent.reload() - menu.add_menu_item(SeparatorMenuItem()) + menu.add_menu_item(context_menu.SeparatorMenuItem()) menu.add_menu_item( - ButtonMenuItem( + context_menu.ButtonMenuItem( "Set to default", None, True, @@ -1294,7 +1289,7 @@ def obj_view_settings(self) -> ObjViewSettings: else: return subentry.obj_view_settings - def modify_context_menu(self, menu: ContextMenu): + def modify_context_menu(self, menu: "context_menu.ContextMenu"): subentry = self._get_subentry() if subentry is None: return @@ -1382,7 +1377,7 @@ def obj_view_settings(self) -> ObjViewSettings: else: return subentry.obj_view_settings - def modify_context_menu(self, menu: ContextMenu): + def modify_context_menu(self, menu: "context_menu.ContextMenu"): subentry = self._get_subentry() if subentry is None: return diff --git a/construct_editor/wx_widgets/wx_construct_editor.py b/construct_editor/wx_widgets/wx_construct_editor.py index 7f15db1..129bea1 100644 --- a/construct_editor/wx_widgets/wx_construct_editor.py +++ b/construct_editor/wx_widgets/wx_construct_editor.py @@ -84,7 +84,7 @@ def GetMode(self) -> int: # this always works.) dvc: "dv.DataViewCtrl" = self.GetView() - editor: "WxConstructEditor" = dvc.GetParent() + editor = t.cast("WxConstructEditor", dvc.GetParent()) selected_entry = editor.get_selected_entry() if selected_entry is None: mode = dv.DATAVIEW_CELL_INERT @@ -127,7 +127,7 @@ def CreateEditorCtrl( view_settings = value.obj_view_settings editor: WxObjEditor = create_obj_editor(parent, view_settings) editor.SetPosition(labelRect.Position) - editor.SetSize(labelRect.Size) + editor.SetSize(labelRect.Size) # type: ignore[call-overload] return editor def GetValueFromEditorCtrl(self, editor: WxObjEditor): diff --git a/construct_editor/wx_widgets/wx_hex_editor.py b/construct_editor/wx_widgets/wx_hex_editor.py index e7d2fec..e5469b8 100644 --- a/construct_editor/wx_widgets/wx_hex_editor.py +++ b/construct_editor/wx_widgets/wx_hex_editor.py @@ -164,7 +164,7 @@ def __init__(self, editor: "WxHexEditor", binary_data: HexEditorBinaryData): self._attr_default = Grid.GridCellAttr() self._attr_default.SetFont(self.font) - self._attr_default.SetBackgroundColour("white") + self._attr_default.SetBackgroundColour(wx.WHITE) self._attr_selected = Grid.GridCellAttr() self._attr_selected.SetFont(self.font) @@ -381,7 +381,7 @@ def on_key_down(self, evt): key = evt.GetKeyCode() if key == wx.WXK_BACK or key == wx.WXK_DELETE: - self.SetValue(self.startValue) + self.SetValue(self.startValue or "") self.Clear() if key == wx.WXK_TAB: @@ -394,7 +394,7 @@ def on_key_down(self, evt): or key == wx.WXK_LEFT or key == wx.WXK_RIGHT ): - self.SetValue(self.startValue) + self.SetValue(self.startValue or "") wx.CallAfter(self.parentgrid._abort_edit) return elif self.mode == "hex": @@ -650,7 +650,7 @@ def __init__( self.SetRowLabelSize(50) self.SetColLabelSize(20) - self.RegisterDataType(Grid.GRID_VALUE_STRING, None, None) + self.RegisterDataType(Grid.GRID_VALUE_STRING, None, None) # type: ignore self.SetDefaultEditor(HexCellEditor(self)) self.ShowScrollbars(wx.SHOW_SB_ALWAYS, wx.SHOW_SB_ALWAYS) @@ -1315,7 +1315,7 @@ class MyFrame(wx.Frame): """We simply derive a new class of Frame.""" def __init__(self, parent, title): - wx.Frame.__init__(self, parent, title=title, size=(420, 800)) + wx.Frame.__init__(self, parent, title=title, size=wx.Size(420, 800)) # Create an instance of our model... self.hex_editor = WxHexEditor(self) diff --git a/construct_editor/wx_widgets/wx_obj_view.py b/construct_editor/wx_widgets/wx_obj_view.py index d7ccc97..0304caf 100644 --- a/construct_editor/wx_widgets/wx_obj_view.py +++ b/construct_editor/wx_widgets/wx_obj_view.py @@ -31,7 +31,8 @@ class WxObjEditor_Default(wx.TextCtrl): def __init__(self, parent, settings: ObjViewSettings): self.entry = settings.entry - super(wx.TextCtrl, self).__init__( + wx.TextCtrl.__init__( + self, parent, wx.ID_ANY, self.entry.obj_str, @@ -48,7 +49,8 @@ class WxObjEditor_String(wx.TextCtrl): def __init__(self, parent, settings: ObjViewSettings_String): self.entry = settings.entry - super(wx.TextCtrl, self).__init__( + wx.TextCtrl.__init__( + self, parent, wx.ID_ANY, self.entry.obj_str, @@ -66,7 +68,8 @@ class WxObjEditor_Integer(wx.TextCtrl): def __init__(self, parent, settings: ObjViewSettings_Integer): self.entry = settings.entry - super(wx.TextCtrl, self).__init__( + wx.TextCtrl.__init__( + self, parent, wx.ID_ANY, self.entry.obj_str, @@ -91,7 +94,8 @@ class WxObjEditor_Bytes(wx.TextCtrl): def __init__(self, parent, settings: ObjViewSettings_Bytes): self.entry = settings.entry - super(wx.TextCtrl, self).__init__( + wx.TextCtrl.__init__( + self, parent, wx.ID_ANY, settings.entry.obj_str, diff --git a/construct_editor/wx_widgets/wx_python_code_editor.py b/construct_editor/wx_widgets/wx_python_code_editor.py index 0fc23a4..fac38f5 100644 --- a/construct_editor/wx_widgets/wx_python_code_editor.py +++ b/construct_editor/wx_widgets/wx_python_code_editor.py @@ -501,7 +501,7 @@ def SetUpEditor(self): # Caret color self.SetCaretForeground("BLUE") # Selection background - self.SetSelBackground(1, "#66CCFF") + self.SetSelBackground(True, "#66CCFF") # Attempt to set caret blink rate. try: diff --git a/pyproject.toml b/pyproject.toml index 30ff546..d1e0089 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,8 @@ construct-editor = "construct_editor.main:main" dev = [ "poethepoet>=0.46.0", "pyright>=1.1.410", + "pytest>=8.0.0", + "pytest-mock>=3.14.0", "ruff>=0.15.16", "ty>=0.0.46", "cryptography", # optional "extra" from construct that the user may or may not have installed @@ -85,6 +87,10 @@ include = [ [tool.setuptools.package-data] construct_editor = ["py.typed"] +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v" + [tool.pyright] typeCheckingMode = "strict" exclude = [ @@ -167,6 +173,9 @@ ignore = [ "F841", # Local variable `...` is assigned to but never used ] +[tool.uv] +system-certs = true + [tool.poe.tasks.typecheck] sequence = [ { cmd = "ruff check --fix --show-fixes" }, diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..40a96af --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..40a96af --- /dev/null +++ b/tests/core/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/tests/core/test_callbacks.py b/tests/core/test_callbacks.py new file mode 100644 index 0000000..967acc6 --- /dev/null +++ b/tests/core/test_callbacks.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""Tests for construct_editor.core.callbacks — CallbackList.""" + +import pytest + +from construct_editor.core.callbacks import CallbackList + + +class TestCallbackList: + """Tests for the generic CallbackList observer utility.""" + + def test_fire_calls_appended_callback(self) -> None: + """A callback that was appended is invoked when fire() is called.""" + calls: list[int] = [] + cb = CallbackList() + cb.append(lambda x: calls.append(x)) + + cb.fire(42) + + assert calls == [42] + + def test_fire_calls_multiple_callbacks_in_order(self) -> 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(self) -> 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(self) -> None: + """clear() empties the callback list so no handlers are invoked.""" + calls: list[int] = [] + cb = CallbackList() + 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(self) -> None: + """Firing an empty CallbackList must not raise any exception.""" + cb = CallbackList() + cb.fire() # should not raise + + def test_remove_non_existing_raises(self) -> None: + """Removing a callback that was never appended should raise ValueError.""" + cb = CallbackList() + with pytest.raises((ValueError, Exception)): + cb.remove(lambda: None) diff --git a/tests/core/test_commands.py b/tests/core/test_commands.py new file mode 100644 index 0000000..6983faf --- /dev/null +++ b/tests/core/test_commands.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +"""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 +# --------------------------------------------------------------------------- + + +class TestCommandProcessor: + """Tests for CommandProcessor undo/redo behaviour.""" + + def _make_processor(self) -> CommandProcessor: + return CommandProcessor(max_commands=100) + + def test_submit_executes_command(self) -> None: + """submit() must call do() on the command.""" + counter = [0] + proc = self._make_processor() + proc.submit(_IncrementCommand(counter)) + assert counter[0] == 1 + + def test_can_undo_after_submit(self) -> None: + """can_undo() returns True after at least one command has been submitted.""" + proc = self._make_processor() + assert not proc.can_undo() + proc.submit(_IncrementCommand([0])) + assert proc.can_undo() + + def test_can_redo_after_undo(self) -> None: + """can_redo() returns True after an undo operation.""" + proc = self._make_processor() + proc.submit(_IncrementCommand([0])) + assert not proc.can_redo() + proc.undo() + assert proc.can_redo() + + def test_undo_reverses_command(self) -> None: + """undo() must call the command's undo() method, restoring the previous state.""" + counter = [0] + proc = self._make_processor() + proc.submit(_IncrementCommand(counter)) + proc.undo() + assert counter[0] == 0 + + def test_redo_re_executes_command(self) -> None: + """redo() must re-apply the previously undone command.""" + counter = [0] + proc = self._make_processor() + proc.submit(_IncrementCommand(counter)) + proc.undo() + proc.redo() + assert counter[0] == 1 + + def test_submit_after_undo_clears_redo_history(self) -> None: + """Submitting a new command after undo must discard the redo stack.""" + counter = [0] + proc = self._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(self) -> None: + """clear_commands() empties the history so undo/redo are no longer possible.""" + proc = self._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(self) -> None: + """Multiple submit/undo/redo operations must stay consistent.""" + counter = [0] + proc = self._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..70a0b5a --- /dev/null +++ b/tests/core/test_construct_editor.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +"""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 + + +class TestConstructEditorContract: + """Placeholder — will hold tests for ConstructEditor once a stub is in place.""" + + @pytest.mark.skip(reason="Stub for ConstructEditor not yet implemented") + def test_placeholder(self) -> None: + pass diff --git a/tests/core/test_context_menu.py b/tests/core/test_context_menu.py new file mode 100644 index 0000000..1d4f7a9 --- /dev/null +++ b/tests/core/test_context_menu.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +"""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 ( + SeparatorMenuItem, + ButtonMenuItem, + CheckboxMenuItem, + RadioGroupMenuItems, + SubmenuItem, +) + + +class TestMenuItemDataclasses: + """Tests for the plain-data menu item types.""" + + def test_separator_menu_item_instantiation(self) -> None: + item = SeparatorMenuItem() + assert item is not None + + def test_button_menu_item_fields(self) -> 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(self) -> 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(self) -> 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(self) -> 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(self) -> 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..6661176 --- /dev/null +++ b/tests/core/test_custom.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +"""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 as cs + +from construct_editor.core.custom import ( + add_custom_transparent_subconstruct, + add_custom_tunnel, + add_custom_adapter, +) + + +# --------------------------------------------------------------------------- +# add_custom_transparent_subconstruct +# --------------------------------------------------------------------------- + + +class TestAddCustomTransparentSubconstruct: + """Tests for registering a transparent subconstruct.""" + + def test_registration_does_not_raise(self) -> None: + """Calling add_custom_transparent_subconstruct must not raise.""" + + class MySubconstruct(cs.Subconstruct): + 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 +# --------------------------------------------------------------------------- + + +class TestAddCustomTunnel: + """Tests for registering a tunnel construct.""" + + def test_registration_does_not_raise(self) -> None: + """Calling add_custom_tunnel must not raise.""" + + class MyTunnel(cs.Tunnel): + 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 +# --------------------------------------------------------------------------- + + +class TestAddCustomAdapter: + """Tests for registering a custom adapter.""" + + def test_registration_does_not_raise(self) -> None: + """Calling add_custom_adapter must not raise.""" + from construct_editor.core.custom import AdapterObjEditorType + + class MyAdapter(cs.Adapter): + 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..0a43375 --- /dev/null +++ b/tests/core/test_entries.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +"""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 ( + int_to_str, + str_to_int, + str_to_bytes, + create_path_str, +) +from construct_editor.core.model import IntegerFormat + + +# --------------------------------------------------------------------------- +# int_to_str helpers +# --------------------------------------------------------------------------- + + +class TestIntToStr: + """Tests for the int_to_str formatting helper. + + Signature: int_to_str(integer_format, val) -> str + """ + + def test_decimal_format(self) -> None: + assert int_to_str(IntegerFormat.Dec, 255) == "255" + + def test_hex_format(self) -> None: + result = int_to_str(IntegerFormat.Hex, 255) + assert result.lower() in ("0xff", "ff", "255") # implementation may vary + + def test_zero_decimal(self) -> None: + assert int_to_str(IntegerFormat.Dec, 0) == "0" + + def test_negative_decimal(self) -> None: + assert int_to_str(IntegerFormat.Dec, -1) == "-1" + + +class TestStrToInt: + """Tests for the str_to_int parsing helper.""" + + def test_parse_decimal(self) -> None: + assert str_to_int("42") == 42 + + def test_parse_hex_0x_prefix(self) -> None: + assert str_to_int("0xFF") == 255 + + def test_parse_hex_lower(self) -> None: + assert str_to_int("0xff") == 255 + + def test_invalid_string_raises(self) -> None: + with pytest.raises(Exception): + str_to_int("not_a_number") + + +class TestStrToBytes: + """Tests for the str_to_bytes parsing helper.""" + + def test_parse_hex_bytes(self) -> None: + result = str_to_bytes("01 02 03") + assert result == b"\x01\x02\x03" + + def test_parse_empty_string(self) -> None: + result = str_to_bytes("") + assert result == b"" + + def test_invalid_hex_raises(self) -> None: + with pytest.raises(Exception): + str_to_bytes("zz") + + +# --------------------------------------------------------------------------- +# create_path_str +# --------------------------------------------------------------------------- + + +class TestCreatePathStr: + """Tests for the create_path_str helper.""" + + def test_single_name(self) -> None: + result = create_path_str(["root"]) + assert "root" in result + + def test_nested_path(self) -> 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..3ebd8ba --- /dev/null +++ b/tests/core/test_model.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +"""Tests for construct_editor.core.model. + +Covers IntegerFormat, ConstructEditorColumn enumerations +and the abstract ConstructEditorModel contract. +""" + + +from construct_editor.core.model import IntegerFormat, ConstructEditorColumn + + +class TestIntegerFormat: + """Tests for the IntegerFormat enum.""" + + def test_members_exist(self) -> None: + assert IntegerFormat.Dec is not None + assert IntegerFormat.Hex is not None + + def test_distinct_values(self) -> None: + assert IntegerFormat.Dec != IntegerFormat.Hex + + +class TestConstructEditorColumn: + """Tests for the ConstructEditorColumn enum.""" + + def test_name_column_index(self) -> None: + assert ConstructEditorColumn.Name == 0 + + def test_type_column_index(self) -> None: + assert ConstructEditorColumn.Type == 1 + + def test_value_column_index(self) -> None: + assert ConstructEditorColumn.Value == 2 + + def test_all_columns_distinct(self) -> 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..2948e8b --- /dev/null +++ b/tests/core/test_preprocessor.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +"""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, +) + + +class TestIncludeMetadata: + """Tests for the include_metadata() instrumentation wrapper.""" + + def test_include_metadata_returns_construct(self) -> None: + """include_metadata() must return a construct object.""" + wrapped = include_metadata(cs.Byte) + assert wrapped is not None + + def test_parse_attaches_gui_metadata(self) -> 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(self) -> 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(self) -> 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(self) -> 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 + + +class TestGetGuiMetadata: + """Tests for the get_gui_metadata() helper.""" + + def test_returns_none_for_plain_int(self) -> None: + """get_gui_metadata() returns None for values without attached metadata.""" + assert get_gui_metadata(42) is None + + def test_returns_none_for_plain_bytes(self) -> None: + assert get_gui_metadata(b"\x00") is None + + def test_returns_none_for_none(self) -> None: + assert get_gui_metadata(None) is None diff --git a/tests/wx_widgets/__init__.py b/tests/wx_widgets/__init__.py new file mode 100644 index 0000000..40a96af --- /dev/null +++ b/tests/wx_widgets/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/tests/wx_widgets/conftest.py b/tests/wx_widgets/conftest.py new file mode 100644 index 0000000..7f09077 --- /dev/null +++ b/tests/wx_widgets/conftest.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +"""Shared pytest fixtures for wx_widgets 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. + +wx must not be imported at module level outside of test files that actually +need it, to keep tests/core/ free of any wx dependency. +""" + +import pytest + + +@pytest.fixture(scope="session") +def wx_app(): + """Session-scoped wx.App instance required by all wx widget tests.""" + import wx # noqa: PLC0415 — intentional lazy import + + app = wx.App(False) + yield app + app.Destroy() + + +@pytest.fixture +def wx_frame(wx_app): + """Module-scoped top-level wx.Frame used as a parent for tested widgets. + + Created fresh for each test and destroyed afterwards to avoid state leakage. + """ + import wx # noqa: PLC0415 + + frame = wx.Frame(None) + frame.Show(False) + yield frame + frame.Destroy() + wx_app.ProcessPendingEvents() diff --git a/tests/wx_widgets/test_wx_construct_editor.py b/tests/wx_widgets/test_wx_construct_editor.py new file mode 100644 index 0000000..4535142 --- /dev/null +++ b/tests/wx_widgets/test_wx_construct_editor.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +"""Tests for construct_editor.wx_widgets.wx_construct_editor. + +Covers WxConstructEditorModel and WxConstructEditor — the wx DataView-backed +implementation of the framework-agnostic ConstructEditor base class. +""" + +import pytest + + +pytestmark = pytest.mark.usefixtures("wx_app") + + +class TestWxConstructEditorModel: + """Tests for WxConstructEditorModel (wx DataView model adapter).""" + + @pytest.mark.skip(reason="Requires a running wx.App — enable when wx tests are wired up") + def test_placeholder(self, wx_frame) -> 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 + + +class TestWxConstructEditor: + """Tests for the WxConstructEditor composite panel.""" + + @pytest.mark.skip(reason="Requires a running wx.App — enable when wx tests are wired up") + def test_placeholder(self, wx_frame) -> 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_widgets/test_wx_construct_hex_editor.py b/tests/wx_widgets/test_wx_construct_hex_editor.py new file mode 100644 index 0000000..fb5657e --- /dev/null +++ b/tests/wx_widgets/test_wx_construct_hex_editor.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +"""Tests for construct_editor.wx_widgets.wx_construct_hex_editor. + +Covers HexEditorPanel and WxConstructHexEditor. +""" + +import pytest + +pytestmark = pytest.mark.usefixtures("wx_app") + + +class TestHexEditorPanel: + """Tests for the HexEditorPanel splitter widget.""" + + @pytest.mark.skip(reason="Requires a running wx.App — enable when wx tests are wired up") + def test_placeholder(self, wx_frame) -> 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 + + +class TestWxConstructHexEditor: + """Tests for the composite WxConstructHexEditor widget.""" + + @pytest.mark.skip(reason="Requires a running wx.App — enable when wx tests are wired up") + def test_placeholder(self, wx_frame) -> 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_widgets/test_wx_context_menu.py b/tests/wx_widgets/test_wx_context_menu.py new file mode 100644 index 0000000..7ec8e98 --- /dev/null +++ b/tests/wx_widgets/test_wx_context_menu.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +"""Tests for construct_editor.wx_widgets.wx_context_menu. + +Covers WxContextMenu — the concrete wx.Menu implementation of the abstract +ContextMenu base class. +""" + +import pytest + +pytestmark = pytest.mark.usefixtures("wx_app") + + +class TestWxContextMenu: + """Tests for WxContextMenu.""" + + @pytest.mark.skip(reason="Requires a running wx.App — enable when wx tests are wired up") + def test_placeholder(self, wx_frame) -> 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_widgets/test_wx_exception_dialog.py b/tests/wx_widgets/test_wx_exception_dialog.py new file mode 100644 index 0000000..859a01c --- /dev/null +++ b/tests/wx_widgets/test_wx_exception_dialog.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +"""Tests for construct_editor.wx_widgets.wx_exception_dialog. + +Covers ExceptionInfo dataclass and WxExceptionDialog. +""" + +import pytest + +pytestmark = pytest.mark.usefixtures("wx_app") + + +class TestExceptionInfo: + """Tests for the ExceptionInfo dataclass (no wx required).""" + + def test_fields_stored_correctly(self) -> None: + """ExceptionInfo must store etype, value, and trace.""" + from construct_editor.wx_widgets.wx_exception_dialog import ExceptionInfo + + 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 + + +class TestWxExceptionDialog: + """Tests for WxExceptionDialog.""" + + @pytest.mark.skip(reason="Requires a running wx.App — enable when wx tests are wired up") + def test_placeholder(self, wx_frame) -> 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_widgets/test_wx_hex_editor.py b/tests/wx_widgets/test_wx_hex_editor.py new file mode 100644 index 0000000..03ddd34 --- /dev/null +++ b/tests/wx_widgets/test_wx_hex_editor.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +"""Tests for construct_editor.wx_widgets.wx_hex_editor. + +Covers HexEditorFormat, HexEditorBinaryData, and WxHexEditor. +""" + +import pytest + +pytestmark = pytest.mark.usefixtures("wx_app") + + +class TestHexEditorFormat: + """Tests for the HexEditorFormat dataclass (no wx required).""" + + def test_default_values(self) -> None: + from construct_editor.wx_widgets.wx_hex_editor import HexEditorFormat + + fmt = HexEditorFormat() + assert fmt.width == 16 + assert fmt.label_base == 16 + + def test_custom_values(self) -> None: + from construct_editor.wx_widgets.wx_hex_editor import HexEditorFormat + + fmt = HexEditorFormat(width=8, label_base=10) + assert fmt.width == 8 + assert fmt.label_base == 10 + + +class TestHexEditorBinaryData: + """Tests for the HexEditorBinaryData observable bytearray (no wx required).""" + + @pytest.mark.skip(reason="Requires a running wx.App for wx.CommandProcessor — enable when wx tests are wired up") + def test_placeholder(self) -> None: + pass + + # TODO: Tests to add: + # - overwrite_all() replaces the entire buffer + # - overwrite_range() changes only the specified slice + # - insert_range() grows the buffer + # - remove_range() shrinks the buffer + # - on_binary_changed callback fires after each mutation + # - undo() reverts the last mutation + # - redo() re-applies the undone mutation + + +class TestWxHexEditor: + """Tests for the WxHexEditor grid widget.""" + + @pytest.mark.skip(reason="Requires a running wx.App — enable when wx tests are wired up") + def test_placeholder(self, wx_frame) -> None: + pass + + # TODO: Tests to add: + # - Widget can be instantiated with a parent frame + # - Setting .binary replaces the displayed data + # - refresh() does not raise + # - on_binary_changed fires when data is edited via the grid diff --git a/tests/wx_widgets/test_wx_obj_view.py b/tests/wx_widgets/test_wx_obj_view.py new file mode 100644 index 0000000..5014e59 --- /dev/null +++ b/tests/wx_widgets/test_wx_obj_view.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +"""Tests for construct_editor.wx_widgets.wx_obj_view. + +Covers the individual WxObjEditor_* widgets, the create_obj_editor() factory, +and the WxObjRendererHelper_* renderer helpers. +""" + +import pytest + +pytestmark = pytest.mark.usefixtures("wx_app") + + +class TestCreateObjEditorFactory: + """Tests for the create_obj_editor() factory function.""" + + @pytest.mark.skip(reason="Requires a running wx.App — enable when wx tests are wired up") + def test_placeholder(self, wx_frame) -> 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 + + +class TestWxObjEditorDefault: + """Tests for WxObjEditor_Default (read-only fallback).""" + + @pytest.mark.skip(reason="Requires a running wx.App — enable when wx tests are wired up") + def test_placeholder(self, wx_frame) -> None: + pass + + # TODO: Tests to add: + # - get_new_obj() returns the original value unchanged (read-only) + + +class TestWxObjEditorString: + """Tests for WxObjEditor_String.""" + + @pytest.mark.skip(reason="Requires a running wx.App — enable when wx tests are wired up") + def test_placeholder(self, wx_frame) -> None: + pass + + # TODO: Tests to add: + # - Displays the initial string value + # - get_new_obj() returns the edited string + + +class TestWxObjEditorInteger: + """Tests for WxObjEditor_Integer.""" + + @pytest.mark.skip(reason="Requires a running wx.App — enable when wx tests are wired up") + def test_placeholder(self, wx_frame) -> 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 + + +class TestWxObjEditorEnum: + """Tests for WxObjEditor_Enum.""" + + @pytest.mark.skip(reason="Requires a running wx.App — enable when wx tests are wired up") + def test_placeholder(self, wx_frame) -> None: + pass + + # TODO: Tests to add: + # - ComboBox is populated with enum label strings + # - Selecting an entry updates get_new_obj() + + +class TestWxObjEditorFlagsEnum: + """Tests for WxObjEditor_FlagsEnum (ComboCtrl with popup checklist).""" + + @pytest.mark.skip(reason="Requires a running wx.App — enable when wx tests are wired up") + def test_placeholder(self, wx_frame) -> None: + pass + + # TODO: Tests to add: + # - Popup lists all flag names + # - Checked flags are reflected in get_new_obj() diff --git a/tests/wx_widgets/test_wx_python_code_editor.py b/tests/wx_widgets/test_wx_python_code_editor.py new file mode 100644 index 0000000..cadd75d --- /dev/null +++ b/tests/wx_widgets/test_wx_python_code_editor.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +"""Tests for construct_editor.wx_widgets.wx_python_code_editor. + +Covers the WxPythonCodeEditor widget. +""" + +import pytest + +pytestmark = pytest.mark.usefixtures("wx_app") + + +class TestWxPythonCodeEditor: + """Tests for the WxPythonCodeEditor syntax-highlighting code editor widget.""" + + @pytest.mark.skip(reason="Requires a running wx.App — enable when wx tests are wired up") + def test_placeholder(self, wx_frame) -> 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/uv.lock b/uv.lock index 0f58e60..460122d 100644 --- a/uv.lock +++ b/uv.lock @@ -109,6 +109,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" @@ -140,6 +149,8 @@ dev = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "poethepoet" }, { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-mock" }, { name = "ruamel-yaml" }, { name = "ruff" }, { name = "ty" }, @@ -163,6 +174,8 @@ dev = [ { name = "numpy", specifier = ">=1.20.0" }, { name = "poethepoet", specifier = ">=0.46.0" }, { name = "pyright", specifier = ">=1.1.410" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "ruamel-yaml" }, { name = "ruff", specifier = ">=0.15.16" }, { name = "ty", specifier = ">=0.0.46" }, @@ -241,6 +254,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +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 = "lz4" version = "4.4.5" @@ -453,6 +487,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, ] +[[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" @@ -462,6 +505,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, ] +[[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.46.0" @@ -485,6 +537,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.410" @@ -498,6 +559,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/33/288b5868fa00846dacf249633719d747893e54aebd196b9968ac1878a5d3/pyright-1.1.410-py3-none-any.whl", hash = "sha256:5e961bed37cacf96b3f7cd7b1da39b350a9239aa2e69138d0e88f728cfaf296c", size = 6082448, upload-time = "2026-06-01T17:35:46.387Z" }, ] +[[package]] +name = "pytest" +version = "9.0.3" +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/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[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"