From 8da13336dfa60c2b4412097bc5e58488aebbb66c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 19:28:07 +0200 Subject: [PATCH 1/6] chore(deps): bump the actions group across 1 directory with 8 updates (#176) Bumps the actions group with 8 updates in the / directory: | Package | From | To | | --- | --- | --- | | [prefix-dev/setup-pixi](https://github.com/prefix-dev/setup-pixi) | `0.9.5` | `0.9.6` | | [codecov/codecov-action](https://github.com/codecov/codecov-action) | `6.0.0` | `6.0.1` | | [github/issue-metrics](https://github.com/github/issue-metrics) | `4.2.2` | `4.2.7` | | [j178/prek-action](https://github.com/j178/prek-action) | `2.0.3` | `2.0.4` | | [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.0` | `7.0.1` | | [actions/download-artifact](https://github.com/actions/download-artifact) | `7.0.0` | `8.0.1` | | [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) | `1.13.0` | `1.14.0` | | [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) | `0.5.3` | `0.5.6` | Updates `prefix-dev/setup-pixi` from 0.9.5 to 0.9.6 - [Release notes](https://github.com/prefix-dev/setup-pixi/releases) - [Commits](https://github.com/prefix-dev/setup-pixi/compare/1b2de7f3351f171c8b4dfeb558c639cb58ed4ec0...5185adfbffb4bd703da3010310260805d89ebb11) Updates `codecov/codecov-action` from 6.0.0 to 6.0.1 - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/57e3a136b779b570ffcdbf80b3bdc90e7fab3de2...e79a6962e0d4c0c17b229090214935d2e33f8354) Updates `github/issue-metrics` from 4.2.2 to 4.2.7 - [Release notes](https://github.com/github/issue-metrics/releases) - [Commits](https://github.com/github/issue-metrics/compare/c9e9838147fd355dace335ba787f01b6641a400a...1e38d5e62363e14db8019ed7d106b9855bdba6cc) Updates `j178/prek-action` from 2.0.3 to 2.0.4 - [Release notes](https://github.com/j178/prek-action/releases) - [Commits](https://github.com/j178/prek-action/compare/6ad80277337ad479fe43bd70701c3f7f8aa74db3...bdca6f102f98e2b4c7029491a53dfd366469e33d) Updates `actions/upload-artifact` from 7.0.0 to 7.0.1 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v7...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a) Updates `actions/download-artifact` from 7.0.0 to 8.0.1 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v7...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c) Updates `pypa/gh-action-pypi-publish` from 1.13.0 to 1.14.0 - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.13.0...cef221092ed1bacb1cc03d23a2d87d1d172e277b) Updates `zizmorcore/zizmor-action` from 0.5.3 to 0.5.6 - [Release notes](https://github.com/zizmorcore/zizmor-action/releases) - [Commits](https://github.com/zizmorcore/zizmor-action/compare/b1d7e1fb5de872772f31590499237e7cce841e8e...5f14fd08f7cf1cb1609c1e344975f152c7ee938d) --- updated-dependencies: - dependency-name: prefix-dev/setup-pixi dependency-version: 0.9.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: codecov/codecov-action dependency-version: 6.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: github/issue-metrics dependency-version: 4.2.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: j178/prek-action dependency-version: 2.0.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: actions/upload-artifact dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: actions/download-artifact dependency-version: 8.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: pypa/gh-action-pypi-publish dependency-version: 1.14.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: zizmorcore/zizmor-action dependency-version: 0.5.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/downstream.yml | 2 +- .github/workflows/gpu_test.yml | 2 +- .github/workflows/hypothesis.yaml | 2 +- .github/workflows/issue-metrics.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 4 ++-- .github/workflows/zarr-metadata-release.yml | 12 ++++++------ .github/workflows/zizmor.yml | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/downstream.yml b/.github/workflows/downstream.yml index 74026233c4..3eb6898895 100644 --- a/.github/workflows/downstream.yml +++ b/.github/workflows/downstream.yml @@ -34,7 +34,7 @@ jobs: persist-credentials: false - name: Set up pixi - uses: prefix-dev/setup-pixi@1b2de7f3351f171c8b4dfeb558c639cb58ed4ec0 # v0.9.5 + uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6 with: manifest-path: xarray/pixi.toml diff --git a/.github/workflows/gpu_test.yml b/.github/workflows/gpu_test.yml index 403441b306..333769cb9e 100644 --- a/.github/workflows/gpu_test.yml +++ b/.github/workflows/gpu_test.yml @@ -76,7 +76,7 @@ jobs: hatch env run --env "$HATCH_ENV" run-coverage - name: Upload coverage - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: token: ${{ secrets.CODECOV_TOKEN }} flags: gpu diff --git a/.github/workflows/hypothesis.yaml b/.github/workflows/hypothesis.yaml index 4f9467be7d..a456b2aa0a 100644 --- a/.github/workflows/hypothesis.yaml +++ b/.github/workflows/hypothesis.yaml @@ -93,7 +93,7 @@ jobs: key: cache-hypothesis-${{ runner.os }}-${{ github.run_id }} - name: Upload coverage - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: token: ${{ secrets.CODECOV_TOKEN }} flags: tests diff --git a/.github/workflows/issue-metrics.yml b/.github/workflows/issue-metrics.yml index 14fba5b9ec..510849ef3e 100644 --- a/.github/workflows/issue-metrics.yml +++ b/.github/workflows/issue-metrics.yml @@ -33,7 +33,7 @@ jobs: echo "last_month=$first_day..$last_day" >> "$GITHUB_ENV" - name: Run issue-metrics tool - uses: github/issue-metrics@c9e9838147fd355dace335ba787f01b6641a400a # v4.2.2 + uses: github/issue-metrics@1e38d5e62363e14db8019ed7d106b9855bdba6cc # v4.2.7 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SEARCH_QUERY: 'repo:zarr-developers/zarr-python is:issue created:${{ env.last_month }} -reason:"not planned"' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 768e660ec2..fec211b4dd 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -30,4 +30,4 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true - - uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3 + - uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 03143d3e5b..62e571856b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,7 +78,7 @@ jobs: hatch env run --env "$HATCH_ENV" run-coverage - name: Upload coverage if: ${{ matrix.dependency-set == 'optional' && matrix.os == 'ubuntu-latest' }} - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: token: ${{ secrets.CODECOV_TOKEN }} flags: tests @@ -125,7 +125,7 @@ jobs: run: | hatch env run --env "$HATCH_ENV" run-coverage - name: Upload coverage - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: token: ${{ secrets.CODECOV_TOKEN }} flags: tests diff --git a/.github/workflows/zarr-metadata-release.yml b/.github/workflows/zarr-metadata-release.yml index 809d502f16..9639fcfdd3 100644 --- a/.github/workflows/zarr-metadata-release.yml +++ b/.github/workflows/zarr-metadata-release.yml @@ -35,7 +35,7 @@ jobs: - name: Build run: hatch build - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: zarr-metadata-dist path: packages/zarr-metadata/dist @@ -45,7 +45,7 @@ jobs: needs: [build] runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: zarr-metadata-dist path: dist @@ -76,7 +76,7 @@ jobs: id-token: write # required for OIDC trusted publishing attestations: write # required for artifact attestations steps: - - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: zarr-metadata-dist path: dist @@ -87,7 +87,7 @@ jobs: subject-path: dist/* - name: Publish package to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 upload_testpypi: name: Upload to TestPyPI @@ -101,7 +101,7 @@ jobs: id-token: write attestations: write steps: - - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: zarr-metadata-dist path: dist @@ -112,6 +112,6 @@ jobs: subject-path: dist/* - name: Publish package to TestPyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index da19f22421..7ac4fe5d0e 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -32,4 +32,4 @@ jobs: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3 + uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 From d889d086e1d506e040b6d6b3d3695f255a7b46a9 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Fri, 10 Jul 2026 10:13:25 +0200 Subject: [PATCH 2/6] test: bind moto server to an ephemeral port to fix xdist deadlock The session-scoped moto_server fixture bound a fixed port (5555). Under pytest-xdist, session-scoped fixtures run once per worker, so concurrent workers raced to bind the same port. The losers blocked forever inside ThreadedMotoServer.start(): its werkzeug thread dies on "Address already in use" before setting the ready event that start() waits on, and the wait has no timeout. Any multi-worker run whose s3 tests landed on 2+ workers deadlocked near the end of the suite at 0% CPU. CI never saw this because it runs the suite single-process. Bind port 0 instead and yield the actual endpoint from the fixture. Since the endpoint is now only known at runtime, test_fsspec.py's module-level endpoint_url constant becomes an endpoint_url fixture (replacing the s3_base alias), and its consumers take it as a parameter. With this fix the full suite completes in ~1 minute under -n auto on a 10-core machine, where it previously hung indefinitely. Assisted-by: ClaudeCode:claude-fable-5 --- tests/conftest.py | 14 +++++++--- tests/test_store/test_fsspec.py | 45 ++++++++++++++++----------------- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c02daf7663..ec8f07b0e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -550,24 +550,30 @@ def deep_nan_equal(a: object, b: object) -> bool: # instead of each module standing up its own. Consumers create their own buckets and choose # how the endpoint reaches the client (explicit storage_options vs. the AWS_ENDPOINT_URL # env var) on top of this fixture. -MOTO_SERVER_PORT = 5555 -MOTO_ENDPOINT_URL = f"http://127.0.0.1:{MOTO_SERVER_PORT}/" @pytest.fixture(scope="session") def moto_server() -> Generator[str, None, None]: """Start a session-scoped moto S3 server and yield its endpoint URL. + The server binds an ephemeral port (port=0), so the endpoint is only known at + runtime; consumers must take it from this fixture rather than a constant. A fixed + port deadlocks under pytest-xdist: session-scoped fixtures run once per *worker*, so + concurrent workers race to bind the same port, and the losers block forever inside + ThreadedMotoServer.start(), whose server thread dies on "Address already in use" + before ever setting the ready event that start() waits on. + importorskip lives inside the fixture so moto is only required when a test actually requests an S3 backend, not for the whole test session.""" moto_server_mod = pytest.importorskip("moto.moto_server.threaded_moto_server") - server = moto_server_mod.ThreadedMotoServer(ip_address="127.0.0.1", port=MOTO_SERVER_PORT) + server = moto_server_mod.ThreadedMotoServer(ip_address="127.0.0.1", port=0) server.start() + host, port = server.get_host_and_port() # moto needs *some* credentials present; use throwaway values if the environment has none. os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "foo") os.environ.setdefault("AWS_ACCESS_KEY_ID", "foo") try: - yield MOTO_ENDPOINT_URL + yield f"http://{host}:{port}/" finally: server.stop() diff --git a/tests/test_store/test_fsspec.py b/tests/test_store/test_fsspec.py index 9efee40d7a..898d49ec08 100644 --- a/tests/test_store/test_fsspec.py +++ b/tests/test_store/test_fsspec.py @@ -9,7 +9,6 @@ from packaging.version import parse as parse_version import zarr.api.asynchronous -from tests.conftest import MOTO_ENDPOINT_URL from zarr import Array from zarr.abc.store import OffsetByteRequest from zarr.core.buffer import Buffer, cpu, default_buffer_prototype @@ -60,18 +59,18 @@ # ### amended from s3fs ### # test_bucket_name = "test" secure_bucket_name = "test-secure" -# The moto server itself is the session-scoped `moto_server` fixture in tests/conftest.py; -# this module reuses its endpoint rather than standing up its own server. -endpoint_url = MOTO_ENDPOINT_URL @pytest.fixture -def s3_base(moto_server: str) -> str: - """Reuse the shared session-scoped moto server (see tests/conftest.py).""" +def endpoint_url(moto_server: str) -> str: + """Endpoint of the shared session-scoped moto server (see tests/conftest.py). + + A fixture rather than a module-level constant because the server binds an ephemeral + port, so the endpoint is only known once the server is running.""" return moto_server -def get_boto3_client() -> botocore.client.BaseClient: +def get_boto3_client(endpoint_url: str) -> botocore.client.BaseClient: # NB: we use the sync botocore client for setup session = botocore.session.Session() @@ -84,7 +83,7 @@ def get_boto3_client() -> botocore.client.BaseClient: @pytest.fixture(autouse=True) -def s3(s3_base: None) -> Generator[s3fs.S3FileSystem, None, None]: +def s3(endpoint_url: str) -> Generator[s3fs.S3FileSystem, None, None]: """ Quoting Martin Durant: pytest-asyncio creates a new event loop for each async test. @@ -97,7 +96,7 @@ def s3(s3_base: None) -> Generator[s3fs.S3FileSystem, None, None]: https://github.com/zarr-developers/zarr-python/pull/1785#discussion_r1634856207 """ - client = get_boto3_client() + client = get_boto3_client(endpoint_url) client.create_bucket(Bucket=test_bucket_name, ACL="public-read") s3fs.S3FileSystem.clear_instance_cache() s3 = s3fs.S3FileSystem( @@ -119,7 +118,7 @@ def s3(s3_base: None) -> Generator[s3fs.S3FileSystem, None, None]: # ### end from s3fs ### # -async def test_basic() -> None: +async def test_basic(endpoint_url: str) -> None: store = FsspecStore.from_url( f"s3://{test_bucket_name}/foo/spam/", storage_options={"endpoint_url": endpoint_url, "anon": False}, @@ -146,7 +145,7 @@ class TestFsspecStoreS3(StoreTests[FsspecStore, cpu.Buffer]): buffer_cls = cpu.Buffer @pytest.fixture - def store_kwargs(self) -> dict[str, str | bool]: + def store_kwargs(self, endpoint_url: str) -> dict[str, str | bool]: try: from fsspec import url_to_fs except ImportError: @@ -184,7 +183,7 @@ def test_store_supports_writes(self, store: FsspecStore) -> None: def test_store_supports_listing(self, store: FsspecStore) -> None: assert store.supports_listing - async def test_fsspec_store_from_uri(self, store: FsspecStore) -> None: + async def test_fsspec_store_from_uri(self, store: FsspecStore, endpoint_url: str) -> None: storage_options = { "endpoint_url": endpoint_url, "anon": False, @@ -237,7 +236,7 @@ async def test_fsspec_store_from_uri(self, store: FsspecStore) -> None: parse_version(fsspec.__version__) < parse_version("2024.03.01"), reason="Prior bug in from_upath", ) - def test_from_upath(self) -> None: + def test_from_upath(self, endpoint_url: str) -> None: upath = pytest.importorskip("upath") path = upath.UPath( f"s3://{test_bucket_name}/foo/bar/", @@ -250,7 +249,7 @@ def test_from_upath(self) -> None: assert result.fs.asynchronous assert result.path == f"{test_bucket_name}/foo/bar" - def test_init_warns_if_fs_asynchronous_is_false(self) -> None: + def test_init_warns_if_fs_asynchronous_is_false(self, endpoint_url: str) -> None: try: from fsspec import url_to_fs except ImportError: @@ -279,7 +278,7 @@ async def test_delete_dir_unsupported_deletes(self, store: FsspecStore) -> None: # ── Filesystem lifecycle (ownership) ────────────────────────────────────── - def test_from_url_owns_filesystem(self) -> None: + def test_from_url_owns_filesystem(self, endpoint_url: str) -> None: """FsspecStore.from_url() creates the async fs; it must own it.""" store = FsspecStore.from_url( f"s3://{test_bucket_name}/lifecycle/", @@ -288,7 +287,7 @@ def test_from_url_owns_filesystem(self) -> None: assert store._owns_fs store.close() - async def test_from_url_close_releases_store(self) -> None: + async def test_from_url_close_releases_store(self, endpoint_url: str) -> None: """ close() on a from_url() store must succeed without error and mark the store as closed. For the owned filesystem, _close_fs() is invoked to @@ -305,7 +304,7 @@ async def test_from_url_close_releases_store(self) -> None: assert not store._is_open - def test_direct_construction_does_not_own_filesystem(self) -> None: + def test_direct_construction_does_not_own_filesystem(self, endpoint_url: str) -> None: """Direct FsspecStore() must not claim ownership — the caller owns the fs.""" try: from fsspec import url_to_fs @@ -321,7 +320,7 @@ def test_direct_construction_does_not_own_filesystem(self) -> None: parse_version(fsspec.__version__) < parse_version("2024.03.01"), reason="Prior bug in from_upath", ) - def test_from_upath_does_not_own_filesystem(self) -> None: + def test_from_upath_does_not_own_filesystem(self, endpoint_url: str) -> None: """from_upath() uses the UPath's existing fs; the store must not own it.""" upath = pytest.importorskip("upath") path = upath.UPath( @@ -333,7 +332,7 @@ def test_from_upath_does_not_own_filesystem(self) -> None: store = FsspecStore.from_upath(path) assert not store._owns_fs - def test_from_mapper_does_not_own_already_async_filesystem(self) -> None: + def test_from_mapper_does_not_own_already_async_filesystem(self, endpoint_url: str) -> None: """from_mapper() with an already-async fs must not claim ownership.""" s3_filesystem = s3fs.S3FileSystem( asynchronous=True, @@ -501,7 +500,7 @@ def test_wrap_sync_filesystem_raises(tmp_path: pathlib.Path) -> None: parse_version(fsspec.__version__) < parse_version("2024.12.0"), reason="No AsyncFileSystemWrapper", ) -def test_no_wrap_async_filesystem() -> None: +def test_no_wrap_async_filesystem(endpoint_url: str) -> None: """An async fs should not be wrapped automatically; fsspec's s3 filesystem is such an fs""" from fsspec.implementations.asyn_wrapper import AsyncFileSystemWrapper @@ -551,7 +550,7 @@ def test_open_fsmap_file_raises(tmp_path: pathlib.Path) -> None: @pytest.mark.parametrize("asynchronous", [True, False]) -def test_open_fsmap_s3(asynchronous: bool) -> None: +def test_open_fsmap_s3(asynchronous: bool, endpoint_url: str) -> None: s3_filesystem = s3fs.S3FileSystem( asynchronous=asynchronous, endpoint_url=endpoint_url, anon=False ) @@ -559,7 +558,7 @@ def test_open_fsmap_s3(asynchronous: bool) -> None: array_roundtrip(mapper) -def test_open_s3map_raises() -> None: +def test_open_s3map_raises(endpoint_url: str) -> None: with pytest.raises(TypeError, match="Unsupported type for store_like:.*"): zarr.open(store=0, mode="w", shape=(3, 3)) s3_filesystem = s3fs.S3FileSystem(asynchronous=True, endpoint_url=endpoint_url, anon=False) @@ -652,7 +651,7 @@ def test_with_read_only_transfers_filesystem_ownership(tmp_path: pathlib.Path) - @pytest.mark.parametrize("asynchronous", [True, False]) -def test_make_async(asynchronous: bool) -> None: +def test_make_async(asynchronous: bool, endpoint_url: str) -> None: s3_filesystem = s3fs.S3FileSystem( asynchronous=asynchronous, endpoint_url=endpoint_url, anon=False ) From a608a2c9a0c073d443f0242f3ce3a43023df1728 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Fri, 10 Jul 2026 10:34:19 +0200 Subject: [PATCH 3/6] doc: add changelog fragment for moto ephemeral-port fix Assisted-by: ClaudeCode:claude-fable-5 --- changes/214.misc.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/214.misc.md diff --git a/changes/214.misc.md b/changes/214.misc.md new file mode 100644 index 0000000000..8611362e84 --- /dev/null +++ b/changes/214.misc.md @@ -0,0 +1 @@ +Bind the shared moto S3 test server to an ephemeral port instead of a fixed one. The fixed port deadlocked `pytest -n auto`: session-scoped fixtures run once per xdist worker, so concurrent workers raced to bind the same port and the losers blocked forever in `ThreadedMotoServer.start()`. From 7f7ae53b2c6dd66442e234d7e2f04ecb5ada676e Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Thu, 9 Jul 2026 21:25:31 +0200 Subject: [PATCH 4/6] test: enable pytest strict mode and fix duplicate parametrization ids Adopt pytest 9's `strict = true` config option, which enables strict_config, strict_markers, strict_xfail, strict_parametrization_ids, and future strictness options. This replaces the --strict-config and --strict-markers addopts flags (which pytest silently ignored when passed via addopts before 9.1, pytest#14442) and the separate xfail_strict setting. minversion is bumped to 9 accordingly. strict_parametrization_ids surfaced seven parametrizations whose ids collided, which pytest previously deduplicated silently: - test_common.py paired float("inf") with np.inf (and -inf/nan), but np.inf IS a Python float, so three cases per test ran twice and the intended numpy-scalar coverage did not exist. Use np.float64 scalars with explicit ids, restoring the intended coverage. - test_int.py deliberately includes np.dtype("i")/np.dtype("I"), which compare equal to one of the explicit-endian dtypes and stringify identically. The class-fixture hook in test_dtype/conftest now suffixes colliding ids with their index instead of relying on pytest's silent deduplication. - test_group.py test_consistent_signatures id'd both create_hierarchy pairs identically; ids now include module and qualname. - test_array.py ("a" vs b"a"), test_vlen.py (str vs "str"), and test_metadata/test_v2.py (3 vs "3") get explicit ids for the colliding member. Assisted-by: ClaudeCode:claude-fable-5 --- pyproject.toml | 10 +++++++--- tests/test_array.py | 3 ++- tests/test_codecs/test_vlen.py | 6 ++++-- tests/test_dtype/conftest.py | 18 +++++++++++++++++- tests/test_dtype/test_npy/test_common.py | 19 +++++++++++-------- tests/test_group.py | 3 +++ tests/test_metadata/test_v2.py | 3 ++- 7 files changed, 46 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6a7238ff8f..988fcd4950 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -427,11 +427,15 @@ module = [ ignore_errors = true [tool.pytest.ini_options] -minversion = "7" +minversion = "9" testpaths = ["src", "tests", "docs/user-guide"] log_cli_level = "INFO" log_level = "INFO" -xfail_strict = true +# Enables strict_config, strict_markers, strict_xfail, strict_parametrization_ids, and +# any strictness options added in future pytest releases. Note that the equivalent +# `--strict-config`/`--strict-markers` flags were silently ignored when passed via +# addopts before pytest 9.1 (pytest#14442), so this option is the reliable spelling. +strict = true asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" doctest_optionflags = [ @@ -443,7 +447,7 @@ addopts = [ "--benchmark-columns", "min,mean,stddev,outliers,rounds,iterations", "--benchmark-disable", # benchmark routines run as tests without benchmarking instrumentation "--durations", "10", - "-ra", "--strict-config", "--strict-markers", + "-ra", "--doctest-modules", "--ignore=tests/test_regression/scripts", "--ignore=src/zarr/_cli", diff --git a/tests/test_array.py b/tests/test_array.py index 0d6d2d5906..09e7732960 100644 --- a/tests/test_array.py +++ b/tests/test_array.py @@ -1693,7 +1693,8 @@ def test_default_endianness( assert endianness_from_numpy_str(byte_order) == endianness # type: ignore[arg-type] -@pytest.mark.parametrize("value", [1, 1.4, "a", b"a", np.array(1)]) +# The explicit id for b"a" avoids colliding with the auto-generated id for "a". +@pytest.mark.parametrize("value", [1, 1.4, "a", pytest.param(b"a", id="a-bytes"), np.array(1)]) @pytest.mark.parametrize("zarr_format", [2, 3]) @pytest.mark.filterwarnings("ignore::zarr.core.dtype.common.UnstableSpecificationWarning") def test_scalar_array(value: Any, zarr_format: ZarrFormat) -> None: diff --git a/tests/test_codecs/test_vlen.py b/tests/test_codecs/test_vlen.py index 3422090a28..96dab25a5d 100644 --- a/tests/test_codecs/test_vlen.py +++ b/tests/test_codecs/test_vlen.py @@ -13,10 +13,12 @@ from zarr.core.metadata.v3 import ArrayV3Metadata from zarr.storage import StorePath -numpy_str_dtypes: list[type | str | None] = [ +# The explicit id for the "str" literal avoids colliding with the auto-generated id for +# the `str` builtin. +numpy_str_dtypes: list[Any] = [ None, str, - "str", + pytest.param("str", id="str-literal"), np.dtypes.StrDType, "S", "U", diff --git a/tests/test_dtype/conftest.py b/tests/test_dtype/conftest.py index 4c585bfdf6..100b9df226 100644 --- a/tests/test_dtype/conftest.py +++ b/tests/test_dtype/conftest.py @@ -1,5 +1,6 @@ # Generate a collection of zdtype instances for use in testing. import warnings +from collections import Counter from typing import Any import numpy as np @@ -64,4 +65,19 @@ class TestB(TestExample): for fixture_name in metafunc.fixturenames: if hasattr(metafunc.cls, fixture_name): params = getattr(metafunc.cls, fixture_name) - metafunc.parametrize(fixture_name, params, scope="class", ids=str) + metafunc.parametrize( + fixture_name, params, scope="class", ids=_unique_ids([str(p) for p in params]) + ) + + +def _unique_ids(ids: list[str]) -> list[str]: + """Suffix repeated ids with their positional index so every id is unique. + + Distinct parameters can stringify identically: for example `np.dtype("i")` and + `np.dtype(" 1 else id_ for idx, id_ in enumerate(ids)] diff --git a/tests/test_dtype/test_npy/test_common.py b/tests/test_dtype/test_npy/test_common.py index d8912a70ec..b7e4e875ad 100644 --- a/tests/test_dtype/test_npy/test_common.py +++ b/tests/test_dtype/test_npy/test_common.py @@ -36,14 +36,17 @@ from zarr.core.common import JSON, ZarrFormat -json_float_v2_roundtrip_cases: tuple[tuple[JSONFloatV2, float | np.floating[Any]], ...] = ( - ("Infinity", float("inf")), - ("Infinity", np.inf), - ("-Infinity", float("-inf")), - ("-Infinity", -np.inf), - ("NaN", float("nan")), - ("NaN", np.nan), - (1.0, 1.0), +# Each special value is tested as both a Python float and a numpy scalar. The explicit +# ids are load-bearing: np.float64("inf") stringifies identically to float("inf"), so +# without them these parameter sets would produce duplicate test ids. +json_float_v2_roundtrip_cases: tuple[Any, ...] = ( + pytest.param("Infinity", float("inf"), id="Infinity-float"), + pytest.param("Infinity", np.float64("inf"), id="Infinity-float64"), + pytest.param("-Infinity", float("-inf"), id="-Infinity-float"), + pytest.param("-Infinity", np.float64("-inf"), id="-Infinity-float64"), + pytest.param("NaN", float("nan"), id="NaN-float"), + pytest.param("NaN", np.float64("nan"), id="NaN-float64"), + pytest.param(1.0, 1.0, id="1.0-1.0"), ) json_float_v3_cases = json_float_v2_roundtrip_cases diff --git a/tests/test_group.py b/tests/test_group.py index bc80e19e86..1acd5551ca 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -1713,6 +1713,9 @@ def test_create_nodes_concurrency_limit(store: MemoryStore) -> None: (zarr.core.group.create_rooted_hierarchy, zarr.core.sync_group.create_rooted_hierarchy), (zarr.core.group.get_node, zarr.core.sync_group.get_node), ], + # The default ids (from __name__) collide: the method pair and the module-level pair + # for create_hierarchy would both be id'd "create_hierarchy-create_hierarchy". + ids=lambda func: f"{func.__module__.rsplit('.', maxsplit=1)[-1]}.{func.__qualname__}", ) def test_consistent_signatures( a_func: Callable[[object], object], b_func: Callable[[object], object] diff --git a/tests/test_metadata/test_v2.py b/tests/test_metadata/test_v2.py index d1a1ca00b4..0f280f0401 100644 --- a/tests/test_metadata/test_v2.py +++ b/tests/test_metadata/test_v2.py @@ -29,7 +29,8 @@ def test_parse_zarr_format_valid() -> None: assert parse_zarr_format(2) == 2 -@pytest.mark.parametrize("data", [None, 1, 3, 4, 5, "3"]) +# The explicit id for "3" avoids colliding with the auto-generated id for the int 3. +@pytest.mark.parametrize("data", [None, 1, 3, 4, 5, pytest.param("3", id="3-str")]) def test_parse_zarr_format_invalid(data: Any) -> None: with pytest.raises(ValueError, match=f"Invalid value. Expected 2. Got {data}"): parse_zarr_format(data) From 297e07cc04e485be9d38838736a5d9035fccf6fc Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Fri, 10 Jul 2026 10:37:41 +0200 Subject: [PATCH 5/6] doc: add changelog fragment for pytest strict mode Assisted-by: ClaudeCode:claude-fable-5 --- changes/215.misc.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/215.misc.md diff --git a/changes/215.misc.md b/changes/215.misc.md new file mode 100644 index 0000000000..f5cc7ebc7d --- /dev/null +++ b/changes/215.misc.md @@ -0,0 +1 @@ +Enable pytest's `strict = true` config option (strict config, markers, xfail, and parametrization ids), replacing the `--strict-config`/`--strict-markers` addopts flags that pytest silently ignored before 9.1, and fix the seven duplicate parametrization ids it surfaced — including restoring float-JSON roundtrip cases that were meant to cover numpy scalars but ran plain-float cases twice instead. From 2697fa560c4093954dfafedeaa0fc43e2bf181af Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Fri, 10 Jul 2026 14:34:58 +0200 Subject: [PATCH 6/6] test: arm faulthandler watchdog to turn deadlocks into loud failures Set faulthandler_timeout=600 with faulthandler_exit_on_timeout (new in pytest 9): if a single test exceeds 10 minutes, every thread's traceback is dumped and the run is killed, instead of hanging indefinitely with no diagnostics. Motivated by the moto fixed-port deadlock, which hung local -n auto runs at ~98% for hours and required manual kill -ABRT forensics to diagnose; with this watchdog the same bug would have failed in 10 minutes with the offending stack in the log. The ceiling is ~30x the slowest legitimate test (~20s locally) to stay clear of slow CI runners, coverage overhead, and nightly stateful hypothesis runs. Assisted-by: ClaudeCode:claude-fable-5 --- changes/215.misc.md | 2 +- pyproject.toml | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/changes/215.misc.md b/changes/215.misc.md index f5cc7ebc7d..f8df7cc632 100644 --- a/changes/215.misc.md +++ b/changes/215.misc.md @@ -1 +1 @@ -Enable pytest's `strict = true` config option (strict config, markers, xfail, and parametrization ids), replacing the `--strict-config`/`--strict-markers` addopts flags that pytest silently ignored before 9.1, and fix the seven duplicate parametrization ids it surfaced — including restoring float-JSON roundtrip cases that were meant to cover numpy scalars but ran plain-float cases twice instead. +Enable pytest's `strict = true` config option (strict config, markers, xfail, and parametrization ids), replacing the `--strict-config`/`--strict-markers` addopts flags that pytest silently ignored before 9.1, and fix the seven duplicate parametrization ids it surfaced — including restoring float-JSON roundtrip cases that were meant to cover numpy scalars but ran plain-float cases twice instead. Also arm pytest's faulthandler watchdog (`faulthandler_timeout = 600` with `faulthandler_exit_on_timeout`) so a deadlocked test dumps every thread's traceback and fails the run instead of hanging indefinitely. diff --git a/pyproject.toml b/pyproject.toml index 988fcd4950..a02ead0a1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -436,6 +436,12 @@ log_level = "INFO" # `--strict-config`/`--strict-markers` flags were silently ignored when passed via # addopts before pytest 9.1 (pytest#14442), so this option is the reliable spelling. strict = true +# Turn deadlocks into loud failures: if a single test exceeds this many seconds, dump +# every thread's traceback and kill the run (exit_on_timeout is new in pytest 9). Sized +# far above the slowest legitimate test (~20s locally; slower under coverage/Windows/ +# nightly stateful-hypothesis runs) so only a genuine hang can trip it. +faulthandler_timeout = 600 +faulthandler_exit_on_timeout = true asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" doctest_optionflags = [