Skip to content

Commit

Permalink
ci: Setup CI testing, linting, and formatting (#56)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
manzt authored Apr 19, 2024
1 parent 32e8247 commit 8625109
Show file tree
Hide file tree
Showing 10 changed files with 344 additions and 215 deletions.
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
51 changes: 42 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
40 changes: 35 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"]
141 changes: 72 additions & 69 deletions scripts/generate_options_mixin.py
Original file line number Diff line number Diff line change
@@ -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()}


Expand Down Expand Up @@ -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",
Expand All @@ -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)
11 changes: 5 additions & 6 deletions src/ipyniivue/__init__.py
Original file line number Diff line number Diff line change
@@ -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")
Loading

0 comments on commit 8625109

Please sign in to comment.