Skip to content

Commit f50f976

Browse files
authored
Merge pull request #25 from machow/feat-pin-delete
feat: implement pin_delete, pin_version_delete
2 parents 571dab8 + aaef12e commit f50f976

File tree

6 files changed

+199
-15
lines changed

6 files changed

+199
-15
lines changed

docs/api/boards.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ Methods (Implemented)
2323
~BaseBoard.pin_versions
2424
~BaseBoard.pin_list
2525
~BaseBoard.pin_exists
26+
~BaseBoard.pin_version_delete
27+
~BaseBoard.pin_versions_prune
28+
~BaseBoard.pin_delete
2629

2730
Methods (Planned)
2831
-----------------
@@ -31,8 +34,5 @@ Methods (Planned)
3134

3235
~BaseBoard.pin_download
3336
~BaseBoard.pin_upload
34-
~BaseBoard.pin_version_delete
35-
~BaseBoard.pin_versions_prune
3637
~BaseBoard.pin_search
37-
~BaseBoard.pin_delete
3838
~BaseBoard.pin_browse

pins/boards.py

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import tempfile
22
import shutil
3+
import inspect
34

45
from io import IOBase
56
from functools import cached_property
67
from pathlib import Path
78
from importlib_resources import files
8-
from datetime import datetime
9+
from datetime import datetime, timedelta
910

1011
from typing import Protocol, Sequence, Optional, Mapping
1112

@@ -253,7 +254,14 @@ def pin_write(
253254
"but that directory already exists."
254255
)
255256

256-
self.fs.put(tmp_dir, dst_version_path, recursive=True)
257+
res = self.fs.put(tmp_dir, dst_version_path, recursive=True)
258+
259+
if dst_version_path == dst_pin_path:
260+
# TODO(refactor): this is a RSConnect specific hack
261+
# since we don't know the bundle id ahead of time, the meta version
262+
# object is incorrect. Could fix through the meta_factory
263+
bundle_version = VersionRaw(res.split("/")[-1])
264+
meta.version = bundle_version
257265

258266
return meta
259267

