Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
23 changes: 13 additions & 10 deletions pins/boards.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from .drivers import REQUIRES_SINGLE_FILE, default_title, load_data, load_file, save_data
from .errors import PinsError, PinsVersionError
from .meta import Meta, MetaFactory, MetaRaw
from .utils import ExtendMethodDoc, inform, warn_deprecated
from .utils import ExtendMethodDoc, fs_protocol, inform, warn_deprecated
from .versions import VersionRaw, guess_version, version_setup

_log = logging.getLogger(__name__)
Expand Down Expand Up @@ -858,22 +858,23 @@ def board_deparse(board: BaseBoard):
else:
allow_pickle = ""

prot = board.fs.protocol
prot = fs_protocol(board.fs)
prots = {prot} if isinstance(prot, str) else set(prot)

if prot == "rsc":
if "rsc" in prots:
url = board.fs.api.server_url
return f"board_connect(server_url={repr(url)}{allow_pickle})"
elif prot in ["file", ("file", "local")]:
elif "file" in prots:
return f"board_folder({repr(board.board)}{allow_pickle})"
elif set(prot) == {"s3", "s3a"}:
elif prots & {"s3", "s3a"}:
return f"board_s3({repr(board.board)}{allow_pickle})"
elif prot == "abfs":
elif "abfs" in prots:
return f"board_azure({repr(board.board)}{allow_pickle})"
elif set(prot) == {"gcs", "gs"} or prot == "gs":
elif prots & {"gcs", "gs"}:
return f"board_gcs({repr(board.board)}{allow_pickle})"
elif prot == "http":
elif "http" in prots:
return f"board_url({repr(board.board)}, {board.pin_paths}{allow_pickle})"
elif prot == "dbc":
elif "dbc" in prots:
return f"board_databricks({repr(board.board)}{allow_pickle})"
else:
raise NotImplementedError(
Expand Down Expand Up @@ -930,7 +931,9 @@ def pin_meta(self, name, version=None):
# a file. here we need to create a stripped down form of metadata, since
# a metadata file does not exist (and we can't pull files from a version dir).
path_to_pin = self.construct_path([pin_name])
if self.fs.protocol == "http" and not path_to_pin.rstrip().endswith("/"):
prot = fs_protocol(self.fs)
is_http = prot == "http" or (not isinstance(prot, str) and "http" in prot)
if is_http and not path_to_pin.rstrip().endswith("/"):
# create metadata, rather than read from a file
return self.meta_factory.create_raw(path_to_pin, type="file", name=pin_name)

Expand Down
8 changes: 3 additions & 5 deletions pins/tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from pins.boards import BaseBoard, BoardRsConnect
from pins.constructors import board_databricks
from pins.utils import fs_protocol

DEFAULT_CREATION_DATE = datetime(2020, 1, 13, 23, 58, 59)

Expand Down Expand Up @@ -65,10 +66,7 @@ def wrapper(*args, **kwargs):
board = arg_value
break

if board and (
board.fs.protocol == "dbc"
or (hasattr(board.fs, "fs") and board.fs.fs.protocol == "dbc")
):
if board and fs_protocol(board.fs) == "dbc":
pytest.skip("All Databricks tests must be read only")

return func(*args, **kwargs)
Expand Down Expand Up @@ -104,7 +102,7 @@ def outer(f):
# Assumes a fixture named board is passed to the test
@wraps(f)
def wrapper(board, *args, **kwargs):
if board.fs.protocol in names:
if fs_protocol(board.fs) in names:
pytest.xfail()
return f(board, *args, **kwargs)
else:
Expand Down
17 changes: 9 additions & 8 deletions pins/tests/test_boards.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from pins.errors import PinsError, PinsInsecureReadError, PinsVersionError
from pins.meta import MetaRaw
from pins.tests.helpers import DEFAULT_CREATION_DATE, rm_env, skip_if_dbc
from pins.utils import fs_protocol


@fixture
Expand Down Expand Up @@ -259,7 +260,7 @@ def test_board_pin_download_no_cache_error(board, tmp_path):
assert meta.type == "file"

# file boards work okay, since the board directory itself is the cache
if board.fs.protocol in ["file", ("file", "local")]:
if fs_protocol(board.fs) in ["file", ("file", "local")]:
pytest.skip()

# uncached boards should fail, since nowhere to store the download
Expand Down Expand Up @@ -303,7 +304,7 @@ def test_board_pin_download_filename_multifile(board_with_cache, tmp_path):


def test_board_pin_write_rsc_index_html(board, tmp_path: Path, snapshot):
if board.fs.protocol != "rsc":
if fs_protocol(board.fs) != "rsc":
pytest.skip()

df = pd.DataFrame({"x": [1, 2, None], "y": ["a", "b", "c"]})
Expand Down Expand Up @@ -451,7 +452,7 @@ def pin_name():
@pytest.fixture
def pin_del(board, df, pin_name):
# TODO: update when dbc boards no longer read-only
if board.fs.protocol == "dbc":
if fs_protocol(board.fs) == "dbc":
pytest.skip()
# 1min ago to avoid name collision
one_min_ago = datetime.now() - timedelta(minutes=1)
Expand All @@ -471,7 +472,7 @@ def pin_del(board, df, pin_name):
@pytest.fixture
def pin_prune(board, df, pin_name):
# TODO: update when dbc boards no longer read-only
if board.fs.protocol == "dbc":
if fs_protocol(board.fs) == "dbc":
pytest.skip()
today = datetime.now()
day_ago = today - timedelta(days=1, minutes=1)
Expand Down Expand Up @@ -521,7 +522,7 @@ def test_board_pin_version_delete_older(board, pin_name, pin_del):
def test_board_pin_version_delete_latest(board, pin_name, pin_del):
meta_old, meta_new = pin_del

if board.fs.protocol == "rsc":
if fs_protocol(board.fs) == "rsc":
with pytest.raises(PinsError) as exc_info:
board.pin_version_delete(pin_name, meta_new.version.version)

Expand Down Expand Up @@ -553,7 +554,7 @@ def test_board_pin_versions_prune_n(board, pin_prune, pin_name, n):
@pytest.mark.parametrize("days", [1, 2])
def test_board_pin_versions_prune_days(board, pin_prune, pin_name, days):
# Posit cannot handle days, since it involves pulling metadata
if board.fs.protocol == "rsc":
if fs_protocol(board.fs) == "rsc":
with pytest.raises(NotImplementedError):
board.pin_versions_prune(pin_name, days=days)
return
Expand All @@ -570,7 +571,7 @@ def test_board_pin_versions_prune_days(board, pin_prune, pin_name, days):
def test_board_pin_versions_prune_days_protect_most_recent(board, pin_name):
"""To address https://github.com/rstudio/pins-python/issues/297"""
# Posit cannot handle days, since it involves pulling metadata
if board.fs.protocol == "rsc":
if fs_protocol(board.fs) == "rsc":
with pytest.raises(NotImplementedError):
board.pin_versions_prune(pin_name, days=5)
return
Expand Down Expand Up @@ -612,7 +613,7 @@ def test_board_pin_versions_prune_days_protect_most_recent(board, pin_name):
)
@skip_if_dbc
def test_board_pin_search_name(board, df, search, matches):
if board.fs.protocol == "rsc":
if fs_protocol(board.fs) == "rsc":
matches = ["derek/" + m for m in matches]

