Skip to content

Commit a6fc8fb

Browse files
Support adaptor in prepare_pin_version
1 parent 4d87d9f commit a6fc8fb

File tree

3 files changed

+137
-9
lines changed

3 files changed

+137
-9
lines changed

pins/adaptors.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
from abc import abstractmethod
4+
from typing import TYPE_CHECKING, Any, ClassVar, Self, TypeAlias, overload
5+
6+
from ._databackend import AbstractBackend
7+
8+
if TYPE_CHECKING:
9+
import pandas as pd
10+
11+
_PandasDataFrame: TypeAlias = pd.DataFrame
12+
_DataFrame: TypeAlias = pd.DataFrame
13+
14+
15+
class _AbstractPandasFrame(AbstractBackend):
16+
_backends = [("pandas", "DataFrame")]
17+
18+
19+
_AbstractDF: TypeAlias = _AbstractPandasFrame
20+
21+
22+
class _Adaptor:
23+
_d: ClassVar[Any]
24+
25+
def __init__(self, data: Any) -> None:
26+
self._d = data
27+
28+
29+
class _DFAdaptor(_Adaptor):
30+
_d: ClassVar[_DataFrame]
31+
32+
def __init__(self, data: _DataFrame) -> None:
33+
super().__init__(data)
34+
35+
@property
36+
@abstractmethod
37+
def columns(self) -> list[Any]: ...
38+
39+
@abstractmethod
40+
def head(self, n: int) -> Self: ...
41+
42+
@abstractmethod
43+
def write_json(self) -> str:
44+
"""Write the dataframe to a JSON string.
45+
46+
In the format: list like [{column -> value}, ... , {column -> value}]
47+
"""
48+
49+
50+
class _PandasAdaptor(_DFAdaptor):
51+
def __init__(self, data: _AbstractPandasFrame) -> None:
52+
super().__init__(data)
53+
54+
@property
55+
def columns(self) -> list[Any]:
56+
return self._d.columns
57+
58+
def head(self, n: int) -> Self:
59+
return _PandasAdaptor(self._d.head(n))
60+
61+
def write_json(self) -> str:
62+
return self._d.to_json(orient="records")
63+
64+
65+
@overload
66+
def _create_df_adaptor(df: _DataFrame) -> _DFAdaptor: ...
67+
@overload
68+
def _create_df_adaptor(df: _PandasDataFrame) -> _PandasAdaptor: ...
69+
def _create_df_adaptor(df):
70+
if isinstance(df, _AbstractPandasFrame):
71+
return _PandasAdaptor(df)
72+
73+
msg = f"Could not determine dataframe adaptor for {df}"
74+
raise NotImplementedError(msg)

pins/boards.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
from datetime import datetime, timedelta
88
from io import IOBase
99
from pathlib import Path
10-
from typing import Mapping, Optional, Protocol, Sequence
10+
from typing import Any, Mapping, Optional, Protocol, Sequence
1111

1212
from importlib_resources import files
1313

14+
from .adaptors import _create_df_adaptor, _DFAdaptor
1415
from .cache import PinsCache
1516
from .config import get_allow_rsc_short_name
1617
from .drivers import default_title, load_data, load_file, save_data
@@ -1121,15 +1122,24 @@ def path_to_deploy_version(self, name: str, version: str):
11211122
def user_name(self):
11221123
return self.fs.api.get_user()["username"]
11231124

1125+
# TODO(NAMC) what about the functions that call this one?
11241126
def prepare_pin_version(self, pin_dir_path, x, name: "str | None", *args, **kwargs):
1127+
try:
1128+
x = _create_df_adaptor(x)
1129+
except NotImplementedError:
1130+
# Not a dataframe.
1131+
pass
1132+
11251133
# RSC pin names can have form <user_name>/<name>, but this will try to
11261134
# create the object in a directory named <user_name>. So we grab just
11271135
# the <name> part.
11281136
short_name = name.split("/")[-1]
11291137