@@ -289,7 +297,7 @@ def pin_upload(self, paths, name=None, title=None, description=None, metadata=No
289297
raise NotImplementedError()
290298

291299
def pin_version_delete(self, name: str, version: str):
292-
"""TODO: Delete a single version of a pin.
300+
"""Delete a single version of a pin.
293301
294302
Parameters
295303
----------
@@ -298,10 +306,16 @@ def pin_version_delete(self, name: str, version: str):
298306
version:
299307
Version identifier.
300308
"""
301-
raise NotImplementedError()
302309

303-
def pin_versions_prune(self, name, n=None, days=None):
304-
"""TODO: Delete old versions of a pin.
310+
pin_name = self.path_to_pin(name)
311+
312+
pin_version_path = self.construct_path([pin_name, version])
313+
self.fs.rm(pin_version_path, recursive=True)
314+
315+
def pin_versions_prune(
316+
self, name, n: "int | None" = None, days: "int | None" = None
317+
):
318+
"""Delete old versions of a pin.
305319
306320
Parameters
307321
----------
@@ -318,7 +332,33 @@ def pin_versions_prune(self, name, n=None, days=None):
318332
the most recent version.
319333
320334
"""
321-
raise NotImplementedError()
335+
336+
if n is None and days is None:
337+
raise ValueError("Cannot specify both n and days.")
338+
339+
versions = self.pin_versions(name, as_df=False)
340+
if n is not None:
341+
if n <= 0:
342+
raise ValueError("Argument n is {n}, but must be greater than 0.")
343+
344+
to_delete = versions[:-n]
345+
if days is not None:
346+
if days <= 0:
347+
raise ValueError("Argument days is {days}, but must be greater than 0.")
348+
349+
date_cutoff = datetime.today() - timedelta(days=days)
350+
to_delete = [v for v in versions if v.created < date_cutoff]
351+
352+
# message user about deletions ----
353+
# TODO(question): how to pin_inform? Log or warning?
354+
if to_delete:
355+
str_vers = ", ".join([v.version for v in to_delete])
356+
print(f"Deleting versions: {str_vers}.")
357+
if not to_delete:
358+
print("No old versions to delete")
359+
360+
for version in to_delete:
361+
self.pin_version_delete(name, version.version)
322362

323363
def pin_search(self, search=None):
324364
"""TODO: Search for pins.
@@ -334,15 +374,24 @@ def pin_search(self, search=None):
334374
"""
335375
raise NotImplementedError()
336376

337-
def pin_delete(self, names):
377+
def pin_delete(self, names: "str | Sequence[str]"):
338378
"""TODO: Delete a pin (or pins), removing it from the board.
339379
340380
Parameters
341381
----------
342382
names:
343383
The names of one or more pins to delete.
344384
"""
345-
raise NotImplementedError()
385+
386+
if isinstance(names, str):
387+
names = [names]
388+
389+
for name in names:
390+
if not self.pin_exists(name):
391+
raise PinsError("Cannot delete pin, since pin %s does not exist" % name)
392+
393+
pin_name = self.path_to_pin(name)
394+
self.fs.rm(pin_name, recursive=True)
346395

347396
def pin_browse(self, name, version=None, local=False):
348397
"""TODO: Navigate to the home of a pin, either on the internet or locally.
@@ -460,6 +509,25 @@ def pin_list(self):
460509
names = [f"{cont['owner_username']}/{cont['name']}" for cont in results]
461510
return names
462511

512+
def pin_version_delete(self, *args, **kwargs):
513+
from pins.rsconnect.api import RsConnectApiRequestError
514+
515+
try:
516+
super().pin_version_delete(*args, **kwargs)
517+
except RsConnectApiRequestError as e:
518+
if e.args[0]["code"] != 75:
519+
raise e
520+
521+
raise PinsError("RStudio Connect cannot delete the latest pin version.")
522+
523+
def pin_versions_prune(self, *args, **kwargs):
524+
sig = inspect.signature(super().pin_versions_prune)
525+
if sig.bind(*args, **kwargs).arguments.get("days") is not None:
526+
raise NotImplementedError(
527+
"RStudio Connect board cannot prune versions using days."
528+
)
529+
super().pin_versions_prune(*args, **kwargs)
530+
463531
def validate_pin_name(self, name) -> None:
464532
if name.count("/") > 1:
465533
raise ValueError(f"Invalid pin name: {name}")

pins/drivers.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import builtins
2+
13
from .meta import Meta
24

5+
36
# TODO: move IFileSystem out of boards, to fix circular import
47
# from .boards import IFileSystem
58

@@ -71,7 +74,7 @@ def default_title(obj, type):
7174
shape_str = " x ".join(map(str, obj.shape))
7275
return f"A pinned {shape_str} CSV"
7376
raise NotImplementedError(
74-
f"No default csv title support for class: {type(obj)}"
77+
f"No default csv title support for class: {builtins.type(obj)}"
7578
)
7679

7780
raise NotImplementedError(f"Cannot create default title for type: {type}")

pins/tests/test_boards.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import pytest
22
import pandas as pd
3+
import uuid
34

45
from pins.tests.helpers import DEFAULT_CREATION_DATE
6+
from pins.errors import PinsError
7+
8+
from datetime import datetime, timedelta
9+
from time import sleep
510

611
# using pytest cases, so that we can pass in fixtures as parameters
712
from pytest_cases import fixture, parametrize
@@ -18,7 +23,7 @@ def df():
1823
return pd.DataFrame({"x": [1, 2, 3], "y": ["a", "b", "c"]})
1924

2025

21-
# High level pins functionality -----------------------------------------------
26+
# pin_write ===================================================================
2227

2328

2429
def test_board_pin_write_default_title(board):
@@ -110,3 +115,106 @@ def test_board_pin_write_type(board, obj, type_, request):
110115
assert obj.equals(dst_obj)
111116

112117
obj == dst_obj
118+
119+
120+
# pin_delete ==================================================================
121+
122+
123+
@pytest.fixture
124+
def pin_name():
125+
return str(uuid.uuid4())
126+
127+
128+
@pytest.fixture
129+
def pin_del(board, df, pin_name):
130+
meta_old = board.pin_write(df, pin_name, type="csv", title="some title")
131+
sleep(1)
132+
meta_new = board.pin_write(df, pin_name, type="csv", title="some title")
133+
134+
assert len(board.pin_versions(pin_name)) == 2
135+
assert meta_old.version.version != meta_new.version.version
136+
137+
return meta_old, meta_new
138+
139+
140+
@pytest.fixture
141+
def pin_prune(board, df, pin_name):
142+
today = datetime.now()
143+
day_ago = today - timedelta(days=1, minutes=1)
144+
two_days_ago = today - timedelta(days=2, minutes=1)
145+
146+
board.pin_write(df, pin_name, type="csv", title="some title", created=today)
147+
board.pin_write(df, pin_name, type="csv", title="some title", created=day_ago)
148+
board.pin_write(df, pin_name, type="csv", title="some title", created=two_days_ago)
149+
150+
versions = board.pin_versions(pin_name, as_df=False)
151+
assert len(versions) == 3
152+
153+
return versions
154+
155+
156+
def test_board_pin_delete(board, df, pin_name, pin_del):
157+
board.pin_delete(pin_name)
158+
159+
assert board.pin_exists(pin_name) is False
160+
161+
162+
def test_board_pin_version_delete_older(board, pin_name, pin_del):
163+
meta_old, meta_new = pin_del
164+
165+
board.pin_version_delete(pin_name, meta_old.version.version)
166+
df_versions = board.pin_versions(pin_name)
167+
168+
# Note that using `in` on a pandas Series checks against the index :/
169+
assert meta_old.version.version not in df_versions.version.values
170+
assert meta_new.version.version in df_versions.version.values
171+
172+
173+
def test_board_pin_version_delete_latest(board, pin_name, pin_del):
174+
meta_old, meta_new = pin_del
175+
176+
if board.fs.protocol == "rsc":
177+
with pytest.raises(PinsError) as exc_info:
178+
board.pin_version_delete(pin_name, meta_new.version.version)
179+
180+
"cannot delete the latest pin version" in exc_info.value.args[0]
181+
return
182+
183+
board.pin_version_delete(pin_name, meta_new.version.version)
184+
df_versions = board.pin_versions(pin_name)
185+
186+
# Note that using `in` on a pandas Series checks against the index :/
187+
assert meta_old.version.version in df_versions.version.values
188+
assert meta_new.version.version not in df_versions.version.values
189+
190+
191+
@pytest.mark.parametrize("n", [1, 2])
192+
def test_board_pin_versions_prune_n(board, pin_prune, pin_name, n):
193+
194+
board.pin_versions_prune(pin_name, n=n)
195+
new_versions = board.pin_versions(pin_name, as_df=False)
196+
197+
assert len(new_versions) == n
198+
199+
# TODO(compat): versions are currently reversed from R pins, with latest last
200+
# so we need to reverse to check the n latest versions
201+
rev_vers = list(reversed(pin_prune))
202+
for ii, v in enumerate(reversed(new_versions)):
203+
assert rev_vers[ii].version == v.version
204+
205+
206+
@pytest.mark.parametrize("days", [1, 2])
207+
def test_board_pin_versions_prune_days(board, pin_prune, pin_name, days):
208+
209+
# RStudio cannot handle days, since it involves pulling metadata
210+
if board.fs.protocol == "rsc":
211+
with pytest.raises(NotImplementedError):
212+
board.pin_versions_prune(pin_name, days=days)
213+
return
214+
215+
board.pin_versions_prune(pin_name, days=days)
216+
217+
new_versions = board.pin_versions(pin_name, as_df=False)
218+
219+
# each of the 3 versions adds an 1 more day + 1 min
220+
assert len(new_versions) == days

pins/tests/test_meta.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def meta():
2525
return Meta(**META_DEFAULTS)
2626

2727

28+
@pytest.mark.xfail
2829
def test_meta_to_dict_is_recursive(meta):
2930
d_meta = meta.to_dict()
3031
assert d_meta["version"] == meta.version.to_dict()

pins/versions.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ class Version(_VersionBase):
2828
hash: str
2929

3030
def to_dict(self) -> Mapping:
31-
return asdict(self)
31+
# properties not automatically added, so need to handle manually
32+
res = asdict(self)
33+
res["version"] = self.version
34+
35+
return res
3236

3337
@property
3438
def version(self) -> str:

0 commit comments

Comments
 (0)