# rsc doesn't search by title
Expand Down
21 changes: 11 additions & 10 deletions pins/tests/test_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
PATH_TO_MANIFEST_BOARD,
)
from pins.tests.helpers import skip_if_dbc, xfail_fs
from pins.utils import fs_protocol

NOT_A_PIN = "not_a_pin_abcdefg"
PIN_CSV = "df_csv"
Expand All @@ -19,7 +20,7 @@
@pytest.fixture(scope="session")
def board(backend):
board = backend.create_tmp_board(str(PATH_TO_EXAMPLE_BOARD.absolute()))
if board.fs.protocol == "dbc":
if fs_protocol(board.fs) == "dbc":
board = backend.create_tmp_board(str(PATH_TO_EXAMPLE_BOARD_DBC))
yield board

Expand Down Expand Up @@ -47,10 +48,10 @@ def test_compat_pin_list(board):
src_sorted = sorted(board.pin_list())
dst_sorted = ["df_arrow", "df_csv", "df_rds", "df_unversioned"]

if board.fs.protocol == "rsc":
if fs_protocol(board.fs) == "rsc":
# rsc backend uses <user_name>/<content_name> for full name
dst_sorted = [f"{board.user_name}/{content}" for content in dst_sorted]
if board.fs.protocol == "dbc":
if fs_protocol(board.fs) == "dbc":
# TODO: update to match when not read-only
dst_sorted = [
"cool_pin",
Expand All @@ -70,12 +71,12 @@ def test_compat_pin_list(board):


def test_compat_pin_versions(board):
if board.fs.protocol == "rsc":
if fs_protocol(board.fs) == "rsc":
pytest.skip("RSC uses bundle ids as pin versions")
versions = board.pin_versions("df_csv", as_df=False)
v_strings = list(v.version for v in versions)
# TODO: update when dbc is not read-only
if board.fs.protocol == "dbc":
if fs_protocol(board.fs) == "dbc":
v_strings == ["20250410T083026Z-a173c"]
else:
assert v_strings == ["20220214T163718Z-eceac", "20220214T163720Z-9bfad"]
Expand Down Expand Up @@ -109,12 +110,12 @@ def test_compat_pin_meta(board):
# Note that this fetches the latest of 2 versions
meta = board.pin_meta(PIN_CSV)

if board.fs.protocol == "rsc":
if fs_protocol(board.fs) == "rsc":
# TODO: afaik the bundle id is largely non-deterministic, so not possible
# to test, but should think a bit more about it.
assert meta.name == "derek/df_csv"
# TODO: update when dbc boards are not read-only
elif board.fs.protocol == "dbc":
elif fs_protocol(board.fs) == "dbc":
assert meta.title == "df_csv: a pinned 3 x 2 DataFrame"
assert meta.description is None
assert meta.created == "20250410T083026Z"
Expand Down Expand Up @@ -154,7 +155,7 @@ def test_compat_pin_meta_pin_missing(board):
def test_compat_pin_meta_version_arg(board):
# note that in RSConnect the version is the bundle id
# TODO: update when dbc is not read-only
if board.fs.protocol == "dbc":
if fs_protocol(board.fs) == "dbc":
meta = board.pin_meta(PIN_CSV, "20250410T083026Z-a173c")
assert meta.version.version == "20250410T083026Z-a173c"
assert meta.version.hash == "a173c"
Expand Down Expand Up @@ -185,7 +186,7 @@ def test_compat_pin_read(board):
src_df = board.pin_read("df_csv")

# TODO: update when dbc boards are not read-only
if board.fs.protocol == "dbc":
if fs_protocol(board.fs) == "dbc":
dst_df = pd.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]})
else:
dst_df = pd.read_csv(p_data)
Expand All @@ -210,7 +211,7 @@ def test_compat_pin_read_supported_rds(board):


