Skip to content

Commit 14e8dea

Browse files
authored
support unversioned boards (#230)
* support unversioned boards * rsconnect boards cannot delete active version * clean up function sig * add versioned arg * updates from review * add tests * try to make smart-ish API calls for Connect * use test value * don't need to test the error twice * also, don't need to read pin
1 parent 5806a71 commit 14e8dea

File tree

4 files changed

+142
-43
lines changed

4 files changed

+142
-43
lines changed

pins/boards.py

Lines changed: 69 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111

1212
from typing import Sequence, Optional, Mapping
1313

14-
from .versions import VersionRaw, guess_version
14+
from .versions import VersionRaw, guess_version, version_setup
1515
from .meta import Meta, MetaRaw, MetaFactory
16-
from .errors import PinsError
16+
from .errors import PinsError, PinsVersionError
1717
from .drivers import load_data, save_data, load_file, default_title
1818
from .utils import inform, warn_deprecated, ExtendMethodDoc
1919
from .config import get_allow_rsc_short_name
@@ -90,16 +90,14 @@ def pin_versions(self, name: str, as_df: bool = True) -> Sequence[VersionRaw]:
9090
Pin name.
9191
9292
"""
93-
if not self.versioned:
94-
raise NotImplementedError(
95-
"Cannot show versions for a board type that does not support versioning."
96-
)
9793

9894
if not self.pin_exists(name):
9995
raise PinsError("Cannot check version, since pin %s does not exist" % name)
10096

97+
detail = isinstance(self, BoardRsConnect)
98+
10199
versions_raw = self.fs.ls(
102-
self.construct_path([self.path_to_pin(name)]), detail=False
100+
self.construct_path([self.path_to_pin(name)]), detail=detail
103101
)
104102

105103
# get a list of Version(Raw) objects
@@ -111,7 +109,6 @@ def pin_versions(self, name: str, as_df: bool = True) -> Sequence[VersionRaw]:
111109
# sort them, with latest last
112110
sorted_versions = self.sort_pin_versions(all_versions)
113111

114-
# TODO(defer): this deviates from R pins, which returns a df by default
115112
if as_df:
116113
import pandas as pd
117114

@@ -241,11 +238,6 @@ def _pin_store(
241238
created: Optional[datetime] = None,
242239
) -> Meta:
243240

244-
if not self.versioned:
245-
raise NotImplementedError(
246-
"Can only write pins with boards that support versioning."
247-
)
248-
249241
if type == "feather":
250242
warn_deprecated(
251243
'Writing pin type "feather" is unsupported. Switching type to "arrow".'
@@ -360,11 +352,6 @@ def pin_write(
360352
part of the pin version name.
361353
"""
362354

363-
if not self.versioned:
364-
raise NotImplementedError(
365-
"Can only write pins with boards that support versioning."
366-
)
367-
368355
if type == "file":
369356
raise NotImplementedError(
370357
".pin_write() does not support type='file'. "
@@ -463,10 +450,6 @@ def pin_version_delete(self, name: str, version: str):
463450
version:
464451
Version identifier.
465452
"""
466-
if not self.versioned:
467-
raise NotImplementedError(
468-
"Can only write pins with boards that support versioning."
469-
)
470453

471454
pin_name = self.path_to_pin(name)
472455

@@ -616,7 +599,7 @@ def pin_browse(self, name, version=None, local=False):
616599
def validate_pin_name(self, name: str) -> None:
617600
"""Raise an error if a pin name is not valid."""
618601

619-
if "/" in name:
602+
if name and "/" in name:
620603
raise ValueError(f"Invalid pin name: {name}")
621604
elif name in self.reserved_pin_names:
622605
raise ValueError(f"The pin name '{name}' is reserved for internal use.")
@@ -654,13 +637,41 @@ def prepare_pin_version(
654637
versioned: Optional[bool] = None,
655638
created: Optional[datetime] = None,
656639
object_name: Optional[str] = None,
640+
):
641+
meta = self._create_meta(
642+
pin_dir_path,
643+
x,
644+
name,
645+
type,
646+
title,
647+
description,
648+
metadata,
649+
versioned,
650+
created,
651+
object_name,
652+
)
653+
654+
# handle unversioned boards
655+
version_setup(self, name, meta.version, versioned)
656+
657+
return meta
658+
659+
def _create_meta(
660+
self,
661+
pin_dir_path,
662+
x,
663+
name: Optional[str] = None,
664+
type: Optional[str] = None,
665+
title: Optional[str] = None,
666+
description: Optional[str] = None,
667+
metadata: Optional[Mapping] = None,
668+
versioned: Optional[bool] = None,
669+
created: Optional[datetime] = None,
670+
object_name: Optional[str] = None,
657671
):
658672
if name is None:
659673
raise NotImplementedError("Name must be specified.")
660674

661-
if versioned is False:
662-
raise NotImplementedError("Only writing versioned pins supported.")
663-
664675
if type is None:
665676
raise NotImplementedError("Type argument is required.")
666677

@@ -770,15 +781,6 @@ def __init__(self, *args, pin_paths: dict, **kwargs):
770781

771782
self.pin_paths = pin_paths
772783

773-
# def pin_read(self, *args, **kwargs):
774-
# if not get_feature_preview():
775-
# raise NotImplementedError(
776-
# "pin_read with BoardManual is currently unimplemented. "
777-
# "See https://github.com/machow/pins-python/issues/59."
778-
# )
779-
780-
# return super().pin_read(*args, **kwargs)
781-
782784
@ExtendMethodDoc
783785
def pin_list(self):
784786
return list(self.pin_paths)
@@ -889,7 +891,9 @@ def pin_list(self):
889891
return names
890892

891893
@ExtendMethodDoc
892-
def pin_write(self, *args, access_type=None, **kwargs):
894+
def pin_write(
895+
self, *args, access_type=None, versioned: Optional[bool] = None, **kwargs
896+
):
893897
"""Write a pin.
894898
895899
Extends parent method in the following ways:
@@ -915,6 +919,28 @@ def pin_write(self, *args, access_type=None, **kwargs):
915919
" can write to it."
916920
)
917921

922+
# attempt to make the least number of API calls possible
923+
if versioned or versioned is None and self.versioned:
924+
# arbitrary number greater than 1
925+
n_versions_before = 100
926+
else:
927+
try:
928+
versions_df = self.pin_versions(pin_name, as_df=True)
929+
versions = versions_df["version"].to_list()
930+
n_versions_before = len(versions)
931+
except PinsError:
932+
# pin does not exist
933+
n_versions_before = 0
934+
935+
if versioned is None:
936+
versioned = True if n_versions_before > 1 else self.versioned
937+
938+
if versioned is False and n_versions_before > 1:
939+
raise PinsVersionError(
940+
"Pin is versioned, but you have requested a write without versions."
941+
"To un-version a pin, you must delete it"
942+
)
943+
918944
# run parent function ---
919945
meta = f_super(*args, **kwargs)
920946

@@ -928,6 +954,12 @@ def pin_write(self, *args, access_type=None, **kwargs):
928954
access_type=access_type or content["access_type"],
929955
)
930956

