Skip to content

Commit a7fe298

Browse files
ajha-ssCopilot
andauthored
optimize management api calls (#110)
* optimize management api calls * remove duplicate calls * review comment * fix ci * fix ci mypy * review comments * Initial plan * Address PR review comments: fix parameter names, decorators, and exception handling Co-authored-by: ajha-ss <[email protected]> * add tests --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: ajha-ss <[email protected]>
1 parent a6e9efa commit a7fe298

File tree

4 files changed

+287
-36
lines changed

4 files changed

+287
-36
lines changed

singlestoredb/fusion/handlers/files.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]:
3232
for x in file_space.listdir(
3333
params['at_path'] or '/',
3434
recursive=params['recursive'],
35+
return_objects=False,
3536
):
3637
info = file_space.info(x)
3738
files.append(
@@ -47,6 +48,7 @@ def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]:
4748
res.set_rows([(x,) for x in file_space.listdir(
4849
params['at_path'] or '/',
4950
recursive=params['recursive'],
51+
return_objects=False,
5052
)])
5153

5254
if params['like']:

singlestoredb/management/files.py

Lines changed: 157 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@
1010
from abc import ABC
1111
from abc import abstractmethod
1212
from typing import Any
13+
from typing import cast
1314
from typing import Dict
1415
from typing import List
16+
from typing import Literal
1517
from typing import Optional
18+
from typing import overload
1619
from typing import Union
1720

1821
from .. import config
@@ -421,15 +424,36 @@ def is_dir(self, path: PathLike) -> bool:
421424
def is_file(self, path: PathLike) -> bool:
422425
pass
423426

424-
@abstractmethod
427+
@overload
425428
def listdir(
426429
self,
427430
path: PathLike = '/',
428431
*,
429432
recursive: bool = False,
433+
return_objects: Literal[True],
434+
) -> List[FilesObject]:
435+
pass
436+
437+
@overload
438+
def listdir(
439+
self,
440+
path: PathLike = '/',
441+
*,
442+
recursive: bool = False,
443+
return_objects: Literal[False] = False,
430444
) -> List[str]:
431445
pass
432446

