Skip to content

Commit ca64ad3

Browse files
authored
Merge branch 'main' into feat/ridgeline-histtype
2 parents 3b0e57d + e92fe33 commit ca64ad3

6 files changed

Lines changed: 111 additions & 31 deletions

File tree

.github/workflows/build-ultraplot.yml

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,22 @@ jobs:
135135
echo "TEST_NODEIDS=${TEST_NODEIDS}"
136136
# Save PR-selected nodeids for reuse after checkout (if provided)
137137
if [ "${TEST_MODE}" = "selected" ] && [ -n "${TEST_NODEIDS}" ]; then
138-
printf "%s\n" ${TEST_NODEIDS} > /tmp/pr_selected_nodeids.txt
138+
python -c 'import json, os
139+
raw = os.environ.get("TEST_NODEIDS", "").strip()
140+
nodeids = []
141+
if raw and raw != "[]":
142+
try:
143+
parsed = json.loads(raw)
144+
except json.JSONDecodeError:
145+
parsed = raw.split()
146+
if isinstance(parsed, str):
147+
parsed = [parsed]
148+
if isinstance(parsed, list):
149+
nodeids = [item for item in parsed if isinstance(item, str) and item]
150+
with open("/tmp/pr_selected_nodeids.txt", "w", encoding="utf-8") as fh:
151+
for nodeid in nodeids:
152+
fh.write(f"{nodeid}\n")
153+
print(f"Selected nodeids parsed: {len(nodeids)}")'
139154
else
140155
: > /tmp/pr_selected_nodeids.txt
141156
fi
@@ -152,26 +167,22 @@ jobs:
152167
python -c "import ultraplot as plt; plt.config.Configurator()._save_yaml('ultraplot.yml')"
153168
if [ "${TEST_MODE}" = "selected" ] && [ -s /tmp/pr_selected_nodeids.txt ]; then
154169
status=0
155-
filter_nodeids() {
156-
local filtered=""
157-
for nodeid in $(cat /tmp/pr_selected_nodeids.txt); do
158-
local path="${nodeid%%::*}"
159-
if [ -f "$path" ]; then
160-
filtered="${filtered} ${nodeid}"
161-
fi
162-
done
163-
echo "${filtered}"
164-
}
165-
FILTERED_NODEIDS="$(filter_nodeids)"
166-
echo "FILTERED_NODEIDS_BASE=${FILTERED_NODEIDS}"
167-
if [ -z "${FILTERED_NODEIDS}" ]; then
170+
mapfile -t FILTERED_NODEIDS < <(
171+
while IFS= read -r nodeid; do
172+
[ -z "$nodeid" ] && continue
173+
path="${nodeid%%::*}"
174+
[ -f "$path" ] && printf '%s\n' "$nodeid"
175+
done < /tmp/pr_selected_nodeids.txt
176+
)
177+
echo "FILTERED_NODEIDS_BASE_COUNT=${#FILTERED_NODEIDS[@]}"
178+
if [ "${#FILTERED_NODEIDS[@]}" -eq 0 ]; then
168179
echo "No valid nodeids found on base; skipping baseline generation."
169180
else
170181
echo "=== Memory before baseline generation ===" && free -h
171182
pytest -n ${PYTEST_WORKERS} --dist loadfile --tb=short --disable-warnings -W ignore \
172183
--mpl-generate-path=./ultraplot/tests/baseline/ \
173184
--mpl-default-style="./ultraplot.yml" \
174-
${FILTERED_NODEIDS} || status=$?
185+
"${FILTERED_NODEIDS[@]}" || status=$?
175186
echo "=== Memory after baseline generation ===" && free -h
176187
if [ "$status" -eq 4 ] || [ "$status" -eq 5 ]; then
177188
echo "No tests collected from selected nodeids on base; skipping baseline generation."
@@ -213,19 +224,15 @@ jobs:
213224
echo "TEST_NODEIDS=${TEST_NODEIDS}"
214225
if [ "${TEST_MODE}" = "selected" ] && [ -s /tmp/pr_selected_nodeids.txt ]; then
215226
status=0
216-
filter_nodeids() {
217-
local filtered=""
218-
for nodeid in $(cat /tmp/pr_selected_nodeids.txt); do
219-
local path="${nodeid%%::*}"
220-
if [ -f "$path" ]; then
221-
filtered="${filtered} ${nodeid}"
222-
fi
223-
done
224-
echo "${filtered}"
225-
}
226-
FILTERED_NODEIDS="$(filter_nodeids)"
227-
echo "FILTERED_NODEIDS_PR=${FILTERED_NODEIDS}"
228-
if [ -z "${FILTERED_NODEIDS}" ]; then
227+
mapfile -t FILTERED_NODEIDS < <(
228+
while IFS= read -r nodeid; do
229+
[ -z "$nodeid" ] && continue
230+
path="${nodeid%%::*}"
231+
[ -f "$path" ] && printf '%s\n' "$nodeid"
232+
done < /tmp/pr_selected_nodeids.txt
233+
)
234+
echo "FILTERED_NODEIDS_PR_COUNT=${#FILTERED_NODEIDS[@]}"
235+
if [ "${#FILTERED_NODEIDS[@]}" -eq 0 ]; then
229236
echo "No valid nodeids found on PR branch; skipping image comparison."
230237
exit 0
231238
else
@@ -236,7 +243,7 @@ jobs:
236243
--mpl-results-path=./results/ \
237244
--mpl-generate-summary=html \
238245
--mpl-default-style="./ultraplot.yml" \
239-
${FILTERED_NODEIDS} || status=$?
246+
"${FILTERED_NODEIDS[@]}" || status=$?
240247
echo "=== Memory after image comparison ===" && free -h
241248
if [ "$status" -eq 4 ] || [ "$status" -eq 5 ]; then
242249
echo "No tests collected from selected nodeids; skipping image comparison."

