Path: blob/main/singlestoredb/management/files.py
801 views
#!/usr/bin/env python1"""SingleStore Cloud Files Management."""2from __future__ import annotations34import datetime5import glob6import io7import os8import re9from abc import ABC10from abc import abstractmethod11from typing import Any12from typing import cast13from typing import Dict14from typing import List15from typing import Literal16from typing import Optional17from typing import overload18from typing import Union1920from .. import config21from ..exceptions import ManagementError22from .manager import Manager23from .utils import PathLike24from .utils import to_datetime25from .utils import vars_to_str2627PERSONAL_SPACE = 'personal'28SHARED_SPACE = 'shared'29MODELS_SPACE = 'models'303132class FilesObject(object):33"""34File / folder object.3536It can belong to either a workspace stage or personal/shared space.3738This object is not instantiated directly. It is used in the results39of various operations in ``WorkspaceGroup.stage``, ``FilesManager.personal_space``,40``FilesManager.shared_space`` and ``FilesManager.models_space`` methods.4142"""4344def __init__(45self,46name: str,47path: str,48size: int,49type: str,50format: str,51mimetype: str,52created: Optional[datetime.datetime],53last_modified: Optional[datetime.datetime],54writable: bool,55content: Optional[List[str]] = None,56):57#: Name of file / folder58self.name = name5960if type == 'directory':61path = re.sub(r'/*$', r'', str(path)) + '/'6263#: Path of file / folder64self.path = path6566#: Size of the object (in bytes)67self.size = size6869#: Data type: file or directory70self.type = type7172#: Data format73self.format = format7475#: Mime type76self.mimetype = mimetype7778#: Datetime the object was created79self.created_at = created8081#: Datetime the object was modified last82self.last_modified_at = last_modified8384#: Is the object writable?85self.writable = writable8687#: Contents of a directory88self.content: List[str] = content or []8990self._location: Optional[FileLocation] = None9192@classmethod93def from_dict(94cls,95obj: Dict[str, Any],96location: FileLocation,97) -> FilesObject:98"""99Construct a FilesObject from a dictionary of values.100101Parameters102----------103obj : dict104Dictionary of values105location : FileLocation106FileLocation object to use as the parent107108Returns109-------110:class:`FilesObject`111112"""113out = cls(114name=obj['name'],115path=obj['path'],116size=obj['size'],117type=obj['type'],118format=obj['format'],119mimetype=obj['mimetype'],120created=to_datetime(obj.get('created')),121last_modified=to_datetime(obj.get('last_modified')),122writable=bool(obj['writable']),123)124out._location = location125return out126127def __str__(self) -> str:128"""Return string representation."""129return vars_to_str(self)130131def __repr__(self) -> str:132"""Return string representation."""133return str(self)134135def open(136self,137mode: str = 'r',138encoding: Optional[str] = None,139) -> Union[io.StringIO, io.BytesIO]:140"""141Open a file path for reading or writing.142143Parameters144----------145mode : str, optional146The read / write mode. The following modes are supported:147* 'r' open for reading (default)148* 'w' open for writing, truncating the file first149* 'x' create a new file and open it for writing150The data type can be specified by adding one of the following:151* 'b' binary mode152* 't' text mode (default)153encoding : str, optional154The string encoding to use for text155156Returns157-------158FilesObjectBytesReader - 'rb' or 'b' mode159FilesObjectBytesWriter - 'wb' or 'xb' mode160FilesObjectTextReader - 'r' or 'rt' mode161FilesObjectTextWriter - 'w', 'x', 'wt' or 'xt' mode162163"""164if self._location is None:165raise ManagementError(166msg='No FileLocation object is associated with this object.',167)168169if self.is_dir():170raise IsADirectoryError(171f'directories can not be read or written: {self.path}',172)173174return self._location.open(self.path, mode=mode, encoding=encoding)175176def download(177self,178local_path: Optional[PathLike] = None,179*,180overwrite: bool = False,181encoding: Optional[str] = None,182) -> Optional[Union[bytes, str]]:183"""184Download the content of a file path.185186Parameters187----------188local_path : Path or str189Path to local file target location190overwrite : bool, optional191Should an existing file be overwritten if it exists?192encoding : str, optional193Encoding used to convert the resulting data194195Returns196-------197bytes or str or None198199"""200if self._location is None:201raise ManagementError(202msg='No FileLocation object is associated with this object.',203)204205return self._location.download_file(206self.path, local_path=local_path,207overwrite=overwrite, encoding=encoding,208)209210download_file = download211212def remove(self) -> None:213"""Delete the file."""214if self._location is None:215raise ManagementError(216msg='No FileLocation object is associated with this object.',217)218219if self.type == 'directory':220raise IsADirectoryError(221f'path is a directory; use rmdir or removedirs {self.path}',222)223224self._location.remove(self.path)225226def rmdir(self) -> None:227"""Delete the empty directory."""228if self._location is None:229raise ManagementError(230msg='No FileLocation object is associated with this object.',231)232233if self.type != 'directory':234raise NotADirectoryError(235f'path is not a directory: {self.path}',236)237238self._location.rmdir(self.path)239240def removedirs(self) -> None:241"""Delete the directory recursively."""242if self._location is None:243raise ManagementError(244msg='No FileLocation object is associated with this object.',245)246247if self.type != 'directory':248raise NotADirectoryError(249f'path is not a directory: {self.path}',250)251252self._location.removedirs(self.path)253254def rename(self, new_path: PathLike, *, overwrite: bool = False) -> None:255"""256Move the file to a new location.257258Parameters259----------260new_path : Path or str261The new location of the file262overwrite : bool, optional263Should path be overwritten if it already exists?264265"""266if self._location is None:267raise ManagementError(268msg='No FileLocation object is associated with this object.',269)270out = self._location.rename(self.path, new_path, overwrite=overwrite)271self.name = out.name272self.path = out.path273return None274275def exists(self) -> bool:276"""Does the file / folder exist?"""277if self._location is None:278raise ManagementError(279msg='No FileLocation object is associated with this object.',280)281return self._location.exists(self.path)282283def is_dir(self) -> bool:284"""Is the object a directory?"""285return self.type == 'directory'286287def is_file(self) -> bool:288"""Is the object a file?"""289return self.type != 'directory'290291def abspath(self) -> str:292"""Return the full path of the object."""293return str(self.path)294295def basename(self) -> str:296"""Return the basename of the object."""297return self.name298299def dirname(self) -> str:300"""Return the directory name of the object."""301return re.sub(r'/*$', r'', os.path.dirname(re.sub(r'/*$', r'', self.path))) + '/'302303def getmtime(self) -> float:304"""Return the last modified datetime as a UNIX timestamp."""305if self.last_modified_at is None:306return 0.0307return self.last_modified_at.timestamp()308309def getctime(self) -> float:310"""Return the creation datetime as a UNIX timestamp."""311if self.created_at is None:312return 0.0313return self.created_at.timestamp()314315316class FilesObjectTextWriter(io.StringIO):317"""StringIO wrapper for writing to FileLocation."""318319def __init__(self, buffer: Optional[str], location: FileLocation, path: PathLike):320self._location = location321self._path = path322super().__init__(buffer)323324def close(self) -> None:325"""Write the content to the path."""326self._location._upload(self.getvalue(), self._path)327super().close()328329330class FilesObjectTextReader(io.StringIO):331"""StringIO wrapper for reading from FileLocation."""332333334class FilesObjectBytesWriter(io.BytesIO):335"""BytesIO wrapper for writing to FileLocation."""336337def __init__(self, buffer: bytes, location: FileLocation, path: PathLike):338self._location = location339self._path = path340super().__init__(buffer)341342def close(self) -> None:343"""Write the content to the file path."""344self._location._upload(self.getvalue(), self._path)345super().close()346347348class FilesObjectBytesReader(io.BytesIO):349"""BytesIO wrapper for reading from FileLocation."""350351352class FileLocation(ABC):353354@abstractmethod355def open(356self,357path: PathLike,358mode: str = 'r',359encoding: Optional[str] = None,360) -> Union[io.StringIO, io.BytesIO]:361pass362363@abstractmethod364def upload_file(365self,366local_path: Union[PathLike, io.IOBase],367path: PathLike,368*,369overwrite: bool = False,370) -> FilesObject:371pass372373@abstractmethod374def upload_folder(375self,376local_path: PathLike,377path: PathLike,378*,379overwrite: bool = False,380recursive: bool = True,381include_root: bool = False,382ignore: Optional[Union[PathLike, List[PathLike]]] = None,383) -> FilesObject:384pass385386@abstractmethod387def _upload(388self,389content: Union[str, bytes, io.IOBase],390path: PathLike,391*,392overwrite: bool = False,393) -> FilesObject:394pass395396@abstractmethod397def mkdir(self, path: PathLike, overwrite: bool = False) -> FilesObject:398pass399400@abstractmethod401def rename(402self,403old_path: PathLike,404new_path: PathLike,405*,406overwrite: bool = False,407) -> FilesObject:408pass409410@abstractmethod411def info(self, path: PathLike) -> FilesObject:412pass413414@abstractmethod415def exists(self, path: PathLike) -> bool:416pass417418@abstractmethod419def is_dir(self, path: PathLike) -> bool:420pass421422@abstractmethod423def is_file(self, path: PathLike) -> bool:424pass425426@overload427def listdir(428self,429path: PathLike = '/',430*,431recursive: bool = False,432return_objects: Literal[True],433) -> List[FilesObject]:434pass435436@overload437def listdir(438self,439path: PathLike = '/',440*,441recursive: bool = False,442return_objects: Literal[False] = False,443) -> List[str]:444pass445446@abstractmethod447def listdir(448self,449path: PathLike = '/',450*,451recursive: bool = False,452return_objects: bool = False,453) -> Union[List[str], List[FilesObject]]:454pass455456@abstractmethod457def download_file(458self,459path: PathLike,460local_path: Optional[PathLike] = None,461*,462overwrite: bool = False,463encoding: Optional[str] = None,464) -> Optional[Union[bytes, str]]:465pass466467@abstractmethod468def download_folder(469self,470path: PathLike,471local_path: PathLike = '.',472*,473overwrite: bool = False,474) -> None:475pass476477@abstractmethod478def remove(self, path: PathLike) -> None:479pass480481@abstractmethod482def removedirs(self, path: PathLike) -> None:483pass484485@abstractmethod486def rmdir(self, path: PathLike) -> None:487pass488489@abstractmethod490def __str__(self) -> str:491pass492493@abstractmethod494def __repr__(self) -> str:495pass496497498class FilesManager(Manager):499"""500SingleStoreDB files manager.501502This class should be instantiated using :func:`singlestoredb.manage_files`.503504Parameters505----------506access_token : str, optional507The API key or other access token for the files management API508version : str, optional509Version of the API to use510base_url : str, optional511Base URL of the files management API512513See Also514--------515:func:`singlestoredb.manage_files`516517"""518519#: Management API version if none is specified.520default_version = config.get_option('management.version') or 'v1'521522#: Base URL if none is specified.523default_base_url = config.get_option('management.base_url') \524or 'https://api.singlestore.com'525526#: Object type527obj_type = 'file'528529@property530def personal_space(self) -> FileSpace:531"""Return the personal file space."""532return FileSpace(PERSONAL_SPACE, self)533534@property535def shared_space(self) -> FileSpace:536"""Return the shared file space."""537return FileSpace(SHARED_SPACE, self)538539@property540def models_space(self) -> FileSpace:541"""Return the models file space."""542return FileSpace(MODELS_SPACE, self)543544545def manage_files(546access_token: Optional[str] = None,547version: Optional[str] = None,548base_url: Optional[str] = None,549*,550organization_id: Optional[str] = None,551) -> FilesManager:552"""553Retrieve a SingleStoreDB files manager.554555Parameters556----------557access_token : str, optional558The API key or other access token for the files management API559version : str, optional560Version of the API to use561base_url : str, optional562Base URL of the files management API563organization_id : str, optional564ID of organization, if using a JWT for authentication565566Returns567-------568:class:`FilesManager`569570"""571return FilesManager(572access_token=access_token, base_url=base_url,573version=version, organization_id=organization_id,574)575576577class FileSpace(FileLocation):578"""579FileSpace manager.580581This object is not instantiated directly.582It is returned by ``FilesManager.personal_space``, ``FilesManager.shared_space``583or ``FileManger.models_space``.584585"""586587def __init__(self, location: str, manager: FilesManager):588self._location = location589self._manager = manager590591def open(592self,593path: PathLike,594mode: str = 'r',595encoding: Optional[str] = None,596) -> Union[io.StringIO, io.BytesIO]:597"""598Open a file path for reading or writing.599600Parameters601----------602path : Path or str603The file path to read / write604mode : str, optional605The read / write mode. The following modes are supported:606* 'r' open for reading (default)607* 'w' open for writing, truncating the file first608* 'x' create a new file and open it for writing609The data type can be specified by adding one of the following:610* 'b' binary mode611* 't' text mode (default)612encoding : str, optional613The string encoding to use for text614615Returns616-------617FilesObjectBytesReader - 'rb' or 'b' mode618FilesObjectBytesWriter - 'wb' or 'xb' mode619FilesObjectTextReader - 'r' or 'rt' mode620FilesObjectTextWriter - 'w', 'x', 'wt' or 'xt' mode621622"""623if '+' in mode or 'a' in mode:624raise ManagementError(msg='modifying an existing file is not supported')625626if 'w' in mode or 'x' in mode:627exists = self.exists(path)628if exists:629if 'x' in mode:630raise FileExistsError(f'file path already exists: {path}')631self.remove(path)632if 'b' in mode:633return FilesObjectBytesWriter(b'', self, path)634return FilesObjectTextWriter('', self, path)635636if 'r' in mode:637content = self.download_file(path)638if isinstance(content, bytes):639if 'b' in mode:640return FilesObjectBytesReader(content)641encoding = 'utf-8' if encoding is None else encoding642return FilesObjectTextReader(content.decode(encoding))643644if isinstance(content, str):645return FilesObjectTextReader(content)646647raise ValueError(f'unrecognized file content type: {type(content)}')648649raise ValueError(f'must have one of create/read/write mode specified: {mode}')650651def upload_file(652self,653local_path: Union[PathLike, io.IOBase],654path: PathLike,655*,656overwrite: bool = False,657) -> FilesObject:658"""659Upload a local file.660661Parameters662----------663local_path : Path or str or file-like664Path to the local file or an open file object665path : Path or str666Path to the file667overwrite : bool, optional668Should the ``path`` be overwritten if it exists already?669670"""671if isinstance(local_path, io.IOBase):672pass673elif not os.path.isfile(local_path):674raise IsADirectoryError(f'local path is not a file: {local_path}')675676if self.exists(path):677if not overwrite:678raise OSError(f'file path already exists: {path}')679680self.remove(path)681682if isinstance(local_path, io.IOBase):683return self._upload(local_path, path, overwrite=overwrite)684685return self._upload(open(local_path, 'rb'), path, overwrite=overwrite)686687def upload_folder(688self,689local_path: PathLike,690path: PathLike,691*,692overwrite: bool = False,693recursive: bool = True,694include_root: bool = False,695ignore: Optional[Union[PathLike, List[PathLike]]] = None,696) -> FilesObject:697"""698Upload a folder recursively.699700Only the contents of the folder are uploaded. To include the701folder name itself in the target path use ``include_root=True``.702703Parameters704----------705local_path : Path or str706Local directory to upload707path : Path or str708Path of folder to upload to709overwrite : bool, optional710If a file already exists, should it be overwritten?711recursive : bool, optional712Should nested folders be uploaded?713include_root : bool, optional714Should the local root folder itself be uploaded as the top folder?715ignore : Path or str or List[Path] or List[str], optional716Glob patterns of files to ignore, for example, '**/*.pyc` will717ignore all '*.pyc' files in the directory tree718719"""720if not os.path.isdir(local_path):721raise NotADirectoryError(f'local path is not a directory: {local_path}')722723if not path:724path = local_path725726ignore_files = set()727if ignore:728if isinstance(ignore, list):729for item in ignore:730ignore_files.update(glob.glob(str(item), recursive=recursive))731else:732ignore_files.update(glob.glob(str(ignore), recursive=recursive))733734for dir_path, _, files in os.walk(str(local_path)):735for fname in files:736if ignore_files and fname in ignore_files:737continue738739local_file_path = os.path.join(dir_path, fname)740remote_path = os.path.join(741path,742local_file_path.lstrip(str(local_path)),743)744self.upload_file(745local_path=local_file_path,746path=remote_path,747overwrite=overwrite,748)749return self.info(path)750751def _upload(752self,753content: Union[str, bytes, io.IOBase],754path: PathLike,755*,756overwrite: bool = False,757) -> FilesObject:758"""759Upload content to a file.760761Parameters762----------763content : str or bytes or file-like764Content to upload765path : Path or str766Path to the file767overwrite : bool, optional768Should the ``path`` be overwritten if it exists already?769770"""771if self.exists(path):772if not overwrite:773raise OSError(f'file path already exists: {path}')774self.remove(path)775776self._manager._put(777f'files/fs/{self._location}/{path}',778files={'file': content},779headers={'Content-Type': None},780)781782return self.info(path)783784def mkdir(self, path: PathLike, overwrite: bool = False) -> FilesObject:785"""786Make a directory in the file space.787788Parameters789----------790path : Path or str791Path of the folder to create792overwrite : bool, optional793Should the file path be overwritten if it exists already?794795Returns796-------797FilesObject798799"""800raise ManagementError(801msg='Operation not supported: directories are currently not allowed '802'in Files API',803)804805mkdirs = mkdir806807def rename(808self,809old_path: PathLike,810new_path: PathLike,811*,812overwrite: bool = False,813) -> FilesObject:814"""815Move the file to a new location.816817Parameters818-----------819old_path : Path or str820Original location of the path821new_path : Path or str822New location of the path823overwrite : bool, optional824Should the ``new_path`` be overwritten if it exists already?825826"""827if not self.exists(old_path):828raise OSError(f'file path does not exist: {old_path}')829830if str(old_path).endswith('/') or str(new_path).endswith('/'):831raise ManagementError(832msg='Operation not supported: directories are currently not allowed '833'in Files API',834)835836if self.exists(new_path):837if not overwrite:838raise OSError(f'file path already exists: {new_path}')839840self.remove(new_path)841842self._manager._patch(843f'files/fs/{self._location}/{old_path}',844json=dict(newPath=new_path),845)846847return self.info(new_path)848849def info(self, path: PathLike) -> FilesObject:850"""851Return information about a file location.852853Parameters854----------855path : Path or str856Path to the file857858Returns859-------860FilesObject861862"""863res = self._manager._get(864re.sub(r'/+$', r'/', f'files/fs/{self._location}/{path}'),865params=dict(metadata=1),866).json()867868return FilesObject.from_dict(res, self)869870def exists(self, path: PathLike) -> bool:871"""872Does the given file path exist?873874Parameters875----------876path : Path or str877Path to file object878879Returns880-------881bool882883"""884try:885self.info(path)886return True887except ManagementError as exc:888if exc.errno == 404:889return False890raise891892def is_dir(self, path: PathLike) -> bool:893"""894Is the given file path a directory?895896Parameters897----------898path : Path or str899Path to file object900901Returns902-------903bool904905"""906try:907return self.info(path).type == 'directory'908except ManagementError as exc:909if exc.errno == 404:910return False911raise912913def is_file(self, path: PathLike) -> bool:914"""915Is the given file path a file?916917Parameters918----------919path : Path or str920Path to file object921922Returns923-------924bool925926"""927try:928return self.info(path).type != 'directory'929except ManagementError as exc:930if exc.errno == 404:931return False932raise933934def _listdir(935self, path: PathLike, *,936recursive: bool = False,937return_objects: bool = False,938) -> List[Union[str, FilesObject]]:939"""940Return the names (or FilesObject instances) of files in a directory.941942Parameters943----------944path : Path or str945Path to the folder946recursive : bool, optional947Should folders be listed recursively?948return_objects : bool, optional949If True, return list of FilesObject instances. Otherwise just paths.950"""951res = self._manager._get(952f'files/fs/{self._location}/{path}',953).json()954955if recursive:956out: List[Union[str, FilesObject]] = []957for item in res.get('content') or []:958if return_objects:959out.append(FilesObject.from_dict(item, self))960else:961out.append(item['path'])962if item['type'] == 'directory':963out.extend(964self._listdir(965item['path'],966recursive=recursive,967return_objects=return_objects,968),969)970return out971972if return_objects:973return [974FilesObject.from_dict(x, self)975for x in (res.get('content') or [])976]977return [x['path'] for x in (res.get('content') or [])]978979@overload980def listdir(981self,982path: PathLike = '/',983*,984recursive: bool = False,985return_objects: Literal[True],986) -> List[FilesObject]:987...988989@overload990def listdir(991self,992path: PathLike = '/',993*,994recursive: bool = False,995return_objects: Literal[False] = False,996) -> List[str]:997...998999def listdir(1000self,1001path: PathLike = '/',1002*,1003recursive: bool = False,1004return_objects: bool = False,1005) -> Union[List[str], List[FilesObject]]:1006"""1007List the files / folders at the given path.10081009Parameters1010----------1011path : Path or str, optional1012Path to the file location10131014return_objects : bool, optional1015If True, return list of FilesObject instances. Otherwise just paths.10161017Returns1018-------1019List[str] or List[FilesObject]10201021"""1022path = re.sub(r'^(\./|/)+', r'', str(path))1023path = re.sub(r'/+$', r'', path) + '/'10241025# Validate via listing GET; if response lacks 'content', it's not a directory1026try:1027out = self._listdir(path, recursive=recursive, return_objects=return_objects)1028except (ManagementError, KeyError) as exc:1029# If the path doesn't exist or isn't a directory, _listdir will fail1030raise NotADirectoryError(f'path is not a directory: {path}') from exc10311032if path != '/':1033path_n = len(path.split('/')) - 11034if return_objects:1035result: List[FilesObject] = []1036for item in out:1037if isinstance(item, FilesObject):1038rel = '/'.join(item.path.split('/')[path_n:])1039item.path = rel1040result.append(item)1041return result1042return ['/'.join(str(x).split('/')[path_n:]) for x in out]10431044# _listdir guarantees homogeneous type based on return_objects1045if return_objects:1046return cast(List[FilesObject], out)1047return cast(List[str], out)10481049def download_file(1050self,1051path: PathLike,1052local_path: Optional[PathLike] = None,1053*,1054overwrite: bool = False,1055encoding: Optional[str] = None,1056) -> Optional[Union[bytes, str]]:1057"""1058Download the content of a file path.10591060Parameters1061----------1062path : Path or str1063Path to the file1064local_path : Path or str1065Path to local file target location1066overwrite : bool, optional1067Should an existing file be overwritten if it exists?1068encoding : str, optional1069Encoding used to convert the resulting data10701071Returns1072-------1073bytes or str - ``local_path`` is None1074None - ``local_path`` is a Path or str10751076"""1077return self._download_file(1078path,1079local_path=local_path,1080overwrite=overwrite,1081encoding=encoding,1082_skip_dir_check=False,1083)10841085def _download_file(1086self,1087path: PathLike,1088local_path: Optional[PathLike] = None,1089*,1090overwrite: bool = False,1091encoding: Optional[str] = None,1092_skip_dir_check: bool = False,1093) -> Optional[Union[bytes, str]]:1094"""1095Internal method to download the content of a file path.10961097Parameters1098----------1099path : Path or str1100Path to the file1101local_path : Path or str1102Path to local file target location1103overwrite : bool, optional1104Should an existing file be overwritten if it exists?1105encoding : str, optional1106Encoding used to convert the resulting data1107_skip_dir_check : bool, optional1108Skip the directory check (internal use only)11091110Returns1111-------1112bytes or str - ``local_path`` is None1113None - ``local_path`` is a Path or str11141115"""1116if local_path is not None and not overwrite and os.path.exists(local_path):1117raise OSError('target file already exists; use overwrite=True to replace')1118if not _skip_dir_check and self.is_dir(path):1119raise IsADirectoryError(f'file path is a directory: {path}')11201121out = self._manager._get(1122f'files/fs/{self._location}/{path}',1123).content11241125if local_path is not None:1126with open(local_path, 'wb') as outfile:1127outfile.write(out)1128return None11291130if encoding:1131return out.decode(encoding)11321133return out11341135def download_folder(1136self,1137path: PathLike,1138local_path: PathLike = '.',1139*,1140overwrite: bool = False,1141) -> None:1142"""1143Download a FileSpace folder to a local directory.11441145Parameters1146----------1147path : Path or str1148Directory path1149local_path : Path or str1150Path to local directory target location1151overwrite : bool, optional1152Should an existing directory / files be overwritten if they exist?11531154"""11551156if local_path is not None and not overwrite and os.path.exists(local_path):1157raise OSError('target path already exists; use overwrite=True to replace')11581159# listdir validates directory; no extra info call needed1160entries = self.listdir(path, recursive=True, return_objects=True)1161for entry in entries:1162# Each entry is a FilesObject with path relative to root and type1163if not isinstance(entry, FilesObject): # defensive: skip unexpected1164continue1165rel_path = entry.path1166if entry.type == 'directory':1167# Ensure local directory exists; no remote call needed1168target_dir = os.path.normpath(os.path.join(local_path, rel_path))1169os.makedirs(target_dir, exist_ok=True)1170continue1171remote_path = os.path.join(path, rel_path)1172target_file = os.path.normpath(1173os.path.join(local_path, rel_path),1174)1175os.makedirs(os.path.dirname(target_file), exist_ok=True)1176self._download_file(1177remote_path, target_file,1178overwrite=overwrite, _skip_dir_check=True,1179)11801181def remove(self, path: PathLike) -> None:1182"""1183Delete a file location.11841185Parameters1186----------1187path : Path or str1188Path to the location11891190"""1191if self.is_dir(path):1192raise IsADirectoryError('file path is a directory')11931194self._manager._delete(f'files/fs/{self._location}/{path}')11951196def removedirs(self, path: PathLike) -> None:1197"""1198Delete a folder recursively.11991200Parameters1201----------1202path : Path or str1203Path to the file location12041205"""1206if not self.is_dir(path):1207raise NotADirectoryError('path is not a directory')12081209self._manager._delete(f'files/fs/{self._location}/{path}')12101211def rmdir(self, path: PathLike) -> None:1212"""1213Delete a folder.12141215Parameters1216----------1217path : Path or str1218Path to the file location12191220"""1221raise ManagementError(1222msg='Operation not supported: directories are currently not allowed '1223'in Files API',1224)12251226def __str__(self) -> str:1227"""Return string representation."""1228return vars_to_str(self)12291230def __repr__(self) -> str:1231"""Return string representation."""1232return str(self)123312341235