Skip to content

Commit ae74ee2

Browse files
Adding async variants to various FusionFS methods. (#109)
Completing async variants of FFS methods with some tests and documentation
1 parent 3cbd48c commit ae74ee2

10 files changed

Lines changed: 1361 additions & 496 deletions

File tree

Cargo.lock

Lines changed: 811 additions & 471 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "pyfusion"
3-
version = "2.0.8"
3+
version = "2.0.9-dev0"
44
edition = "2021"
55

66

docs/fusion_filesystem.ipynb

Lines changed: 389 additions & 0 deletions
Large diffs are not rendered by default.

py_src/fusion/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
__author__ = """Fusion Devs"""
44
__email__ = "fusion_developers@jpmorgan.com"
5-
__version__ = "2.0.8"
5+
__version__ = "2.0.9-dev0"
66

77
from fusion._fusion import FusionCredentials
88
from fusion.fs_sync import fsync

py_src/fusion/embeddings.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,29 +46,29 @@ class FusionEmbeddingsConnection(Connection): # type: ignore
4646
- Provides methods for modifying and validating URLs specific to the Fusion Embedding API.
4747
4848
Args:
49-
http_auth (str or tuple, optional): HTTP auth information as either ':' separated string or a tuple.
49+
http_auth (str or tuple, optional): HTTP auth information as either ':' separated string or a tuple.
5050
Any value will be passed into requests as `auth`.
5151
use_ssl (bool, optional): Use SSL for the connection if `True`.
5252
verify_certs (bool, optional): Whether to verify SSL certificates.
5353
ssl_show_warn (bool, optional): Show warning when verify certs is disabled.
54-
ca_certs (str, optional): Path to CA bundle. Defaults to configured OpenSSL bundles from environment variables
55-
and then certifi before falling back to the standard requests bundle to improve consistency with other
54+
ca_certs (str, optional): Path to CA bundle. Defaults to configured OpenSSL bundles from environment variables
55+
and then certifi before falling back to the standard requests bundle to improve consistency with other
5656
Connection implementations.
57-
client_cert (str, optional): Path to the file containing the private key and the certificate,
57+
client_cert (str, optional): Path to the file containing the private key and the certificate,
5858
or cert only if using client_key.
59-
client_key (str, optional): Path to the file containing the private key if using separate cert and key files
59+
client_key (str, optional): Path to the file containing the private key if using separate cert and key files
6060
(client_cert will contain only the cert).
6161
headers (dict, optional): Any custom HTTP headers to be added to requests.
6262
http_compress (bool, optional): Use gzip compression.
63-
opaque_id (str, optional): Send this value in the 'X-Opaque-Id' HTTP header for tracing
63+
opaque_id (str, optional): Send this value in the 'X-Opaque-Id' HTTP header for tracing
6464
all requests made by this transport.
65-
pool_maxsize (int, optional): Maximum connection pool size used by pool-manager
65+
pool_maxsize (int, optional): Maximum connection pool size used by pool-manager
6666
for custom connection-pooling on current session.
67-
metrics (Metrics, optional): Instance of a subclass of the `opensearchpy.Metrics` class,
67+
metrics (Metrics, optional): Instance of a subclass of the `opensearchpy.Metrics` class,
6868
used for collecting and reporting metrics related to the client's operations.
6969
7070
Keyword Args:
71-
root_url (str, optional): Root URL for the Fusion API. Defaults to
71+
root_url (str, optional): Root URL for the Fusion API. Defaults to
7272
"https://fusion.jpmorgan.com/api/v1/".
7373
credentials (FusionCredentials or str, optional): Credentials for the Fusion API. Can be a `FusionCredentials`
7474
object or a path to a credentials file. Defaults to "config/client_credentials.json".

py_src/fusion/fusion.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,13 +215,19 @@ def _use_catalog(self, catalog: str | None) -> str:
215215

216216
return catalog
217217

218-
def get_fusion_filesystem(self) -> FusionHTTPFileSystem:
219-
"""Creates Fusion Filesystem.
218+
def get_fusion_filesystem(self, **kwargs: Any) -> FusionHTTPFileSystem:
219+
"""Retrieve Fusion file system instance.
220+
221+
Note: This function always returns a reference to the exact same FFS instance since
222+
an FFS instance is based off the FusionCredentials object.
220223
221224
Returns: Fusion Filesystem
222225
223226
"""
224-
return FusionHTTPFileSystem(client_kwargs={"root_url": self.root_url, "credentials": self.credentials})
227+
as_async = kwargs.get("asynchronous", False)
228+
return FusionHTTPFileSystem(
229+
asynchronous=as_async, client_kwargs={"root_url": self.root_url, "credentials": self.credentials}
230+
)
225231

226232
def list_catalogs(self, output: bool = False) -> pd.DataFrame:
227233
"""Lists the catalogs available to the API account.

py_src/fusion/fusion_filesystem.py

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,21 @@ async def _async_raise_not_found_for_status(self, response: Any, url: str) -> No
8686
finally:
8787
self._raise_not_found_for_status(response, url)
8888

89+
def _check_session_open(self) -> bool:
90+
# Check that _session is active. Expects that if _session is populated with .set_session, result
91+
# result was already awaited
92+
if self._session is None:
93+
return False
94+
return not self._session.closed
95+
96+
async def _async_startup(self) -> None:
97+
await self.set_session()
98+
if not self._check_session_open():
99+
raise RuntimeError("FusionFS session closed before operation")
100+
89101
async def _decorate_url_a(self, url: str) -> str:
90102
url = urljoin(f"{self.client_kwargs['root_url']}catalogs/", url) if "http" not in url else url
103+
url = url[:-1] if url[-1] == "/" else url
91104
return url
92105

93106
def _decorate_url(self, url: str) -> str:
@@ -105,6 +118,16 @@ async def _isdir(self, path: str) -> bool:
105118
return False
106119

107120
async def _changes(self, url: str) -> dict[Any, Any]:
121+
"""Get from given url.
122+
123+
Currently called within the context of the /changes api endpoint.
124+
125+
Args:
126+
url: str
127+
128+
Returns:
129+
Dict containing json-ified return from endpoint.
130+
"""
108131
url = self._decorate_url(url)
109132
try:
110133
session = await self.set_session()
@@ -120,7 +143,7 @@ async def _changes(self, url: str) -> dict[Any, Any]:
120143
logger.log(VERBOSE_LVL, f"Artificial error, {ex}")
121144
raise ex
122145

123-
async def _ls_real(self, url: str, detail: bool = True, **kwargs: Any) -> Any:
146+
async def _ls_real(self, url: str, detail: bool = False, **kwargs: Any) -> Any:
124147
# ignoring URL-encoded arguments
125148
clean_url = url
126149
if "http" not in url:
@@ -195,9 +218,21 @@ def info(self, path: str, **kwargs: Any) -> Any:
195218

196219
return res
197220

198-
async def _ls(self, url: str, detail: bool = True, **kwargs: Any) -> Any:
199-
url = await self._decorate_url_a(url)
200-
return await super()._ls(url, detail, **kwargs)
221+
async def _info(self, path: str, **kwargs: Any) -> Any:
222+
await self._async_startup()
223+
path = await self._decorate_url_a(path)
224+
kwargs["keep_protocol"] = True
225+
res = await super()._ls(path, detail=True, **kwargs)
226+
if res[0]["type"] != "file":
227+
res = await super()._info(path, **kwargs)
228+
if path.split("/")[-2] == "datasets":
229+
target = path.split("/")[-1]
230+
args = ["/".join(path.split("/")[:-1]) + f"/changes?datasets={quote(target)}"]
231+
res["changes"] = await self._changes(*args)
232+
split_path = path.split("/")
233+
if len(split_path) > 1 and split_path[-2] == "distributions":
234+
res = res[0]
235+
return res
201236

202237
def ls(self, url: str, detail: bool = False, **kwargs: Any) -> Any:
203238
"""List resources.
@@ -223,7 +258,21 @@ def ls(self, url: str, detail: bool = False, **kwargs: Any) -> Any:
223258

224259
return ret
225260

226-
def exists(self, url: str, detail: bool = True, **kwargs: Any) -> Any:
261+
async def _ls(self, url: str, detail: bool = False, **kwargs: Any) -> Any:
262+
await self._async_startup()
263+
url = await self._decorate_url_a(url)
264+
ret = await super()._ls(url, detail, **kwargs)
265+
keep_protocol = kwargs.pop("keep_protocol", False)
266+
if detail:
267+
if not keep_protocol:
268+
for k in ret:
269+
k["name"] = k["name"].split(f"{self.client_kwargs['root_url']}catalogs/")[-1]
270+
elif not keep_protocol:
271+
return [x.split(f"{self.client_kwargs['root_url']}catalogs/")[-1] for x in ret]
272+
273+
return ret
274+
275+
def exists(self, url: str, **kwargs: Any) -> Any:
227276
"""Check existence.
228277
229278
Args:
@@ -235,7 +284,13 @@ def exists(self, url: str, detail: bool = True, **kwargs: Any) -> Any:
235284
236285
"""
237286
url = self._decorate_url(url)
238-
return super().exists(url, detail, **kwargs)
287+
return super().exists(url, **kwargs)
288+
289+
async def _exists(self, url: str, **kwargs: Any) -> Any:
290+
await self._async_startup()
291+
url = self._decorate_url(url)
292+
out = await super()._exists(url, **kwargs)
293+
return out
239294

240295
def isfile(self, path: str) -> Union[bool, Any]:
241296
"""Is path a file.
@@ -264,6 +319,12 @@ def cat(self, url: str, start: Optional[int] = None, end: Optional[int] = None,
264319
url = self._decorate_url(url)
265320
return super().cat(url, start=start, end=end, **kwargs)
266321

322+
async def _cat(self, url: str, start: Optional[int] = None, end: Optional[int] = None, **kwargs: Any) -> Any:
323+
await self._async_startup()
324+
url = self._decorate_url(url)
325+
out = await super()._cat(url, start=start, end=end, **kwargs)
326+
return out
327+
267328
async def _fetch_range(
268329
self,
269330
session: aiohttp.ClientSession,
@@ -836,6 +897,12 @@ def find(self, path: str, maxdepth: Optional[int] = None, withdirs: bool = False
836897
path = self._decorate_url(path)
837898
return super().find(path, maxdepth=maxdepth, withdirs=withdirs, **kwargs)
838899

900+
async def _find(self, path: str, maxdepth: Optional[int] = None, withdirs: bool = False, **kwargs: Any) -> Any:
901+
await self._async_startup()
902+
path = self._decorate_url(path)
903+
out = await super()._find(path, maxdepth=maxdepth, withdirs=withdirs, **kwargs)
904+
return out
905+
839906
def glob(self, path: str, **kwargs: Any) -> Any:
840907
"""Glob.
841908
@@ -849,6 +916,10 @@ def glob(self, path: str, **kwargs: Any) -> Any:
849916

850917
return super().glob(path, **kwargs)
851918

919+
async def _glob(self, path: str, **kwargs: Any) -> Any:
920+
out = await super()._glob(path, **kwargs)
921+
return out
922+
852923
def open(
853924
self,
854925
path: str,

py_tests/test_filesystem.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,19 +101,78 @@ async def test_decorate_url_with_http_async(http_fs_instance: FusionHTTPFileSyst
101101
@pytest.mark.asyncio
102102
async def test_isdir_true(http_fs_instance: FusionHTTPFileSystem) -> None:
103103
http_fs_instance._decorate_url = AsyncMock(return_value="decorated_path_dir") # type: ignore
104-
http_fs_instance._info = AsyncMock(return_value={"type": "directory"})
104+
http_fs_instance._info = AsyncMock(return_value={"type": "directory"}) # type: ignore
105105
result = await http_fs_instance._isdir("path_dir")
106106
assert result
107107

108108

109109
@pytest.mark.asyncio
110110
async def test_isdir_false(http_fs_instance: FusionHTTPFileSystem) -> None:
111111
http_fs_instance._decorate_url = AsyncMock(return_value="decorated_path_file") # type: ignore
112-
http_fs_instance._info = AsyncMock(return_value={"type": "file"})
112+
http_fs_instance._info = AsyncMock(return_value={"type": "file"}) # type: ignore
113113
result = await http_fs_instance._isdir("path_file")
114114
assert not result
115115

116116

117+
@pytest.mark.asyncio
118+
async def test_check_sess_open(http_fs_instance: FusionHTTPFileSystem) -> None:
119+
new_fs_session_closed = not http_fs_instance._check_session_open()
120+
assert new_fs_session_closed
121+
122+
# Corresponds to running .set_session()
123+
session_mock = MagicMock()
124+
session_mock.closed = False
125+
http_fs_instance._session = session_mock
126+
fs_session_open = http_fs_instance._check_session_open()
127+
assert fs_session_open
128+
129+
# Corresponds to situation where session was closed
130+
session_mock2 = MagicMock()
131+
session_mock2.closed = True
132+
http_fs_instance._session = session_mock2
133+
fs_session_closed = not http_fs_instance._check_session_open()
134+
assert fs_session_closed
135+
136+
137+
@pytest.mark.asyncio
138+
async def test_async_startup(http_fs_instance: FusionHTTPFileSystem) -> None:
139+
http_fs_instance._session = None
140+
with (
141+
patch("fusion.fusion_filesystem.FusionHTTPFileSystem.set_session") as SetSessionMock,
142+
pytest.raises(RuntimeError) as re,
143+
):
144+
await http_fs_instance._async_startup()
145+
SetSessionMock.assert_called_once()
146+
assert re.match("FusionFS session closed before operation")
147+
148+
# Mock an open session
149+
MockClient = MagicMock()
150+
MockClient.closed = False
151+
http_fs_instance._session = MockClient
152+
with patch("fusion.fusion_filesystem.FusionHTTPFileSystem.set_session") as SetSessionMock2:
153+
await http_fs_instance._async_startup()
154+
SetSessionMock2.assert_called_once()
155+
156+
157+
@pytest.mark.asyncio
158+
async def test_exists_methods(http_fs_instance: FusionHTTPFileSystem) -> None:
159+
with patch("fusion.fusion_filesystem.HTTPFileSystem.exists") as MockExists:
160+
MockExists.return_value = True
161+
exists_out = http_fs_instance.exists("dummy_path")
162+
MockExists.assert_called_once()
163+
assert exists_out
164+
165+
with (
166+
patch("fusion.fusion_filesystem.HTTPFileSystem._exists") as Mock_Exists,
167+
patch("fusion.fusion_filesystem.FusionHTTPFileSystem._async_startup") as MockStartup,
168+
):
169+
Mock_Exists.return_value = True
170+
_exists_out = await http_fs_instance._exists("dummy_path")
171+
Mock_Exists.assert_awaited_once()
172+
MockStartup.assert_awaited_once()
173+
assert _exists_out
174+
175+
117176
@patch("requests.Session")
118177
def test_stream_single_file(mock_session_class: MagicMock, example_creds_dict: dict[str, Any], tmp_path: Path) -> None:
119178
url = "http://example.com/data"

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pyfusion"
3-
version = "2.0.8"
3+
version = "2.0.9-dev0"
44

55
homepage = "https://github.com/jpmorganchase/fusion"
66
description = "JPMC Fusion Developer Tools"
@@ -241,7 +241,7 @@ exclude_lines = [
241241

242242

243243
[tool.bumpversion]
244-
current_version = "2.0.8"
244+
current_version = "2.0.9-dev0"
245245
parse = '(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<release>[a-z]+)(?P<candidate>\d+))?'
246246
serialize = [
247247
'{major}.{minor}.{patch}-{release}{candidate}',

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)