.github/workflows/main.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ jobs:
7474
run: |
7575
if [ "${{ github.event_name }}" != "pull_request" ]; then
7676
echo "mode=full" >> $GITHUB_OUTPUT
77-
echo "tests=" >> $GITHUB_OUTPUT
77+
echo "tests=[]" >> $GITHUB_OUTPUT
7878
exit 0
7979
fi
8080
@@ -104,7 +104,7 @@ jobs:
104104
import json
105105
data = json.load(open(".ci/selection.json", "r", encoding="utf-8"))
106106
print(f"mode={data['mode']}")
107-
print("tests=" + " ".join(data.get("tests", [])))
107+
print("tests=" + json.dumps(data.get("tests", []), separators=(",", ":")))
108108
PY
109109
cat .ci/selection.out >> $GITHUB_OUTPUT
110110

ultraplot/figure.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,7 @@ def __init__(
869869

870870
@override
871871
def draw(self, renderer):
872+
self._snap_axes_to_pixel_grid(renderer)
872873
# implement the tick sharing here
873874
# should be shareable --> either all cartesian or all geographic
874875
# but no mixing (panels can be mixed)
@@ -880,6 +881,53 @@ def draw(self, renderer):
880881
self._apply_share_label_groups()
881882
super().draw(renderer)
882883

884+
def _snap_axes_to_pixel_grid(self, renderer) -> None:
885+
"""
886+
Snap visible axes bounds to the renderer pixel grid.
887+
"""
888+
if not rc.find("subplots.pixelsnap", context=True):
889+
return
890+
891+
width = getattr(renderer, "width", None)
892+
height = getattr(renderer, "height", None)
893+
if not width or not height:
894+
return
895+
896+
width = float(width)
897+
height = float(height)
898+
if width <= 0 or height <= 0:
899+
return
900+
901+
invw = 1.0 / width
902+
invh = 1.0 / height
903+
minw = invw
904+
minh = invh
905+
906+
for ax in self._iter_axes(hidden=False, children=False, panels=True):
907+
bbox = ax.get_position(original=False)
908+
old = np.array([bbox.x0, bbox.y0, bbox.x1, bbox.y1], dtype=float)
909+
new = np.array(
910+
[
911+
round(old[0] * width) * invw,
912+
round(old[1] * height) * invh,
913+
round(old[2] * width) * invw,
914+
round(old[3] * height) * invh,
915+
],
916+
dtype=float,
917+
)
918+
919+
if new[2] <= new[0]:
920+
new[2] = new[0] + minw
921+
if new[3] <= new[1]:
922+
new[3] = new[1] + minh
923+
924+
if np.allclose(new, old, rtol=0.0, atol=1e-12):
925+
continue
926+
ax.set_position(
927+
[new[0], new[1], new[2] - new[0], new[3] - new[1]],
928+
which="both",
929+
)
930+
883931
def _share_ticklabels(self, *, axis: str) -> None:
884932
"""
885933
Tick label sharing is determined at the figure level. While

ultraplot/internals/rcsetup.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2099,6 +2099,11 @@ def copy(self):
20992099
_validate_bool,
21002100
"Whether to auto-adjust the subplot spaces and figure margins.",
21012101
),
2102+
"subplots.pixelsnap": (
2103+
False,
2104+
_validate_bool,
2105+
"Whether to snap subplot bounds to the renderer pixel grid during draw.",
2106+
),
21022107
# Super title settings
21032108
"suptitle.color": (BLACK, _validate_color, "Figure title color."),
21042109
"suptitle.pad": (

ultraplot/tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ def close_figures_after_test(request):
3737
# Start from a clean rc state.
3838
uplt.rc._context.clear()
3939
uplt.rc.reset(local=False, user=False, default=True)
40+
if request.node.get_closest_marker("mpl_image_compare"):
41+
uplt.rc["subplots.pixelsnap"] = True
4042

4143
yield
4244
uplt.close("all")

ultraplot/tests/test_figure.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,3 +297,21 @@ def test_suptitle_kw_position_reverted(ha, expectation):
297297
assert np.isclose(x, expectation, atol=0.1), f"Expected x={expectation}, got {x=}"
298298

299299
uplt.close("all")
300+
301+
302+
def test_subplots_pixelsnap_aligns_axes_bounds():
303+
with uplt.rc.context({"subplots.pixelsnap": True}):
304+
fig, axs = uplt.subplots(ncols=2, nrows=2)
305+
axs.plot([0, 1], [0, 1])
306+
fig.canvas.draw()
307+
308+
renderer = fig._get_renderer()
309+
width = float(renderer.width)
310+
height = float(renderer.height)
311+
312+
for ax in axs:
313+
bbox = ax.get_position(original=False)
314+
coords = np.array(
315+
[bbox.x0 * width, bbox.y0 * height, bbox.x1 * width, bbox.y1 * height]
316+
)
317+
assert np.allclose(coords, np.round(coords), atol=1e-8)

0 commit comments

Comments
 (0)