Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
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: 2 additions & 0 deletions metaflow/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,8 @@ def compress_list(lst, separator=",", rangedelim=":", zlibmarker="!", zlibmin=50


def decompress_list(lststr, separator=",", rangedelim=":", zlibmarker="!"):
if not lststr:
return []
# Three input modes:
if lststr[0] == zlibmarker:
# 3. zlib-compressed, base64-encoded
Expand Down
81 changes: 81 additions & 0 deletions test/unit/test_util_compress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""
Unit tests for compress_list / decompress_list in metaflow/util.py

Bug: decompress_list("") raised IndexError on lststr[0] because there
was no guard for an empty input string. compress_list([]) legitimately
produces "" (joining an empty list), so the round-trip
decompress_list(compress_list([]))
crashed instead of returning [].

Fix: add `if not lststr: return []` before the index access.
"""

import sys
from unittest.mock import MagicMock

# fcntl is Linux/macOS-only. Stub it out so the test runs on Windows too.
if "fcntl" not in sys.modules:
sys.modules["fcntl"] = MagicMock()
if "metaflow.sidecar.sidecar_subprocess" not in sys.modules:
sys.modules["metaflow.sidecar.sidecar_subprocess"] = MagicMock()

Comment on lines +14 to +21

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Module-level sys.modules injection may leak across tests

The sys.modules mutation at module import time is permanent for the entire test session. On Linux/macOS, if fcntl has not yet been imported by a prior test when this file is collected, the real fcntl module will be replaced with a MagicMock() for all subsequent tests in the same pytest process — including tests that legitimately need the real fcntl.

A safer pattern is to scope the stub to only the tests in this file using an autouse session/module fixture:

import pytest

@pytest.fixture(autouse=True, scope="module")
def _stub_platform_modules():
    stubs = {}
    for mod in ("fcntl", "metaflow.sidecar.sidecar_subprocess"):
        if mod not in sys.modules:
            sys.modules[mod] = MagicMock()
            stubs[mod] = True
    yield
    for mod, was_stubbed in stubs.items():
        if was_stubbed:
            del sys.modules[mod]

This restores the original state after the module's tests finish, preventing leakage.

from metaflow.util import compress_list, decompress_list # noqa: E402

# ---------------------------------------------------------------------------
# Core regression tests
# ---------------------------------------------------------------------------


def test_compress_empty_list_returns_empty_string():
"""compress_list([]) must produce '' (join of zero elements)."""
assert compress_list([]) == ""
Comment on lines +29 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing edge-case test: [""] round-trip collision

compress_list([""]) produces "" (joining a list of one empty string), which is the same output as compress_list([]). After this fix, decompress_list("") returns [], meaning decompress_list(compress_list([""])) returns [] instead of [""].

This is a pre-existing design-level ambiguity (both an empty list and a list containing one empty string compress to the same token), but the fix silently resolves it in favor of []. Adding a test documenting the current behavior would make this intent explicit and prevent future surprises:

def test_roundtrip_single_empty_string():
    # compress_list([""]) == "" == compress_list([]),
    # so decompress cannot distinguish the two; [] is the chosen result.
    assert decompress_list(compress_list([""])) == []

If [""] inputs are genuinely expected in the codebase, the collision should be addressed at the compress_list level (e.g., encoding the count) rather than relying on callers to avoid empty-string elements.



def test_decompress_empty_string_returns_empty_list():
"""
decompress_list("") must return [] without raising.

This is the direct crash case: before the fix, lststr[0] on an
empty string raised IndexError: string index out of range.
Fails without the fix, passes with it.
"""
result = decompress_list("")
assert result == []


def test_roundtrip_empty_list():
"""
decompress_list(compress_list([])) must return [].

This is the end-to-end round-trip that exposed the bug: the output
of compress_list is fed straight into decompress_list, so an empty
list must survive the round-trip unharmed.
Fails without the fix, passes with it.
"""
assert decompress_list(compress_list([])) == []


# ---------------------------------------------------------------------------
# Regression guard: existing non-empty round-trips must still work
# ---------------------------------------------------------------------------


def test_roundtrip_single_item():
"""A single-element list survives the round-trip."""
items = ["abc"]
assert decompress_list(compress_list(items)) == items


def test_roundtrip_multiple_items_with_common_prefix():
"""
Lists with a long common prefix use the prefix-encoding path;
verify the round-trip is correct after the guard was added.
"""
items = ["MyFlow/1234/start/1", "MyFlow/1234/start/2", "MyFlow/1234/start/3"]
assert decompress_list(compress_list(items)) == items


def test_roundtrip_items_without_common_prefix():
"""Lists with no common prefix use the plain comma-separated path."""
items = ["alpha", "beta", "gamma"]
assert decompress_list(compress_list(items)) == items