Skip to content
Open
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
233c590
Support python 3.13
poshul Aug 11, 2025
0e189f1
Update ci.yml
poshul Aug 11, 2025
0e76422
Update static.yml
poshul Aug 11, 2025
7232408
Update execute_notebooks.yml
poshul Aug 11, 2025
71cf474
bump versions
poshul Aug 11, 2025
4345693
update requirements
poshul Aug 11, 2025
c9fe996
Update ci.yml
poshul Aug 11, 2025
4d34f9c
Merge branch 'main' into poshul-patch-1
poshul Nov 3, 2025
0090e51
update snapshots for new bokeh
poshul Nov 3, 2025
7f2db37
add building with 3.14 in CI
poshul Nov 3, 2025
3b607cd
stabilize UUIDs generated during testing to make less brittle
poshul Nov 3, 2025
6bfb3c3
push stabilized uuids
poshul Nov 3, 2025
51abde0
Update test/conftest.py
poshul Nov 3, 2025
b8d95b8
allow small differences between snapshots to handle different platforms
poshul Nov 3, 2025
14be8a8
increase test tolerance
poshul Nov 3, 2025
a040bcf
consistant UUID for a run
poshul Nov 3, 2025
0c8cd37
canonicalize json before doing snapshot comparisons
poshul Nov 3, 2025
927f4ad
fix canonicalization
poshul Nov 3, 2025
0339566
decode base64 arrays in the BokehSnapshotExtension before doing a com…
poshul Nov 3, 2025
6b898ac
apply fix from suggestion
poshul Nov 3, 2025
335fca1
more debugging output on failed test
poshul Nov 3, 2025
8863690
decode bas64 dicts before comparing them.
poshul Nov 3, 2025
fc1b504
sort arrays before comparison
poshul Nov 3, 2025
948d826
handle tuples in plotly snapshot properly.
poshul Nov 3, 2025
ada0ce0
Handle customData
poshul Nov 3, 2025
2067db2
address code review
poshul Nov 3, 2025
5010aa3
Apply suggestion from @coderabbitai[bot]
poshul Nov 3, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .binder/runtime.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
python-3.12
python-3.13
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, windows-latest] # note mac-latest tests fail due to slight differences in images
python-version: ["3.12"]
python-version: ["3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/execute_notebooks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
python-version: '3.13'
cache: 'pip'

- name: Install dependencies
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/static.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:
- name: Set up python
uses: actions/setup-python@v4
with:
python-version: '3.12'
python-version: '3.13'

- name: Install dependencies
run: |
Expand Down
4 changes: 2 additions & 2 deletions .readthedocs.yaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
version: 2
# Set the OS, Python version and other tools you might need
build:
os: ubuntu-22.04
os: ubuntu-24.04
tools:
python: "3.12"
python: "3.13"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would keep testing at 3.12 for now, see above

# Build documentation in the "docs/" directory with Sphinx
sphinx:
configuration: docs/conf.py
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ The recommended way of installing pyopenms_viz is through the Python Package Ind
First create a new environemnt:

```bash
conda create --name=pyopenms_viz python=3.12
conda create --name=pyopenms_viz python=3.13
conda activate pyopenms_viz
```
Then in the new environment install pyopenms_viz.
Expand Down
2 changes: 1 addition & 1 deletion docs/Installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ First create a new environment:

.. code-block:: bash

conda create --name=pyopenms-viz python=3.12
conda create --name=pyopenms-viz python=3.13
conda activate pyopenms-viz

Then in the new environment install pyOpenMS-viz.
Expand Down
191 changes: 155 additions & 36 deletions pyopenms_viz/testing/BokehSnapshotExtension.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from syrupy.types import SerializableData
from bokeh.resources import CDN
from html.parser import HTMLParser
import json as _json
from typing import Tuple


class BokehHTMLParser(HTMLParser):
Expand Down Expand Up @@ -80,61 +82,178 @@ def extract_bokeh_json(self, html: str) -> json:
return json.loads(parser.bokehJson)

@staticmethod
def compare_json(json1, json2):
def compare_json(json1, json2, _ignore_keys=None, path=""):
"""
Compare two bokeh json objects. This function acts recursively
Compare two bokeh json objects recursively, ignoring ephemeral keys.

Args:
json1: first object
json2: second object
_ignore_keys: set of keys to ignore during comparison

Returns:
bool: True if the objects are equal, False otherwise
"""
if _ignore_keys is None:
_ignore_keys = {"id", "root_ids"}

if isinstance(json1, dict) and isinstance(json2, dict):
for key in json1.keys():
if key not in json2:
print(f"Key {key} not in second json")
# Special handling for Bokeh's __ndarray__ format
if '__ndarray__' in json1 and '__ndarray__' in json2:
# This is a serialized numpy array: {"__ndarray__": "base64", "dtype": "...", "shape": [...]}
try:
import base64
import numpy as np

b64_1 = json1['__ndarray__']
b64_2 = json2['__ndarray__']
dtype_1 = json1.get('dtype', 'float64')
dtype_2 = json2.get('dtype', 'float64')

if dtype_1 != dtype_2:
print(f"Dtype mismatch in __ndarray__: {dtype_1} vs {dtype_2}")
return False

arr1 = np.frombuffer(base64.b64decode(b64_1), dtype=np.dtype(dtype_1))
arr2 = np.frombuffer(base64.b64decode(b64_2), dtype=np.dtype(dtype_2))

# For integer arrays, use sorted comparison
if np.issubdtype(arr1.dtype, np.integer):
if not (np.array_equal(arr1, arr2) or np.array_equal(np.sort(arr1), np.sort(arr2))):
print(f"Integer __ndarray__ arrays differ")
return False
else:
# For float arrays, use tolerance
if not np.allclose(arr1, arr2, rtol=1e-6, atol=1e-9):
print(f"Float __ndarray__ arrays differ (tolerance exceeded)")
return False

# Arrays match, skip other keys in this dict
return True
except (ValueError, TypeError, KeyError, base64.binascii.Error) as e:
print(f"Error comparing __ndarray__: {e}")
return False
elif key in ["id", "root_ids"]: # add keys to ignore here
pass
elif not BokehSnapshotExtension.compare_json(json1[key], json2[key]):
print(f"Values for key {key} not equal")

# Get keys excluding ignored ones
keys1 = set(json1.keys()) - _ignore_keys
keys2 = set(json2.keys()) - _ignore_keys

if keys1 != keys2:
print(f"Key mismatch: {keys1 ^ keys2}")
return False

for key in keys1:
new_path = f"{path}.{key}" if path else key
if not BokehSnapshotExtension.compare_json(json1[key], json2[key], _ignore_keys, new_path):
print(f"Values for key '{key}' not equal")
return False
return True

elif isinstance(json1, list) and isinstance(json2, list):
if len(json1) != len(json2):
print("Lists have different lengths")
print(f"List length mismatch: {len(json1)} vs {len(json2)}")
return False
# lists are unordered so we need to compare every element one by one
for idx, i in enumerate(json1):
check = True
if isinstance(i, dict):
if (
"type" not in i.keys()
): # if "type" not present than dictionary with only id, do not need to compare, will get key error if check
check = False
pass
if check: # find corresponding entry in json2 only if check is true
for j in json2:
if (
"type" not in j.keys()
): # if "type" not present than dictionary only has id, do not need to compare, will get key error if check
check = False
if check and (j["type"] == i["type"]):
if not BokehSnapshotExtension.compare_json(i, j):
print(f"Element {i} not equal to {j}")
return False
return True
print(f"Element {i} not in second list")

# If list of simple strings (like annotation labels), sort before comparing
if (len(json1) > 0 and
all(isinstance(i, str) for i in json1) and
all(isinstance(i, str) for i in json2)):
# Sort string lists for deterministic comparison
return sorted(json1) == sorted(json2)

# If list of dicts with 'type' field, sort by type+attributes for deterministic comparison
if (len(json1) > 0 and
all(isinstance(i, dict) for i in json1) and
all(isinstance(i, dict) for i in json2)):

# Normalize attributes by removing ignored keys recursively
def _normalize(value):
if isinstance(value, dict):
return {
k: _normalize(v)
for k, v in value.items()
if k not in _ignore_keys
}
if isinstance(value, list):
return [_normalize(v) for v in value]
return value

# Try to sort by type, name, and complete attribute content
def sort_key(item):
item_type = item.get("type", "")
item_name = item.get("name", "")
attrs = _normalize(item.get("attributes", {}))
attrs_repr = _json.dumps(attrs, sort_keys=True)
return (item_type, item_name, attrs_repr)

try:
sorted1 = sorted(json1, key=sort_key)
sorted2 = sorted(json2, key=sort_key)
except (TypeError, KeyError):
# If sorting fails, compare in order
sorted1, sorted2 = json1, json2

for i, (item1, item2) in enumerate(zip(sorted1, sorted2)):
new_path = f"{path}[{i}]" if path else f"[{i}]"
if not BokehSnapshotExtension.compare_json(item1, item2, _ignore_keys, new_path):
print(f"List item {i} differs")
return False
else:
return json1[idx] == json2[idx]
return True
return True
else:
# For non-dict lists, compare element by element
for i, (item1, item2) in enumerate(zip(json1, json2)):
new_path = f"{path}[{i}]" if path else f"[{i}]"
if not BokehSnapshotExtension.compare_json(item1, item2, _ignore_keys, new_path):
print(f"List element {i} differs")
return False
return True

else:
# Base case: direct comparison
# Special handling for base64 strings (likely index arrays)
if isinstance(json1, str) and isinstance(json2, str):
# Check if these look like base64 (all printable ASCII, ends with = potentially)
if len(json1) > 50 and len(json2) > 50 and all(c in 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=' for c in json1[:100]):
# Try to decode as numpy arrays and compare
try:
import base64
import numpy as np

# Decode raw bytes first
raw1 = base64.b64decode(json1)
raw2 = base64.b64decode(json2)

# Try interpreting as int32 but require exact (order-sensitive) equality
try:
arr1 = np.frombuffer(raw1, dtype=np.int32)
arr2 = np.frombuffer(raw2, dtype=np.int32)
if np.array_equal(arr1, arr2):
return True
except Exception:
pass

# Try interpreting as float64 with tolerance (order-sensitive)
try:
arr1f = np.frombuffer(raw1, dtype=np.float64)
arr2f = np.frombuffer(raw2, dtype=np.float64)
if np.allclose(arr1f, arr2f, rtol=1e-6, atol=1e-9):
return True
except Exception:
pass

# NOTE: We intentionally do NOT perform an order-insensitive (sorted)
# comparison here for arbitrary base64 strings. The sorted comparison
# is only allowed when we can prove the payload is an index set
# (for example when the surrounding key path is 'selected.indices'
# and a declared dtype indicates an integer type). Plain base64
# strings without such context must be treated as order-sensitive.
except (ValueError, TypeError, base64.binascii.Error):
pass # Not base64 or not decodable, fall through to string comparison

if json1 != json2:
print(f"Values not equal: {json1} != {json2}")
return json1 == json2
print(f"Values differ: {json1} != {json2}")
return False
return True

def _read_snapshot_data_from_location(
self, *, snapshot_location: str, snapshot_name: str, session_id: str
Expand Down
17 changes: 16 additions & 1 deletion pyopenms_viz/testing/MatplotlibSnapshotExtension.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,27 @@ def matches(self, *, serialized_data, snapshot_data):
serialized_image_array = np.array(serialized_data)
snapshot_image_array = np.array(snapshot_data)

# Allow small differences due to platform-specific rendering
# Calculate the percentage of different pixels
diff = np.where(
serialized_image_array != snapshot_image_array
) # get locations where different, get a tuple of 3 arrays corresponding with the x, y, and channel of the image

# if one of these arrays is 0 than all are 0 and images are equal
return len(diff[0]) == 0 # if there are no differences, return True
if len(diff[0]) == 0:
return True

# Allow small percentage of pixels to be different (for antialiasing/font rendering differences)
total_pixels = serialized_image_array.size
different_pixels = len(diff[0])
diff_percentage = (different_pixels / total_pixels) * 100

# Print difference for debugging (will show in test output if fails)
if diff_percentage > 0:
print(f"\nImage difference: {diff_percentage:.4f}% of pixels differ ({different_pixels}/{total_pixels})")

# Allow up to 1% difference to account for platform differences in font rendering
return diff_percentage < 1.0

def _read_snapshot_data_from_location(
self, *, snapshot_location: str, snapshot_name: str, session_id: str
Expand Down
Loading
Loading