def test_board_pin_write_manifest_name_error(board_manifest):
if board_manifest.fs.protocol == "rsc":
if fs_protocol(board_manifest.fs) == "rsc":
pytest.skip()

with pytest.raises(ValueError) as exc_info:
Expand Down
11 changes: 6 additions & 5 deletions pins/tests/test_constructors.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
PATH_TO_EXAMPLE_VERSION,
)
from pins.tests.helpers import rm_env, skip_if_dbc
from pins.utils import fs_protocol


@pytest.fixture
Expand All @@ -36,7 +37,7 @@ def check_cache_file_path(p_file, p_cache):


def construct_from_board(board):
prot = board.fs.protocol
prot = fs_protocol(board.fs)
fs_name = prot if isinstance(prot, str) else prot[0]

if fs_name in ["file", ("file", "local")]:
Expand Down Expand Up @@ -192,15 +193,15 @@ def test_constructor_boards(board, df_csv, tmp_cache):

# check data
# TODO: update when dbc boards are not read-only
if board.fs.protocol == "dbc":
if fs_protocol(board.fs) == "dbc":
pass
else:
assert_frame_equal(df, df_csv)

# check the cache structure -----------------------------------------------

# check cache
if board.fs.protocol in ["file", ("file", "local")]:
if fs_protocol(board.fs) in ["file", ("file", "local")]:
# no caching for local file boards
pass
else:
Expand Down Expand Up @@ -239,7 +240,7 @@ def board2(backend):

@skip_if_dbc
def test_constructor_boards_multi_user(board2, df_csv, tmp_cache):
prot = board2.fs.protocol
prot = fs_protocol(board2.fs)
fs_name = prot if isinstance(prot, str) else prot[0]

if fs_name == "rsc":
Expand Down Expand Up @@ -296,7 +297,7 @@ def test_board_constructor_folder(tmp_path: Path, df):


def test_board_deparse(board):
prot = board.fs.protocol
prot = fs_protocol(board.fs)

with rm_env("CONNECT_API_KEY"):
if prot == "rsc":
Expand Down
11 changes: 11 additions & 0 deletions pins/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ def warn_deprecated(msg):
warn(msg, DeprecationWarning)


def fs_protocol(fs):
"""Return the protocol of a (possibly cache-wrapped) filesystem.

pins wraps a board's filesystem in a cache filesystem. Since fsspec 2025.9,
a cache filesystem reports its own protocol (e.g. "pinscache") rather than
delegating to the wrapped filesystem, so unwrap it to recover the board's
underlying protocol.
"""
return getattr(fs, "fs", fs).protocol


def hash_name(path, same_name):
if same_name:
_hash = os.path.basename(path)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ requires-python = ">=3.9"
dynamic = ["version"]
dependencies = [
"appdirs<2", # Using appdirs rather than platformdirs is deliberate, see https://github.com/rstudio/pins-python/pull/239
"fsspec>=2022.2,<2025.9",
"fsspec>=2022.2",
"humanize>=1",
"importlib-metadata>=4.4",
"importlib-resources>=1.3",
Expand Down
Loading