447+
@abstractmethod
448+
def listdir(
449+
self,
450+
path: PathLike = '/',
451+
*,
452+
recursive: bool = False,
453+
return_objects: bool = False,
454+
) -> Union[List[str], List[FilesObject]]:
455+
pass
456+
433457
@abstractmethod
434458
def download_file(
435459
self,
@@ -908,38 +932,78 @@ def is_file(self, path: PathLike) -> bool:
908932
return False
909933
raise
910934

911-
def _listdir(self, path: PathLike, *, recursive: bool = False) -> List[str]:
935+
def _listdir(
936+
self, path: PathLike, *,
937+
recursive: bool = False,
938+
return_objects: bool = False,
939+
) -> List[Union[str, FilesObject]]:
912940
"""
913-
Return the names of files in a directory.
941+
Return the names (or FilesObject instances) of files in a directory.
914942
915943
Parameters
916944
----------
917945
path : Path or str
918946
Path to the folder
919947
recursive : bool, optional
920948
Should folders be listed recursively?
921-
949+
return_objects : bool, optional
950+
If True, return list of FilesObject instances. Otherwise just paths.
922951
"""
923952
res = self._manager._get(
924953
f'files/fs/{self._location}/{path}',
925954
).json()
926955

927956
if recursive:
928-
out = []
929-
for item in res['content'] or []:
930-
out.append(item['path'])
957+
out: List[Union[str, FilesObject]] = []
958+
for item in res.get('content') or []:
959+
if return_objects:
960+
out.append(FilesObject.from_dict(item, self))
961+
else:
962+
out.append(item['path'])
931963
if item['type'] == 'directory':
932-
out.extend(self._listdir(item['path'], recursive=recursive))
964+
out.extend(
965+
self._listdir(
966+
item['path'],
967+
recursive=recursive,
968+
return_objects=return_objects,
969+
),
970+
)
933971
return out
934972

935-
return [x['path'] for x in res['content'] or []]
973+
if return_objects:
974+
return [
975+
FilesObject.from_dict(x, self)
976+
for x in (res.get('content') or [])
977+
]
978+
return [x['path'] for x in (res.get('content') or [])]
936979

980+
@overload
937981
def listdir(
938982
self,
939983
path: PathLike = '/',
940984
*,
941985
recursive: bool = False,
986+
return_objects: Literal[True],
987+
) -> List[FilesObject]:
988+
...
989+
990+
@overload
991+
def listdir(
992+
self,
993+
path: PathLike = '/',
994+
*,
995+
recursive: bool = False,
996+
return_objects: Literal[False] = False,
942997
) -> List[str]:
998+
...
999+
1000+
def listdir(
1001+
self,
1002+
path: PathLike = '/',
1003+
*,
1004+
recursive: bool = False,
1005+
return_objects: bool = False,
1006+
) -> Union[List[str], List[FilesObject]]:
9431007
"""
9441008
List the files / folders at the given path.
9451009
@@ -948,22 +1012,40 @@ def listdir(
9481012
path : Path or str, optional
9491013
Path to the file location
9501014
1015+
return_objects : bool, optional
1016+
If True, return list of FilesObject instances. Otherwise just paths.
1017+
9511018
Returns
9521019
-------
953-
List[str]
1020+
List[str] or List[FilesObject]
9541021
9551022
"""
9561023
path = re.sub(r'^(\./|/)+', r'', str(path))
9571024
path = re.sub(r'/+$', r'', path) + '/'
9581025

959-
if not self.is_dir(path):
960-
raise NotADirectoryError(f'path is not a directory: {path}')
1026+
# Validate via listing GET; if response lacks 'content', it's not a directory
1027+
try:
1028+
out = self._listdir(path, recursive=recursive, return_objects=return_objects)
1029+
except (ManagementError, KeyError) as exc:
1030+
# If the path doesn't exist or isn't a directory, _listdir will fail
1031+
raise NotADirectoryError(f'path is not a directory: {path}') from exc
9611032

962-
out = self._listdir(path, recursive=recursive)
9631033
if path != '/':
9641034
path_n = len(path.split('/')) - 1
965-
out = ['/'.join(x.split('/')[path_n:]) for x in out]
966-
return out
1035+
if return_objects:
1036+
result: List[FilesObject] = []
1037+
for item in out:
1038+
if isinstance(item, FilesObject):
1039+
rel = '/'.join(item.path.split('/')[path_n:])
1040+
item.path = rel
1041+
result.append(item)
1042+
return result
1043+
return ['/'.join(str(x).split('/')[path_n:]) for x in out]
1044+
1045+
# _listdir guarantees homogeneous type based on return_objects
1046+
if return_objects:
1047+
return cast(List[FilesObject], out)
1048+
return cast(List[str], out)
9671049

9681050
def download_file(
9691051
self,
@@ -992,10 +1074,49 @@ def download_file(
9921074
bytes or str - ``local_path`` is None
9931075
None - ``local_path`` is a Path or str
9941076
1077+
"""
1078+
return self._download_file(
1079+
path,
1080+
local_path=local_path,
1081+
overwrite=overwrite,
1082+
encoding=encoding,
1083+
_skip_dir_check=False,
1084+
)
1085+
1086+
def _download_file(
1087+
self,
1088+
path: PathLike,
1089+
local_path: Optional[PathLike] = None,
1090+
*,
1091+
overwrite: bool = False,
1092+
encoding: Optional[str] = None,
1093+
_skip_dir_check: bool = False,
1094+
) -> Optional[Union[bytes, str]]:
1095+
"""
1096+
Internal method to download the content of a file path.
1097+
1098+
Parameters
1099+
----------
1100+
path : Path or str
1101+
Path to the file
1102+
local_path : Path or str
1103+
Path to local file target location
1104+
overwrite : bool, optional
1105+
Should an existing file be overwritten if it exists?
1106+
encoding : str, optional
1107+
Encoding used to convert the resulting data
1108+
_skip_dir_check : bool, optional
1109+
Skip the directory check (internal use only)
1110+
1111+
Returns
1112+
-------
1113+
bytes or str - ``local_path`` is None
1114+
None - ``local_path`` is a Path or str
1115+
9951116
"""
9961117
if local_path is not None and not overwrite and os.path.exists(local_path):
9971118
raise OSError('target file already exists; use overwrite=True to replace')
998-
if self.is_dir(path):
1119+
if not _skip_dir_check and self.is_dir(path):
9991120
raise IsADirectoryError(f'file path is a directory: {path}')
10001121

10011122
out = self._manager._get(
@@ -1036,17 +1157,27 @@ def download_folder(
10361157
if local_path is not None and not overwrite and os.path.exists(local_path):
10371158
raise OSError('target path already exists; use overwrite=True to replace')
10381159

1039-
if not self.is_dir(path):
1040-
raise NotADirectoryError(f'path is not a directory: {path}')
1041-
1042-
files = self.listdir(path, recursive=True)
1043-
for f in files:
1044-
remote_path = os.path.join(path, f)
1045-
if self.is_dir(remote_path):
1160+
# listdir validates directory; no extra info call needed
1161+
entries = self.listdir(path, recursive=True, return_objects=True)
1162+
for entry in entries:
1163+
# Each entry is a FilesObject with path relative to root and type
1164+
if not isinstance(entry, FilesObject): # defensive: skip unexpected
1165+
continue
1166+
rel_path = entry.path
1167+
if entry.type == 'directory':
1168+
# Ensure local directory exists; no remote call needed
1169+
target_dir = os.path.normpath(os.path.join(local_path, rel_path))
1170+
os.makedirs(target_dir, exist_ok=True)
10461171
continue
1047-
target = os.path.normpath(os.path.join(local_path, f))
1048-
os.makedirs(os.path.dirname(target), exist_ok=True)
1049-
self.download_file(remote_path, target, overwrite=overwrite)
1172+
remote_path = os.path.join(path, rel_path)
1173+
target_file = os.path.normpath(
1174+
os.path.join(local_path, rel_path),
1175+
)
1176+
os.makedirs(os.path.dirname(target_file), exist_ok=True)
1177+
self._download_file(
1178+
remote_path, target_file,
1179+
overwrite=overwrite, _skip_dir_check=True,
1180+
)
10501181

10511182
def remove(self, path: PathLike) -> None:
10521183
"""

0 commit comments

Comments
 (0)