From 86251090ba08dceea350da68e0a1ec5f0ddeee47 Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Fri, 19 Apr 2024 15:34:24 -0400 Subject: [PATCH] ci: Setup CI testing, linting, and formatting (#56) * ci: Setup CI and linting/testing * test: Add initial test * fix: setting float nan/inf * fix: prefer double quotes * fix: Actually handle nan * chore: Run ruff format * chore: Apply linting * docs: Add developement instructions to README.md --- .github/workflows/ci.yml | 40 ++++++ README.md | 51 +++++-- pyproject.toml | 40 +++++- scripts/generate_options_mixin.py | 141 +++++++++--------- src/ipyniivue/__init__.py | 11 +- src/ipyniivue/_constants.py | 11 +- src/ipyniivue/_options_mixin.py | 228 +++++++++++++++--------------- src/ipyniivue/_utils.py | 4 +- src/ipyniivue/_widget.py | 29 +++- tests/test_ipyniivue.py | 4 + 10 files changed, 344 insertions(+), 215 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/test_ipyniivue.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..edd08c0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + Lint: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: | + pip install uv + uv pip install --system ruff + ruff check + + Test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: | + pip install uv + uv pip install --system . + uv pip install --system pytest inline-snapshot + - name: Run tests + run: pytest ./tests --color=yes diff --git a/README.md b/README.md index f9202f3..7db0bac 100644 --- a/README.md +++ b/README.md @@ -25,27 +25,60 @@ See the [basic demo](./demo/basic_multiplanar.ipynb) to learn more. # Development -This is an [anywidget](https://github.com/manzt/anywidget) project. To get -started create a virtual Python environment and install the necessary -development dependencies. +**ipyniivue** uses [the +recommended](https://packaging.python.org/en/latest/flow/#) `hatchling` +build-system, which is convenient to use via the [`hatch` +CLI](https://hatch.pypa.io/latest/). We recommend installing `hatch` globally +(e.g., via `pipx`) and running the various commands defined within +`pyproject.toml`. `hatch` will take care of creating and synchronizing a +virtual environment with all dependencies defined in `pyproject.toml`. + +### Commands Cheatsheet + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +| :--------------------- | :-------------------------------------------------------------------------| +| `hatch run format` | Format project with `ruff format .` and apply linting with `ruff --fix .` | +| `hatch run lint` | Lint project with `ruff check .`. | +| `hatch run test` | Run unit tests with `pytest` | + +Alternatively, you can develop **ipyniivue** by manually creating a virtual +environment and managing installation and dependencies with `pip`. ```sh python3 -m venv .venv && source .venv/bin/activate -pip install -e ".[dev]" +pip install -e ".[dev]" +``` + +### Making Changes to the JavaScript Code + +This is an [anywidget](https://github.com/manzt/anywidget) project, which means +the code base is hybrid Python and JavaScript. The JavaScript part is developed +under `js/` and uses [esbuild](https://esbuild.github.io/) to bundle the code. +Any time you make changes to the JavaScript code, you need to rebuild the files +under `src/ipyniivue/static`. This can be done in two ways: + +```sh +npm run build ``` -Then, install JS dependencies with `npm` and run the dev server. +which will build the JavaScript code once, or you can start a development server: ```sh -npm install npm run dev ``` -You can now start VS Code or JupyterLab to develop the widget. When finished, -stop the JS development server. +which will start a development server that will automatically rebuild the code +as you make changes. We recommend the latter approach, as it is more convenient. + +Once you have the development server running, you can start the JupyterLab +or VS Code to develop the widget. When finished, you can stop the development +server with `Ctrl+C`. > NOTE: In order to have anywidget automatically apply changes as you work, -> make sure to `export ANYWIDGET_HMR=1` environment variable. +> make sure to `export ANYWIDGET_HMR=1` environment variable. This can be set +> directly in a notebook with `%env ANYWIDGET_HMR=1` in a cell. # Changelog: diff --git a/pyproject.toml b/pyproject.toml index 69d53af..137d386 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,21 +1,24 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" [project] name = "ipyniivue" -version = "2.0.0" +dynamic = ["version"] +description = "A Jupyter Widget for Niivue based on anywidget." dependencies = ["anywidget"] readme = "README.md" - [project.optional-dependencies] -dev = ["watchfiles", "jupyterlab"] +dev = ["watchfiles", "jupyterlab", "ruff", "pytest"] -# automatically add the dev feature to the default env (e.g., hatch shell) [tool.hatch.envs.default] features = ["dev"] +uv = true +# https://github.com/ofek/hatch-vcs +[tool.hatch.version] +source = "vcs" [tool.hatch.build] only-packages = true @@ -28,3 +31,30 @@ dependencies = ["hatch-jupyter-builder>=0.5.0"] [tool.hatch.build.hooks.jupyter-builder.build-kwargs] npm = "npm" build_cmd = "build" + +[tool.hatch.envs.default.scripts] +lint = ["ruff check . {args:.}", "ruff format . --check --diff {args:.}"] +format = ["ruff format . {args:.}", "ruff check . --fix {args:.}"] +test = ["pytest {args:.}"] + +[tool.ruff.lint] +pydocstyle = { convention = "numpy" } +select = [ + "E", # style errors + "W", # style warnings + "F", # flakes + "D", # pydocstyle + "D417", # Missing argument descriptions in Docstrings + "I", # isort + "UP", # pyupgrade + "C4", # flake8-comprehensions + "B", # flake8-bugbear + "A001", # flake8-builtins + "RUF", # ruff-specific rules + "TCH", # flake8-type-checking + "TID", # flake8-tidy-imports +] + +[tool.ruff.lint.per-file-ignores] +"tests/*.py" = ["D", "S"] +"scripts/*.py" = ["D", "S"] diff --git a/scripts/generate_options_mixin.py b/scripts/generate_options_mixin.py index 6131888..cd0ee69 100644 --- a/scripts/generate_options_mixin.py +++ b/scripts/generate_options_mixin.py @@ -1,15 +1,14 @@ +import math import pathlib import typing - from ipyniivue._constants import ( + _SNAKE_TO_CAMEL_OVERRIDES, DragMode, MuliplanarType, SliceType, - _SNAKE_TO_CAMEL_OVERRIDES, ) - RENAME_OVERRIDES = {v: k for k, v in _SNAKE_TO_CAMEL_OVERRIDES.items()} @@ -43,20 +42,28 @@ def type_hint(value: typing.Any): def get_value(value: typing.Any): + if isinstance(value, float) and math.isnan(value): + return 'float("nan")' + if value == float("inf"): + return 'float("inf")' if isinstance(value, SliceType): return f"SliceType.{value.name}" if isinstance(value, MuliplanarType): return f"MuliplanarType.{value.name}" if isinstance(value, DragMode): return f"DragMode.{value.name}" + if isinstance(value, str): + # double quote + return f'"{value}"' return repr(value) -def generate_mixin(options): +def generate_mixin(options: typing.Dict[str, typing.Any]): lines = [ "# This file is automatically generated by scripts/generate_options_mixin.py", "# Do not edit this file directly", "from __future__ import annotations", + "", "import typing", "", "from ._constants import SliceType, MuliplanarType, DragMode", @@ -66,83 +73,79 @@ def generate_mixin(options): "class OptionsMixin:", ] for option, value in options.items(): - snake_name = RENAME_OVERRIDES.get(option, camel_to_snake(option)) hint = type_hint(value) lines.append(" @property") lines.append(f" def {snake_name}(self) -> {hint}:") - lines.append(f" return self._opts.get('{option}', {get_value(value)})") + lines.append(f' return self._opts.get("{option}", {get_value(value)})') lines.append("") lines.append(f" @{snake_name}.setter") lines.append(f" def {snake_name}(self, value: {hint}):") - lines.append(f' self._opts = {{ **self._opts, "{option}": value }}') + lines.append(f' self._opts = {{**self._opts, "{option}": value}}') lines.append("") return "\n".join(lines) if __name__ == "__main__": # Copied from niivue (should be able to automatically generate this) - DEFAULT_OPTIONS = dict( - textHeight=0.06, - colorbarHeight=0.05, - crosshairWidth=1, - rulerWidth=4, - show3Dcrosshair=False, - backColor=(0, 0, 0, 1), - crosshairColor=(1, 0, 0, 1), - fontColor=(0.5, 0.5, 0.5, 1), - selectionBoxColor=(1, 1, 1, 0.5), - clipPlaneColor=(0.7, 0, 0.7, 0.5), - rulerColor=(1, 0, 0, 0.8), - colorbarMargin=0.05, - trustCalMinMax=True, - clipPlaneHotKey="KeyC", - viewModeHotKey="KeyV", - doubleTouchTimeout=500, - longTouchTimeout=1000, - keyDebounceTime=50, - isNearestInterpolation=False, - isResizeCanvas=True, - isAtlasOutline=False, - isRuler=False, - isColorbar=False, - isOrientCube=False, - multiplanarPadPixels=0, - multiplanarForceRender=False, - isRadiologicalConvention=False, - meshThicknessOn2D=float("inf"), - dragMode=DragMode.CONTRAST, - yoke3Dto2DZoom=False, - isDepthPickMesh=False, - isCornerOrientationText=False, - sagittalNoseLeft=False, - isSliceMM=False, - isV1SliceShader=False, - isHighResolutionCapable=True, - logLevel="info", - loadingText="waiting for images...", - isForceMouseClickToVoxelCenters=False, - dragAndDropEnabled=True, - drawingEnabled=False, - penValue=1, - floodFillNeighbors=6, - isFilledPen=False, - thumbnail="", - maxDrawUndoBitmaps=8, - sliceType=SliceType.MULTIPLANAR, - meshXRay=0.0, - isAntiAlias=None, - limitFrames4D=float("nan"), - isAdditiveBlend=False, - showLegend=True, - legendBackgroundColor=(0.3, 0.3, 0.3, 0.5), - legendTextColor=(1.0, 1.0, 1.0, 1.0), - multiplanarLayout=MuliplanarType.AUTO, - renderOverlayBlend=1.0, - ) + DEFAULT_OPTIONS = { + "textHeight": 0.06, + "colorbarHeight": 0.05, + "crosshairWidth": 1, + "rulerWidth": 4, + "show3Dcrosshair": False, + "backColor": (0, 0, 0, 1), + "crosshairColor": (1, 0, 0, 1), + "fontColor": (0.5, 0.5, 0.5, 1), + "selectionBoxColor": (1, 1, 1, 0.5), + "clipPlaneColor": (0.7, 0, 0.7, 0.5), + "rulerColor": (1, 0, 0, 0.8), + "colorbarMargin": 0.05, + "trustCalMinMax": True, + "clipPlaneHotKey": "KeyC", + "viewModeHotKey": "KeyV", + "doubleTouchTimeout": 500, + "longTouchTimeout": 1000, + "keyDebounceTime": 50, + "isNearestInterpolation": False, + "isResizeCanvas": True, + "isAtlasOutline": False, + "isRuler": False, + "isColorbar": False, + "isOrientCube": False, + "multiplanarPadPixels": 0, + "multiplanarForceRender": False, + "isRadiologicalConvention": False, + "meshThicknessOn2D": float("inf"), + "dragMode": DragMode.CONTRAST, + "yoke3Dto2DZoom": False, + "isDepthPickMesh": False, + "isCornerOrientationText": False, + "sagittalNoseLeft": False, + "isSliceMM": False, + "isV1SliceShader": False, + "isHighResolutionCapable": True, + "logLevel": "info", + "loadingText": "waiting for images...", + "isForceMouseClickToVoxelCenters": False, + "dragAndDropEnabled": True, + "drawingEnabled": False, + "penValue": 1, + "floodFillNeighbors": 6, + "isFilledPen": False, + "thumbnail": "", + "maxDrawUndoBitmaps": 8, + "sliceType": SliceType.MULTIPLANAR, + "meshXRay": 0.0, + "isAntiAlias": None, + "limitFrames4D": float("nan"), + "isAdditiveBlend": False, + "showLegend": True, + "legendBackgroundColor": (0.3, 0.3, 0.3, 0.5), + "legendTextColor": (1.0, 1.0, 1.0, 1.0), + "multiplanarLayout": MuliplanarType.AUTO, + "renderOverlayBlend": 1.0, + } code = generate_mixin(DEFAULT_OPTIONS) - loc = ( - pathlib.Path(__file__).parent - / "../src/ipyniivue/_options_mixin.py" - ) + loc = pathlib.Path(__file__).parent / "../src/ipyniivue/_options_mixin.py" loc.write_text(code) diff --git a/src/ipyniivue/__init__.py b/src/ipyniivue/__init__.py index 1e081dc..bf0ea3e 100644 --- a/src/ipyniivue/__init__.py +++ b/src/ipyniivue/__init__.py @@ -1,9 +1,8 @@ +"""A Jupyter widget for Niivue based on anywidget.""" + import importlib.metadata -from ._constants import SliceType, DragMode, MuliplanarType # noqa -from ._widget import AnyNiivue # noqa +from ._constants import DragMode, MuliplanarType, SliceType # noqa: F401 +from ._widget import AnyNiivue # noqa: F401 -try: - __version__ = importlib.metadata.version("ipyniivue") -except importlib.metadata.PackageNotFoundError: - __version__ = "unknown" +__version__ = importlib.metadata.version("ipyniivue") diff --git a/src/ipyniivue/_constants.py b/src/ipyniivue/_constants.py index 340c38c..014c380 100644 --- a/src/ipyniivue/_constants.py +++ b/src/ipyniivue/_constants.py @@ -6,6 +6,7 @@ "MuliplanarType", ] + class SliceType(enum.Enum): AXIAL = 1 CORONAL = 2 @@ -28,9 +29,9 @@ class MuliplanarType(enum.Enum): _SNAKE_TO_CAMEL_OVERRIDES = { - "show_3d_crosshair": "show3Dcrosshair", - "mesh_thickness_on_2d": "meshThicknessOn2D", - "yoke_3d_to_2d_zoom": "yoke3Dto2DZoom", - "is_slice_mm": "isSliceMM", - "limit_frames_4d": "limitFrames4D", + "show_3d_crosshair": "show3Dcrosshair", + "mesh_thickness_on_2d": "meshThicknessOn2D", + "yoke_3d_to_2d_zoom": "yoke3Dto2DZoom", + "is_slice_mm": "isSliceMM", + "limit_frames_4d": "limitFrames4D", } diff --git a/src/ipyniivue/_options_mixin.py b/src/ipyniivue/_options_mixin.py index 4abde72..8d43aa2 100644 --- a/src/ipyniivue/_options_mixin.py +++ b/src/ipyniivue/_options_mixin.py @@ -1,457 +1,459 @@ # This file is automatically generated by scripts/generate_options_mixin.py # Do not edit this file directly from __future__ import annotations + import typing -from ._constants import SliceType, MuliplanarType, DragMode +from ._constants import DragMode, MuliplanarType, SliceType __all__ = ["OptionsMixin"] + class OptionsMixin: @property def text_height(self) -> float: - return self._opts.get('textHeight', 0.06) + return self._opts.get("textHeight", 0.06) @text_height.setter def text_height(self, value: float): - self._opts = { **self._opts, "textHeight": value } + self._opts = {**self._opts, "textHeight": value} @property def colorbar_height(self) -> float: - return self._opts.get('colorbarHeight', 0.05) + return self._opts.get("colorbarHeight", 0.05) @colorbar_height.setter def colorbar_height(self, value: float): - self._opts = { **self._opts, "colorbarHeight": value } + self._opts = {**self._opts, "colorbarHeight": value} @property def crosshair_width(self) -> int: - return self._opts.get('crosshairWidth', 1) + return self._opts.get("crosshairWidth", 1) @crosshair_width.setter def crosshair_width(self, value: int): - self._opts = { **self._opts, "crosshairWidth": value } + self._opts = {**self._opts, "crosshairWidth": value} @property def ruler_width(self) -> int: - return self._opts.get('rulerWidth', 4) + return self._opts.get("rulerWidth", 4) @ruler_width.setter def ruler_width(self, value: int): - self._opts = { **self._opts, "rulerWidth": value } + self._opts = {**self._opts, "rulerWidth": value} @property def show_3d_crosshair(self) -> bool: - return self._opts.get('show3Dcrosshair', False) + return self._opts.get("show3Dcrosshair", False) @show_3d_crosshair.setter def show_3d_crosshair(self, value: bool): - self._opts = { **self._opts, "show3Dcrosshair": value } + self._opts = {**self._opts, "show3Dcrosshair": value} @property def back_color(self) -> tuple: - return self._opts.get('backColor', (0, 0, 0, 1)) + return self._opts.get("backColor", (0, 0, 0, 1)) @back_color.setter def back_color(self, value: tuple): - self._opts = { **self._opts, "backColor": value } + self._opts = {**self._opts, "backColor": value} @property def crosshair_color(self) -> tuple: - return self._opts.get('crosshairColor', (1, 0, 0, 1)) + return self._opts.get("crosshairColor", (1, 0, 0, 1)) @crosshair_color.setter def crosshair_color(self, value: tuple): - self._opts = { **self._opts, "crosshairColor": value } + self._opts = {**self._opts, "crosshairColor": value} @property def font_color(self) -> tuple: - return self._opts.get('fontColor', (0.5, 0.5, 0.5, 1)) + return self._opts.get("fontColor", (0.5, 0.5, 0.5, 1)) @font_color.setter def font_color(self, value: tuple): - self._opts = { **self._opts, "fontColor": value } + self._opts = {**self._opts, "fontColor": value} @property def selection_box_color(self) -> tuple: - return self._opts.get('selectionBoxColor', (1, 1, 1, 0.5)) + return self._opts.get("selectionBoxColor", (1, 1, 1, 0.5)) @selection_box_color.setter def selection_box_color(self, value: tuple): - self._opts = { **self._opts, "selectionBoxColor": value } + self._opts = {**self._opts, "selectionBoxColor": value} @property def clip_plane_color(self) -> tuple: - return self._opts.get('clipPlaneColor', (0.7, 0, 0.7, 0.5)) + return self._opts.get("clipPlaneColor", (0.7, 0, 0.7, 0.5)) @clip_plane_color.setter def clip_plane_color(self, value: tuple): - self._opts = { **self._opts, "clipPlaneColor": value } + self._opts = {**self._opts, "clipPlaneColor": value} @property def ruler_color(self) -> tuple: - return self._opts.get('rulerColor', (1, 0, 0, 0.8)) + return self._opts.get("rulerColor", (1, 0, 0, 0.8)) @ruler_color.setter def ruler_color(self, value: tuple): - self._opts = { **self._opts, "rulerColor": value } + self._opts = {**self._opts, "rulerColor": value} @property def colorbar_margin(self) -> float: - return self._opts.get('colorbarMargin', 0.05) + return self._opts.get("colorbarMargin", 0.05) @colorbar_margin.setter def colorbar_margin(self, value: float): - self._opts = { **self._opts, "colorbarMargin": value } + self._opts = {**self._opts, "colorbarMargin": value} @property def trust_cal_min_max(self) -> bool: - return self._opts.get('trustCalMinMax', True) + return self._opts.get("trustCalMinMax", True) @trust_cal_min_max.setter def trust_cal_min_max(self, value: bool): - self._opts = { **self._opts, "trustCalMinMax": value } + self._opts = {**self._opts, "trustCalMinMax": value} @property def clip_plane_hot_key(self) -> str: - return self._opts.get('clipPlaneHotKey', 'KeyC') + return self._opts.get("clipPlaneHotKey", "KeyC") @clip_plane_hot_key.setter def clip_plane_hot_key(self, value: str): - self._opts = { **self._opts, "clipPlaneHotKey": value } + self._opts = {**self._opts, "clipPlaneHotKey": value} @property def view_mode_hot_key(self) -> str: - return self._opts.get('viewModeHotKey', 'KeyV') + return self._opts.get("viewModeHotKey", "KeyV") @view_mode_hot_key.setter def view_mode_hot_key(self, value: str): - self._opts = { **self._opts, "viewModeHotKey": value } + self._opts = {**self._opts, "viewModeHotKey": value} @property def double_touch_timeout(self) -> int: - return self._opts.get('doubleTouchTimeout', 500) + return self._opts.get("doubleTouchTimeout", 500) @double_touch_timeout.setter def double_touch_timeout(self, value: int): - self._opts = { **self._opts, "doubleTouchTimeout": value } + self._opts = {**self._opts, "doubleTouchTimeout": value} @property def long_touch_timeout(self) -> int: - return self._opts.get('longTouchTimeout', 1000) + return self._opts.get("longTouchTimeout", 1000) @long_touch_timeout.setter def long_touch_timeout(self, value: int): - self._opts = { **self._opts, "longTouchTimeout": value } + self._opts = {**self._opts, "longTouchTimeout": value} @property def key_debounce_time(self) -> int: - return self._opts.get('keyDebounceTime', 50) + return self._opts.get("keyDebounceTime", 50) @key_debounce_time.setter def key_debounce_time(self, value: int): - self._opts = { **self._opts, "keyDebounceTime": value } + self._opts = {**self._opts, "keyDebounceTime": value} @property def is_nearest_interpolation(self) -> bool: - return self._opts.get('isNearestInterpolation', False) + return self._opts.get("isNearestInterpolation", False) @is_nearest_interpolation.setter def is_nearest_interpolation(self, value: bool): - self._opts = { **self._opts, "isNearestInterpolation": value } + self._opts = {**self._opts, "isNearestInterpolation": value} @property def is_resize_canvas(self) -> bool: - return self._opts.get('isResizeCanvas', True) + return self._opts.get("isResizeCanvas", True) @is_resize_canvas.setter def is_resize_canvas(self, value: bool): - self._opts = { **self._opts, "isResizeCanvas": value } + self._opts = {**self._opts, "isResizeCanvas": value} @property def is_atlas_outline(self) -> bool: - return self._opts.get('isAtlasOutline', False) + return self._opts.get("isAtlasOutline", False) @is_atlas_outline.setter def is_atlas_outline(self, value: bool): - self._opts = { **self._opts, "isAtlasOutline": value } + self._opts = {**self._opts, "isAtlasOutline": value} @property def is_ruler(self) -> bool: - return self._opts.get('isRuler', False) + return self._opts.get("isRuler", False) @is_ruler.setter def is_ruler(self, value: bool): - self._opts = { **self._opts, "isRuler": value } + self._opts = {**self._opts, "isRuler": value} @property def is_colorbar(self) -> bool: - return self._opts.get('isColorbar', False) + return self._opts.get("isColorbar", False) @is_colorbar.setter def is_colorbar(self, value: bool): - self._opts = { **self._opts, "isColorbar": value } + self._opts = {**self._opts, "isColorbar": value} @property def is_orient_cube(self) -> bool: - return self._opts.get('isOrientCube', False) + return self._opts.get("isOrientCube", False) @is_orient_cube.setter def is_orient_cube(self, value: bool): - self._opts = { **self._opts, "isOrientCube": value } + self._opts = {**self._opts, "isOrientCube": value} @property def multiplanar_pad_pixels(self) -> int: - return self._opts.get('multiplanarPadPixels', 0) + return self._opts.get("multiplanarPadPixels", 0) @multiplanar_pad_pixels.setter def multiplanar_pad_pixels(self, value: int): - self._opts = { **self._opts, "multiplanarPadPixels": value } + self._opts = {**self._opts, "multiplanarPadPixels": value} @property def multiplanar_force_render(self) -> bool: - return self._opts.get('multiplanarForceRender', False) + return self._opts.get("multiplanarForceRender", False) @multiplanar_force_render.setter def multiplanar_force_render(self, value: bool): - self._opts = { **self._opts, "multiplanarForceRender": value } + self._opts = {**self._opts, "multiplanarForceRender": value} @property def is_radiological_convention(self) -> bool: - return self._opts.get('isRadiologicalConvention', False) + return self._opts.get("isRadiologicalConvention", False) @is_radiological_convention.setter def is_radiological_convention(self, value: bool): - self._opts = { **self._opts, "isRadiologicalConvention": value } + self._opts = {**self._opts, "isRadiologicalConvention": value} @property def mesh_thickness_on_2d(self) -> float: - return self._opts.get('meshThicknessOn2D', inf) + return self._opts.get("meshThicknessOn2D", float("inf")) @mesh_thickness_on_2d.setter def mesh_thickness_on_2d(self, value: float): - self._opts = { **self._opts, "meshThicknessOn2D": value } + self._opts = {**self._opts, "meshThicknessOn2D": value} @property def drag_mode(self) -> DragMode: - return self._opts.get('dragMode', DragMode.CONTRAST) + return self._opts.get("dragMode", DragMode.CONTRAST) @drag_mode.setter def drag_mode(self, value: DragMode): - self._opts = { **self._opts, "dragMode": value } + self._opts = {**self._opts, "dragMode": value} @property def yoke_3d_to_2d_zoom(self) -> bool: - return self._opts.get('yoke3Dto2DZoom', False) + return self._opts.get("yoke3Dto2DZoom", False) @yoke_3d_to_2d_zoom.setter def yoke_3d_to_2d_zoom(self, value: bool): - self._opts = { **self._opts, "yoke3Dto2DZoom": value } + self._opts = {**self._opts, "yoke3Dto2DZoom": value} @property def is_depth_pick_mesh(self) -> bool: - return self._opts.get('isDepthPickMesh', False) + return self._opts.get("isDepthPickMesh", False) @is_depth_pick_mesh.setter def is_depth_pick_mesh(self, value: bool): - self._opts = { **self._opts, "isDepthPickMesh": value } + self._opts = {**self._opts, "isDepthPickMesh": value} @property def is_corner_orientation_text(self) -> bool: - return self._opts.get('isCornerOrientationText', False) + return self._opts.get("isCornerOrientationText", False) @is_corner_orientation_text.setter def is_corner_orientation_text(self, value: bool): - self._opts = { **self._opts, "isCornerOrientationText": value } + self._opts = {**self._opts, "isCornerOrientationText": value} @property def sagittal_nose_left(self) -> bool: - return self._opts.get('sagittalNoseLeft', False) + return self._opts.get("sagittalNoseLeft", False) @sagittal_nose_left.setter def sagittal_nose_left(self, value: bool): - self._opts = { **self._opts, "sagittalNoseLeft": value } + self._opts = {**self._opts, "sagittalNoseLeft": value} @property def is_slice_mm(self) -> bool: - return self._opts.get('isSliceMM', False) + return self._opts.get("isSliceMM", False) @is_slice_mm.setter def is_slice_mm(self, value: bool): - self._opts = { **self._opts, "isSliceMM": value } + self._opts = {**self._opts, "isSliceMM": value} @property def is_v1_slice_shader(self) -> bool: - return self._opts.get('isV1SliceShader', False) + return self._opts.get("isV1SliceShader", False) @is_v1_slice_shader.setter def is_v1_slice_shader(self, value: bool): - self._opts = { **self._opts, "isV1SliceShader": value } + self._opts = {**self._opts, "isV1SliceShader": value} @property def is_high_resolution_capable(self) -> bool: - return self._opts.get('isHighResolutionCapable', True) + return self._opts.get("isHighResolutionCapable", True) @is_high_resolution_capable.setter def is_high_resolution_capable(self, value: bool): - self._opts = { **self._opts, "isHighResolutionCapable": value } + self._opts = {**self._opts, "isHighResolutionCapable": value} @property def log_level(self) -> str: - return self._opts.get('logLevel', 'info') + return self._opts.get("logLevel", "info") @log_level.setter def log_level(self, value: str): - self._opts = { **self._opts, "logLevel": value } + self._opts = {**self._opts, "logLevel": value} @property def loading_text(self) -> str: - return self._opts.get('loadingText', 'waiting for images...') + return self._opts.get("loadingText", "waiting for images...") @loading_text.setter def loading_text(self, value: str): - self._opts = { **self._opts, "loadingText": value } + self._opts = {**self._opts, "loadingText": value} @property def is_force_mouse_click_to_voxel_centers(self) -> bool: - return self._opts.get('isForceMouseClickToVoxelCenters', False) + return self._opts.get("isForceMouseClickToVoxelCenters", False) @is_force_mouse_click_to_voxel_centers.setter def is_force_mouse_click_to_voxel_centers(self, value: bool): - self._opts = { **self._opts, "isForceMouseClickToVoxelCenters": value } + self._opts = {**self._opts, "isForceMouseClickToVoxelCenters": value} @property def drag_and_drop_enabled(self) -> bool: - return self._opts.get('dragAndDropEnabled', True) + return self._opts.get("dragAndDropEnabled", True) @drag_and_drop_enabled.setter def drag_and_drop_enabled(self, value: bool): - self._opts = { **self._opts, "dragAndDropEnabled": value } + self._opts = {**self._opts, "dragAndDropEnabled": value} @property def drawing_enabled(self) -> bool: - return self._opts.get('drawingEnabled', False) + return self._opts.get("drawingEnabled", False) @drawing_enabled.setter def drawing_enabled(self, value: bool): - self._opts = { **self._opts, "drawingEnabled": value } + self._opts = {**self._opts, "drawingEnabled": value} @property def pen_value(self) -> int: - return self._opts.get('penValue', 1) + return self._opts.get("penValue", 1) @pen_value.setter def pen_value(self, value: int): - self._opts = { **self._opts, "penValue": value } + self._opts = {**self._opts, "penValue": value} @property def flood_fill_neighbors(self) -> int: - return self._opts.get('floodFillNeighbors', 6) + return self._opts.get("floodFillNeighbors", 6) @flood_fill_neighbors.setter def flood_fill_neighbors(self, value: int): - self._opts = { **self._opts, "floodFillNeighbors": value } + self._opts = {**self._opts, "floodFillNeighbors": value} @property def is_filled_pen(self) -> bool: - return self._opts.get('isFilledPen', False) + return self._opts.get("isFilledPen", False) @is_filled_pen.setter def is_filled_pen(self, value: bool): - self._opts = { **self._opts, "isFilledPen": value } + self._opts = {**self._opts, "isFilledPen": value} @property def thumbnail(self) -> str: - return self._opts.get('thumbnail', '') + return self._opts.get("thumbnail", "") @thumbnail.setter def thumbnail(self, value: str): - self._opts = { **self._opts, "thumbnail": value } + self._opts = {**self._opts, "thumbnail": value} @property def max_draw_undo_bitmaps(self) -> int: - return self._opts.get('maxDrawUndoBitmaps', 8) + return self._opts.get("maxDrawUndoBitmaps", 8) @max_draw_undo_bitmaps.setter def max_draw_undo_bitmaps(self, value: int): - self._opts = { **self._opts, "maxDrawUndoBitmaps": value } + self._opts = {**self._opts, "maxDrawUndoBitmaps": value} @property def slice_type(self) -> SliceType: - return self._opts.get('sliceType', SliceType.MULTIPLANAR) + return self._opts.get("sliceType", SliceType.MULTIPLANAR) @slice_type.setter def slice_type(self, value: SliceType): - self._opts = { **self._opts, "sliceType": value } + self._opts = {**self._opts, "sliceType": value} @property def mesh_x_ray(self) -> float: - return self._opts.get('meshXRay', 0.0) + return self._opts.get("meshXRay", 0.0) @mesh_x_ray.setter def mesh_x_ray(self, value: float): - self._opts = { **self._opts, "meshXRay": value } + self._opts = {**self._opts, "meshXRay": value} @property def is_anti_alias(self) -> typing.Any: - return self._opts.get('isAntiAlias', None) + return self._opts.get("isAntiAlias", None) @is_anti_alias.setter def is_anti_alias(self, value: typing.Any): - self._opts = { **self._opts, "isAntiAlias": value } + self._opts = {**self._opts, "isAntiAlias": value} @property def limit_frames_4d(self) -> float: - return self._opts.get('limitFrames4D', nan) + return self._opts.get("limitFrames4D", float("nan")) @limit_frames_4d.setter def limit_frames_4d(self, value: float): - self._opts = { **self._opts, "limitFrames4D": value } + self._opts = {**self._opts, "limitFrames4D": value} @property def is_additive_blend(self) -> bool: - return self._opts.get('isAdditiveBlend', False) + return self._opts.get("isAdditiveBlend", False) @is_additive_blend.setter def is_additive_blend(self, value: bool): - self._opts = { **self._opts, "isAdditiveBlend": value } + self._opts = {**self._opts, "isAdditiveBlend": value} @property def show_legend(self) -> bool: - return self._opts.get('showLegend', True) + return self._opts.get("showLegend", True) @show_legend.setter def show_legend(self, value: bool): - self._opts = { **self._opts, "showLegend": value } + self._opts = {**self._opts, "showLegend": value} @property def legend_background_color(self) -> tuple: - return self._opts.get('legendBackgroundColor', (0.3, 0.3, 0.3, 0.5)) + return self._opts.get("legendBackgroundColor", (0.3, 0.3, 0.3, 0.5)) @legend_background_color.setter def legend_background_color(self, value: tuple): - self._opts = { **self._opts, "legendBackgroundColor": value } + self._opts = {**self._opts, "legendBackgroundColor": value} @property def legend_text_color(self) -> tuple: - return self._opts.get('legendTextColor', (1.0, 1.0, 1.0, 1.0)) + return self._opts.get("legendTextColor", (1.0, 1.0, 1.0, 1.0)) @legend_text_color.setter def legend_text_color(self, value: tuple): - self._opts = { **self._opts, "legendTextColor": value } + self._opts = {**self._opts, "legendTextColor": value} @property def multiplanar_layout(self) -> MuliplanarType: - return self._opts.get('multiplanarLayout', MuliplanarType.AUTO) + return self._opts.get("multiplanarLayout", MuliplanarType.AUTO) @multiplanar_layout.setter def multiplanar_layout(self, value: MuliplanarType): - self._opts = { **self._opts, "multiplanarLayout": value } + self._opts = {**self._opts, "multiplanarLayout": value} @property def render_overlay_blend(self) -> float: - return self._opts.get('renderOverlayBlend', 1.0) + return self._opts.get("renderOverlayBlend", 1.0) @render_overlay_blend.setter def render_overlay_blend(self, value: float): - self._opts = { **self._opts, "renderOverlayBlend": value } + self._opts = {**self._opts, "renderOverlayBlend": value} diff --git a/src/ipyniivue/_utils.py b/src/ipyniivue/_utils.py index 1d49346..a0d6dd3 100644 --- a/src/ipyniivue/_utils.py +++ b/src/ipyniivue/_utils.py @@ -1,6 +1,6 @@ -import typing -import pathlib import enum +import pathlib +import typing def snake_to_camel(snake_str: str): diff --git a/src/ipyniivue/_widget.py b/src/ipyniivue/_widget.py index 949bec8..a9e1365 100644 --- a/src/ipyniivue/_widget.py +++ b/src/ipyniivue/_widget.py @@ -4,9 +4,9 @@ import ipywidgets import traitlets as t +from ._constants import _SNAKE_TO_CAMEL_OVERRIDES from ._options_mixin import OptionsMixin from ._utils import file_serializer, serialize_options, snake_to_camel -from ._constants import _SNAKE_TO_CAMEL_OVERRIDES __all__ = ["AnyNiivue"] @@ -23,6 +23,8 @@ class Volume(ipywidgets.Widget): class AnyNiivue(OptionsMixin, anywidget.AnyWidget): + """Represents a Niivue instance.""" + _esm = pathlib.Path(__file__).parent / "static" / "widget.js" _opts = t.Dict({}).tag(sync=True, to_json=serialize_options) _volumes = t.List(t.Instance(Volume), default_value=[]).tag( @@ -31,19 +33,34 @@ class AnyNiivue(OptionsMixin, anywidget.AnyWidget): def __init__(self, **opts): # convert to JS camelCase options - _opts = {_SNAKE_TO_CAMEL_OVERRIDES.get(k, snake_to_camel(k)): v for k, v in opts.items()} + _opts = { + _SNAKE_TO_CAMEL_OVERRIDES.get(k, snake_to_camel(k)): v + for k, v in opts.items() + } super().__init__(_opts=_opts, _volumes=[]) def load_volumes(self, volumes: list): - """Loads a list of volumes into the widget""" + """Load a list of volumes into the widget. + + Parameters + ---------- + volumes : list + A list of dictionaries containing the volume information. + """ volumes = [Volume(**item) for item in volumes] self._volumes = volumes def add_volume(self, volume: dict): - """Adds a single volume to the widget""" - self._volumes = self._volumes + [Volume(**volume)] + """Add a single volume to the widget. + + Parameters + ---------- + volume : dict + A dictionary containing the volume information. + """ + self._volumes = [*self._volumes, Volume(**volume)] @property def volumes(self): - """Returns the list of volumes""" + """Returns the list of volumes.""" return self._volumes diff --git a/tests/test_ipyniivue.py b/tests/test_ipyniivue.py new file mode 100644 index 0000000..7b7b2a2 --- /dev/null +++ b/tests/test_ipyniivue.py @@ -0,0 +1,4 @@ +def test_it_loads(): + import ipyniivue + + assert ipyniivue.__version__ is not None