Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 50 additions & 26 deletions singlestoredb/management/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -908,38 +908,45 @@ def is_file(self, path: PathLike) -> bool:
return False
raise

def _listdir(self, path: PathLike, *, recursive: bool = False) -> List[str]:
def _listdir(self, path: PathLike, *, recursive: bool = False, return_meta: bool = False) -> List[Union[str, Dict[str, Any]]]:
"""
Return the names of files in a directory.
Return the names (or metadata) of files in a directory.

Parameters
----------
path : Path or str
Path to the folder
recursive : bool, optional
Should folders be listed recursively?

return_meta : bool, optional
If True, return list of dicts with 'path' and 'type'. Otherwise just paths.
"""
res = self._manager._get(
f'files/fs/{self._location}/{path}',
).json()

if recursive:
out = []
for item in res['content'] or []:
out.append(item['path'])
out: List[Union[str, Dict[str, Any]]] = []
for item in res.get('content') or []:
if return_meta:
out.append({'path': item['path'], 'type': item['type']})
else:
out.append(item['path'])
if item['type'] == 'directory':
out.extend(self._listdir(item['path'], recursive=recursive))
out.extend(self._listdir(item['path'], recursive=recursive, return_meta=return_meta))
return out

return [x['path'] for x in res['content'] or []]
if return_meta:
return [{'path': x['path'], 'type': x['type']} for x in (res.get('content') or [])]
return [x['path'] for x in (res.get('content') or [])]

def listdir(
self,
path: PathLike = '/',
*,
recursive: bool = False,
) -> List[str]:
return_meta: bool = False,
) -> List[Union[str, Dict[str, Any]]]:
"""
List the files / folders at the given path.

Expand All @@ -948,21 +955,32 @@ def listdir(
path : Path or str, optional
Path to the file location

return_meta : bool, optional
If True, return list of dicts with 'path' and 'type'. Otherwise just paths.

Returns
-------
List[str]
List[str] or List[dict]

"""
path = re.sub(r'^(\./|/)+', r'', str(path))
path = re.sub(r'/+$', r'', path) + '/'

if not self.is_dir(path):
raise NotADirectoryError(f'path is not a directory: {path}')

out = self._listdir(path, recursive=recursive)
# Validate via listing GET; if response lacks 'content', it's not a directory
try:
out = self._listdir(path, recursive=recursive, return_meta=return_meta)
except Exception as exc:
# If the path doesn't exist or isn't a directory, _listdir will fail
raise NotADirectoryError(f'path is not a directory: {path}') from exc
if path != '/':
path_n = len(path.split('/')) - 1
out = ['/'.join(x.split('/')[path_n:]) for x in out]
if return_meta:
for i in range(len(out)):
if isinstance(out[i], dict):
rel = '/'.join(out[i]['path'].split('/')[path_n:])
out[i]['path'] = rel
else:
out = ['/'.join(str(x).split('/')[path_n:]) for x in out]
return out

def download_file(
Expand All @@ -972,6 +990,7 @@ def download_file(
*,
overwrite: bool = False,
encoding: Optional[str] = None,
_skip_dir_check: bool = False,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than add this to a publicly visible API, how about we create a _download_file internal method with the extra parameter that is called by download_file. So the parameters for download_file stay the way they were before, but we call the _download_file internal version with _skip_dir_check when needed. That keeps internally used parameters out of the generated documentation.

) -> Optional[Union[bytes, str]]:
"""
Download the content of a file path.
Expand All @@ -995,7 +1014,7 @@ def download_file(
"""
if local_path is not None and not overwrite and os.path.exists(local_path):
raise OSError('target file already exists; use overwrite=True to replace')
if self.is_dir(path):
if not _skip_dir_check and self.is_dir(path):
raise IsADirectoryError(f'file path is a directory: {path}')

out = self._manager._get(
Expand Down Expand Up @@ -1036,17 +1055,22 @@ def download_folder(
if local_path is not None and not overwrite and os.path.exists(local_path):
raise OSError('target path already exists; use overwrite=True to replace')

if not self.is_dir(path):
raise NotADirectoryError(f'path is not a directory: {path}')

files = self.listdir(path, recursive=True)
for f in files:
remote_path = os.path.join(path, f)
if self.is_dir(remote_path):
# listdir validates directory; no extra info call needed
entries = self.listdir(path, recursive=True, return_meta=True)
for entry in entries:
# Each entry is a dict with path relative to root and type
if not isinstance(entry, dict): # defensive: skip unexpected
continue
rel_path = entry['path']
if entry['type'] == 'directory':
# Ensure local directory exists; no remote call needed
target_dir = os.path.normpath(os.path.join(local_path, rel_path))
os.makedirs(target_dir, exist_ok=True)
continue
target = os.path.normpath(os.path.join(local_path, f))
os.makedirs(os.path.dirname(target), exist_ok=True)
self.download_file(remote_path, target, overwrite=overwrite)
remote_path = os.path.join(path, rel_path)
target_file = os.path.normpath(os.path.join(local_path, rel_path))
os.makedirs(os.path.dirname(target_file), exist_ok=True)
self.download_file(remote_path, target_file, overwrite=overwrite, _skip_dir_check=True)

def remove(self, path: PathLike) -> None:
"""
Expand Down
Loading