11301138
# TODO(compat): py pins always uses the short name, R pins uses w/e the
11311139
# user passed, but guessing people want the long name?
1132-
meta = super()._create_meta(pin_dir_path, x, short_name, *args, **kwargs)
1140+
meta = super()._create_meta(
1141+
pin_dir_path, x, short_name, *args, **kwargs
1142+
) # TODO(NAMC) ensure .create_meta can accept adaptor
11331143
meta.name = name
11341144

11351145
# copy in files needed by index.html ----------------------------------
@@ -1147,7 +1157,7 @@ def prepare_pin_version(self, pin_dir_path, x, name: "str | None", *args, **kwar
11471157
# render index.html ------------------------------------------------
11481158

11491159
all_files = [meta.file] if isinstance(meta.file, str) else meta.file
1150-
pin_files = ", ".join(f"""<a href="{x}">{x}</a>""" for x in all_files)
1160+
pin_files = ", ".join(f"""<a href="{file}">{file}</a>""" for file in all_files)
11511161

11521162
context = {
11531163
"date": meta.version.created.replace(microsecond=0),
@@ -1164,15 +1174,13 @@ def prepare_pin_version(self, pin_dir_path, x, name: "str | None", *args, **kwar
11641174

11651175
import json
11661176

1167-
import pandas as pd
1168-
1169-
if isinstance(x, pd.DataFrame):
1177+
if isinstance(x, _DFAdaptor):
11701178
# TODO(compat) is 100 hard-coded?
1171-
# Note that we go df -> json -> dict, to take advantage of pandas type conversions
1172-
data = json.loads(x.head(100).to_json(orient="records"))
1179+
# Note that we go df -> json -> dict, to take advantage of type conversions in the dataframe library
1180+
data: list[dict[Any, Any]] = json.loads(x.head(100).write_json())
11731181
columns = [
11741182
{"name": [col], "label": [col], "align": ["left"], "type": [""]}
1175-
for col in x
1183+
for col in x.columns
11761184
]
11771185

11781186
# this reproduces R pins behavior, by omitting entries that would be null

pins/tests/test_adaptors.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import pandas as pd
2+
import pytest
3+
from pandas.testing import assert_frame_equal, assert_index_equal
4+
5+
from pins.adaptors import _AbstractPandasFrame, _create_df_adaptor, _PandasAdaptor
6+
7+
8+
class TestCreateDFAdaptor:
9+
def test_pandas(self):
10+
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
11+
adaptor = _create_df_adaptor(df)
12+
assert isinstance(adaptor, _PandasAdaptor)
13+
14+
def test_non_df(self):
15+
with pytest.raises(NotImplementedError):
16+
_create_df_adaptor(42)
17+
18+
19+
class TestPandasAdaptor:
20+
def test_columns(self):
21+
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
22+
adaptor = _create_df_adaptor(df)
23+
assert_index_equal(adaptor.columns, pd.Index(["a", "b"]))
24+
25+
def test_head(self):
26+
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
27+
adaptor = _create_df_adaptor(df)
28+
head1_df = pd.DataFrame({"a": [1], "b": [4]})
29+
expected = _create_df_adaptor(head1_df)
30+
assert isinstance(adaptor.head(1), _PandasAdaptor)
31+
assert_frame_equal(adaptor.head(1)._d, expected._d)
32+
33+
def test_write_json(self):
34+
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
35+
adaptor = _create_df_adaptor(df)
36+
assert adaptor.write_json() == """[{"a":1,"b":4},{"a":2,"b":5},{"a":3,"b":6}]"""
37+
38+
39+
class TestAbstractBackends:
40+
class TestAbstractPandasFrame:
41+
def test_isinstance(self):
42+
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
43+
assert isinstance(df, _AbstractPandasFrame)
44+
45+
def test_not_isinstance(self):
46+
assert not isinstance(42, _AbstractPandasFrame)

0 commit comments

Comments
 (0)