diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b9bc920 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + checks: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.12" + - "3.13" + - "3.14" + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install uv and python + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Install the project + run: uv sync --locked --dev --extra analysis + + - name: Check formatting + run: uv run ruff format --check + + - name: Lint + run: uv run ruff check + + - name: Type check + run: uv run pyrefly check + + - name: Test + run: uv run pytest -v diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a5c6bfd --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,74 @@ +name: Publish to PyPI + +on: + push: + tags: + - "v*" + +concurrency: + group: pypi-publish + cancel-in-progress: false + +jobs: + build: + name: Build distributions + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + enable-cache: false + + - name: Verify tag matches package version + shell: bash + run: | + tag_version="${GITHUB_REF_NAME#v}" + project_version="$(uv version --short)" + if [[ "$tag_version" != "$project_version" ]]; then + echo "Tag version $tag_version does not match package version $project_version" >&2 + exit 1 + fi + + - name: Build distributions + run: uv build + + - name: Smoke test wheel + run: uv run --isolated --no-project --with dist/*.whl python -c "import routing_pyramids" + + - name: Smoke test source distribution + run: uv run --isolated --no-project --with dist/*.tar.gz python -c "import routing_pyramids" + + - name: Upload distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: distributions + path: dist/ + if-no-files-found: error + + publish: + name: Publish distributions + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/routing-pyramids/ + permissions: + id-token: write + + steps: + - name: Download distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: distributions + path: dist/ + + - name: Publish distributions to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e56a468..9880899 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,3 +10,19 @@ uv run pyrefly check # test uv run pytest ``` + +## Releasing + +Publishing uses PyPI trusted publishing with GitHub Actions. +For each release, update and commit the version and lockfile: + +```sh +uv version 0.2.0 +git add pyproject.toml uv.lock +git commit -m "release 0.2.0" +``` + +Wait for CI to pass on the version commit, +then create and push the matching version tag. +The tag must be the package version prefixed with `v`. +Pushing it starts the publishing [workflow](.github/workflows/publish.yml). diff --git a/examples/visualize_bbbc013_latents.py b/examples/visualize_bbbc013_latents.py index dd44505..ba49487 100644 --- a/examples/visualize_bbbc013_latents.py +++ b/examples/visualize_bbbc013_latents.py @@ -3,13 +3,11 @@ from dataclasses import dataclass from pathlib import Path -import betterplots import matplotlib.pyplot as plt import numpy as np import pandas as pd import seaborn as sns import torch -from betterplots.boxstripplot import boxstripplot from cmap import Colormap as CmapColormap from matplotlib.axes import Axes from matplotlib.cm import ScalarMappable @@ -24,6 +22,8 @@ from torch.utils.data import DataLoader from umap import UMAP +from routing_pyramids._vendor.betterplots import set_style +from routing_pyramids._vendor.betterplots.boxstripplot import boxstripplot from routing_pyramids.data.temporal_datamodule import BBBC013VideoDataModule from routing_pyramids.pyramid_flow_system import ( PyramidFlowDecoder2d, @@ -77,7 +77,7 @@ device = torch.device("cpu" if FORCE_CPU or not torch.cuda.is_available() else "cuda") print(f"Using device: {device}") -betterplots.set_style( +set_style( usetex=False, serif=True, font_size=8, diff --git a/examples/visualize_flow_aics_object_posterior.py b/examples/visualize_flow_aics_object_posterior.py index f1d4c08..39810ce 100644 --- a/examples/visualize_flow_aics_object_posterior.py +++ b/examples/visualize_flow_aics_object_posterior.py @@ -5,11 +5,11 @@ import matplotlib.pyplot as plt import numpy as np import torch -from betterplots import set_style from matplotlib.colors import hsv_to_rgb from torch import Tensor from torch.nn import functional as F +from routing_pyramids._vendor.betterplots import set_style from routing_pyramids.data.temporal_video_dataset import OMEZarrVideoDataset from routing_pyramids.data.transforms import get_normalization, get_transforms from routing_pyramids.pyramid_flow_system import ( diff --git a/pyproject.toml b/pyproject.toml index 9f22d84..bb1f02b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ analysis = [ "seaborn>=0.13.2", "scikit-learn>=1.9.0", "umap-learn>=0.5.12", - "betterplots @ git+https://github.com/maweigert/betterplots@3a2c3bd7646290c5748e463553f33368ea8253bd", ] [build-system] diff --git a/src/routing_pyramids/_vendor/__init__.py b/src/routing_pyramids/_vendor/__init__.py new file mode 100644 index 0000000..8752099 --- /dev/null +++ b/src/routing_pyramids/_vendor/__init__.py @@ -0,0 +1 @@ +"""Private vendored dependencies.""" diff --git a/src/routing_pyramids/_vendor/betterplots/__init__.py b/src/routing_pyramids/_vendor/betterplots/__init__.py new file mode 100644 index 0000000..aee1baf --- /dev/null +++ b/src/routing_pyramids/_vendor/betterplots/__init__.py @@ -0,0 +1,76 @@ +# Vendored from https://github.com/maweigert/betterplots +# 3a2c3bd7646290c5748e463553f33368ea8253bd. +# Copyright Martin Weigert (BSD-3-Clause). + +from typing import Literal + +from cycler import cycler + +PALETTES = { + "mw": ["#4B6584", "#7A6FAF", "#B99B52", "#5F8F7A", "#A45A6A", "#5B8FA8"], + "tab10": [ + "#1f77b4", + "#ff7f0e", + "#2ca02c", + "#d62728", + "#9467bd", + "#8c564b", + "#e377c2", + "#7f7f7f", + "#bcbd22", + "#17becf", + ], +} + + +def set_style( + usetex=False, + serif=True, + font_size=12, + legend_font_size=10, + label_size=10, + tick_size=10, + colors: Literal["mw", "tab10"] | None = "tab10", +): + import matplotlib as mpl + + mpl.rc("text", usetex=usetex) + # mpl.rc('font',family='serif') + # mpl.rc('font',serif=['Palatino']) + mpl.rcParams["font.family"] = "serif" if serif else "sans-serif" + mpl.rcParams["font.serif"] = ["Palatino"] + mpl.rcParams["font.sans-serif"] = [ + "Inter 18pt", + "Myriad Pro", + "Neue Haas Grotesk Display Pro", + ] + mpl.rc("font", size=font_size) + mpl.rc("legend", fontsize=legend_font_size) + mpl.rc("axes", labelsize=label_size) + mpl.rc("xtick", labelsize=tick_size) + mpl.rc("ytick", labelsize=tick_size) + mpl.rcParams["axes.titleweight"] = "semibold" + + if colors is None: + colors = "tab10" + if colors not in PALETTES: + raise ValueError( + f"colors must be one of {list(PALETTES)} or None, got {colors!r}" + ) + mpl.rcParams["axes.prop_cycle"] = cycler(color=PALETTES[colors]) + + if usetex: + mpl.rcParams["text.latex.preamble"] = r"\usepackage{mathtools,amssymb,bm}" + print( + "in case latex is not working, make sue that the following packages are installed:" + ) + print( + "sudo apt-get install dvipng texlive-latex-extra texlive-fonts-recommended cm-super" + ) + + # xtick.major.size : 5 + # xtick.minor.size : 3 + # ytick.major.size : 5 + # ytick.minor.size : 3 + # axes.linewidth : 0.8 + # legend.handlelength : 2.0 diff --git a/src/routing_pyramids/_vendor/betterplots/boxstripplot.py b/src/routing_pyramids/_vendor/betterplots/boxstripplot.py new file mode 100644 index 0000000..9bb1309 --- /dev/null +++ b/src/routing_pyramids/_vendor/betterplots/boxstripplot.py @@ -0,0 +1,243 @@ +# Vendored from https://github.com/maweigert/betterplots +# 3a2c3bd7646290c5748e463553f33368ea8253bd. +# Copyright Martin Weigert (BSD-3-Clause). + +import warnings + +import matplotlib.pyplot as plt +import seaborn as sns +from seaborn.categorical import ( + _CategoricalPlotter, # type: ignore[missing-module-attribute] +) +from seaborn.utils import _default_color # type: ignore[missing-module-attribute] + + +# patched version including `width`` parameter +class _MyCategoricalPlotter(_CategoricalPlotter): + def __init__(self, width, **kwargs): + super().__init__(**kwargs) + self._width = width + + @property + def _native_width(self): + return self._width / 0.8 + + +# patched version to allow for hue offset +def mystripplot( + width=0.1, + data=None, + *, + x=None, + y=None, + hue=None, + order=None, + hue_order=None, + dodge=False, + orient=None, + color=None, + palette=None, + size=5, + edgecolor="none", + linewidth=0, + hue_norm=None, + log_scale=None, + native_scale=False, + formatter=None, + legend="auto", + ax=None, + **kwargs, +): + + p = _MyCategoricalPlotter( + width=width, + data=data, + variables={"x": x, "y": y, "hue": hue}, + order=order, + orient=orient, + color=color, + legend=legend, + ) + + if ax is None: + ax = plt.gca() + + if p.plot_data.empty: + return ax + + if p.var_types.get(p.orient) == "categorical" or not native_scale: + p.scale_categorical(p.orient, order=order, formatter=formatter) + + p._attach(ax, log_scale=log_scale) + + # Deprecations to remove in v0.14.0. + hue_order = p._palette_without_hue_backcompat(palette, hue_order) + palette, hue_order = p._hue_backcompat(color, palette, hue_order) + + p.map_hue(palette=palette, order=hue_order, norm=hue_norm) + color = _default_color(ax.scatter, hue, color, kwargs) + + if edgecolor is None: + edgecolor = p._complement_color(edgecolor, color, p._hue_map) + + kwargs.setdefault("zorder", 3) + size = kwargs.get("s", size) + + kwargs.update( + s=size**2, + edgecolor=edgecolor, + linewidth=linewidth, + ) + + p.plot_strips( + jitter=0.5 * width, + dodge=dodge, + color=color, + plot_kws=kwargs, + ) + + # XXX this happens inside a plotting method in the distribution plots + # but maybe it's better out here? Alternatively, we have an open issue + # suggesting that _attach could add default axes labels, which seems smart. + p._add_axis_labels(ax) + p._adjust_cat_axis(ax, axis=p.orient) + + +def boxstripplot( + x=None, + y=None, + data=None, + width=0.2, + size=2, + box_alpha=0.9, + strip_alpha=0.5, + linewidth=0.5, + fliersize=0, + showmeans=False, + hue=None, + order=None, + hue_order=None, + orient=None, + color=None, + palette=None, + ax=None, + box_kwargs=None, + strip_kwargs=None, + violin=False, + saturation=None, +): + """ + plots a half-box plot with a regular boxplot and a stripplot next to each other + + parameters are like seaborn.boxplot + + Example: + ======== + + import numpy as np + import pandas as pd + + x = np.random.uniform(0, 1, 100) + y = np.random.uniform(.5, 1.2, 100) + + df = pd.DataFrame(dict(x=x, y=y)) + + halfboxstripplot(data=df, width=.5, + strip_kwargs=dict(alpha=0.5)) + + """ + if ax is None: + ax = plt.gca() + + if palette is None: + palette = sns.color_palette("tab10") + + if box_kwargs is None: + box_kwargs = {} + + if strip_kwargs is None: + strip_kwargs = {} + + box_kwargs.setdefault("boxprops", {}) + box_kwargs.setdefault("whiskerprops", {}) + box_kwargs.setdefault("capprops", {}) + box_kwargs.setdefault( + "meanprops", + { + "marker": "o", + "markerfacecolor": "none", + "markeredgecolor": "black", + "markersize": "5", + }, + ) + box_kwargs.setdefault( + "medianprops", + { + "color": "black", + "alpha": 0.35, + }, + ) + + box_kwargs["boxprops"].setdefault("alpha", box_alpha) + box_kwargs["whiskerprops"].setdefault("alpha", box_alpha) + box_kwargs["capprops"].setdefault("alpha", box_alpha) + if saturation is not None: + box_kwargs["saturation"] = saturation + + strip_kwargs.setdefault("alpha", strip_alpha) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + if not violin: + ax = sns.boxplot( + data=data, + x=x, + y=y, + width=width, + hue=hue, + order=order, + showmeans=showmeans, + hue_order=hue_order, + orient=orient, + color=color, + palette=palette, + fliersize=fliersize, + ax=ax, + **box_kwargs, + ) + else: + ax = sns.violinplot( + data=data, + x=x, + y=y, + width=width, + hue=hue, + order=order, + showmeans=showmeans, + hue_order=hue_order, + orient=orient, + color=color, + palette=palette, + fliersize=fliersize, + ax=ax, + **box_kwargs, + ) + ax = mystripplot( + data=data, + x=x, + y=y, + width=width, + size=size, + hue=hue, + dodge=hue is not None, + order=order, + hue_order=hue_order, + orient=orient, + color=color, + palette=palette, + linewidth=linewidth, + ax=ax, + legend=None, + **strip_kwargs, + ) + return ax diff --git a/uv.lock b/uv.lock index 9928216..a05b249 100644 --- a/uv.lock +++ b/uv.lock @@ -150,15 +150,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "betterplots" -version = "0.1.0" -source = { git = "https://github.com/maweigert/betterplots?rev=3a2c3bd7646290c5748e463553f33368ea8253bd#3a2c3bd7646290c5748e463553f33368ea8253bd" } -dependencies = [ - { name = "numpy" }, - { name = "seaborn" }, -] - [[package]] name = "cmap" version = "0.7.2" @@ -1743,7 +1734,6 @@ dependencies = [ [package.optional-dependencies] analysis = [ - { name = "betterplots" }, { name = "matplotlib" }, { name = "pandas" }, { name = "scikit-learn" }, @@ -1760,7 +1750,6 @@ dev = [ [package.metadata] requires-dist = [ - { name = "betterplots", marker = "extra == 'analysis'", git = "https://github.com/maweigert/betterplots?rev=3a2c3bd7646290c5748e463553f33368ea8253bd" }, { name = "cmap", specifier = ">=0.7.2" }, { name = "einops", specifier = ">=0.8.1" }, { name = "lightning" },