957+
# clean up non-active pins in the case of an unversioned board
958+
# a pin existed before the latest pin
959+
if versioned is False and n_versions_before == 1:
960+
_log.info(f"Replacing version '{versions}' with '{meta.version.version}'")
961+
self.pin_version_delete(pin_name, versions[0])
962+
931963
return meta
932964

933965
@ExtendMethodDoc
@@ -1067,7 +1099,7 @@ def prepare_pin_version(self, pin_dir_path, x, name: "str | None", *args, **kwar
10671099

10681100
# TODO(compat): py pins always uses the short name, R pins uses w/e the
10691101
# user passed, but guessing people want the long name?
1070-
meta = super().prepare_pin_version(pin_dir_path, x, short_name, *args, **kwargs)
1102+
meta = super()._create_meta(pin_dir_path, x, short_name, *args, **kwargs)
10711103
meta.name = name
10721104

10731105
# copy in files needed by index.html ----------------------------------

pins/tests/helpers.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ def get_param_from_env(entry):
114114

115115
return value
116116

117-
def create_tmp_board(self, src_board=None) -> BaseBoard:
117+
def create_tmp_board(self, src_board=None, versioned=True) -> BaseBoard:
118118
if self.fs_name == "gcs":
119119
opts = {"cache_timeout": 0}
120120
else:
@@ -136,7 +136,7 @@ def create_tmp_board(self, src_board=None) -> BaseBoard:
136136
fs.mkdir(board_name)
137137

138138
self.board_path_registry.append(board_name)
139-
return BaseBoard(board_name, fs=fs)
139+
return BaseBoard(board_name, fs=fs, versioned=versioned)
140140

141141
def teardown_board(self, board):
142142
board.fs.rm(board.board, recursive=True)
@@ -168,10 +168,10 @@ def __init__(self, fs_name, path=None, *args, **kwargs):
168168
self.fs_name = fs_name
169169
self.path = None
170170

171-
def create_tmp_board(self, src_board=None):
171+
def create_tmp_board(self, src_board=None, versioned=True):
172172
from pins.rsconnect.fs import PinBundleManifest # noqa
173173

174-
board = BoardRsConnect("", rsc_fs_from_key("derek"))
174+
board = BoardRsConnect("", rsc_fs_from_key("derek"), versioned=versioned)
175175

176176
if src_board is None:
177177
return board

pins/tests/test_boards.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from pins.tests.helpers import DEFAULT_CREATION_DATE, rm_env
88
from pins.config import PINS_ENV_INSECURE_READ
9-
from pins.errors import PinsError, PinsInsecureReadError
9+
from pins.errors import PinsError, PinsInsecureReadError, PinsVersionError
1010
from pins.meta import MetaRaw
1111

1212
from datetime import datetime, timedelta
@@ -31,6 +31,12 @@ def board(backend):
3131
backend.teardown()
3232

3333

34+
@fixture
35+
def board_unversioned(backend):
36+
yield backend.create_tmp_board(versioned=False)
37+
backend.teardown()
38+
39+
3440
@fixture
3541
def board_with_cache(backend):
3642
from pins.constructors import board as board_constructor, board_rsconnect
@@ -304,6 +310,36 @@ def test_board_pin_read_insecure_succeed_board_flag(board):
304310
board.pin_read("test_pin")
305311

306312

313+
# pin_write with unversioned boards ===========================================
314+
315+
316+
@pytest.mark.parametrize("versioned", [None, False])
317+
def test_board_unversioned_pin_write_unversioned(versioned, board_unversioned):
318+
board_unversioned.pin_write({"a": 1}, "test_pin", type="json", versioned=versioned)
319+
board_unversioned.pin_write({"a": 2}, "test_pin", type="json", versioned=versioned)
320+
321+
assert len(board_unversioned.pin_versions("test_pin")) == 1
322+
assert board_unversioned.pin_read("test_pin") == {"a": 2}
323+
324+
325+
def test_board_unversioned_pin_write_versioned(board_unversioned):
326+
board_unversioned.pin_write({"a": 1}, "test_pin", type="json", versioned=False)
327+
board_unversioned.pin_write({"a": 2}, "test_pin", type="json", versioned=True)
328+
329+
assert len(board_unversioned.pin_versions("test_pin")) == 2
330+
331+
332+
def test_board_versioned_pin_write_unversioned(board):
333+
# should fall back to the versioned setting of the board
334+
board.pin_write({"a": 1}, "test_pin", type="json")
335+
board.pin_write({"a": 2}, "test_pin", type="json")
336+
337+
with pytest.raises(PinsVersionError):
338+
board.pin_write({"a": 3}, "test_pin", type="json", versioned=False)
339+
340+
assert len(board.pin_versions("test_pin")) == 2
341+
342+
307343
# pin_delete ==================================================================
308344

309345

pins/versions.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
import logging
2+
13
from dataclasses import dataclass, asdict
24
from datetime import datetime
35
from xxhash import xxh64
46

57
from typing import Union, Sequence, Mapping
6-
78
from .errors import PinsVersionError
89
from ._types import StrOrFile, IOBase
910

11+
_log = logging.getLogger(__name__)
12+
1013
VERSION_TIME_FORMAT = "%Y%m%dT%H%M%SZ"
1114

1215

@@ -116,3 +119,31 @@ def guess_version(x: str):
116119
return Version.from_string(x)
117120
except PinsVersionError:
118121
return VersionRaw(x)
122+
123+
124+
def version_setup(board, name, new_version, versioned):
125+
if board.pin_exists(name):
126+
versions_df = board.pin_versions(name, as_df=True)
127+
versions = versions_df["version"].to_list()
128+
old_version = versions[-1]
129+
n_versions = len(versions)
130+
131+
else:
132+
n_versions = 0
133+
134+
# if pin does not have version specified, see if multiple pins on board/board's version
135+
if versioned is None:
136+
versioned = True if n_versions > 1 else board.versioned
137+
138+
if versioned or n_versions == 0:
139+
_log.info(f"Creating new version '{new_version}'")
140+
elif n_versions == 1:
141+
_log.info(f"Replacing version '{old_version}' with '{new_version}'")
142+
board.pin_version_delete(name, old_version)
143+
else:
144+
raise PinsVersionError(
145+
"Pin is versioned, but you have requested a write without versions."
146+
"To un-version a pin, you must delete it"
147+
)
148+
149+
return new_version

0 commit comments

Comments
 (0)