Path: blob/main/singlestoredb/management/files.py
469 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 Dict13from typing import List14from typing import Optional15from typing import Union1617from .. import config18from ..exceptions import ManagementError19from .manager import Manager20from .utils import PathLike21from .utils import to_datetime22from .utils import vars_to_str2324PERSONAL_SPACE = 'personal'25SHARED_SPACE = 'shared'26MODELS_SPACE = 'models'272829class FilesObject(object):30"""31File / folder object.3233It can belong to either a workspace stage or personal/shared space.3435This object is not instantiated directly. It is used in the results36of various operations in ``WorkspaceGroup.stage``, ``FilesManager.personal_space``,37``FilesManager.shared_space`` and ``FilesManager.models_space`` methods.3839"""4041def __init__(42self,43name: str,44path: str,45size: int,46type: str,47format: str,48mimetype: str,49created: Optional[datetime.datetime],50last_modified: Optional[datetime.datetime],51writable: bool,52content: Optional[List[str]] = None,53):54#: Name of file / folder55self.name = name5657if type == 'directory':58path = re.sub(r'/*$', r'', str(path)) + '/'5960#: Path of file / folder61self.path = path6263#: Size of the object (in bytes)64self.size = size6566#: Data type: file or directory67self.type = type6869#: Data format70self.format = format7172#: Mime type73self.mimetype = mimetype7475#: Datetime the object was created76self.created_at = created7778#: Datetime the object was modified last79self.last_modified_at = last_modified8081#: Is the object writable?82self.writable = writable8384#: Contents of a directory85self.content: List[str] = content or []8687self._location: Optional[FileLocation] = None8889@classmethod90def from_dict(91cls,92obj: Dict[str, Any],93location: FileLocation,94) -> FilesObject:95"""96Construct a FilesObject from a dictionary of values.9798Parameters99----------100obj : dict101Dictionary of values102location : FileLocation103FileLocation object to use as the parent104105Returns106-------107:class:`FilesObject`108109"""110out = cls(111name=obj['name'],112path=obj['path'],113size=obj['size'],114type=obj['type'],115format=obj['format'],116mimetype=obj['mimetype'],117created=to_datetime(obj.get('created')),118last_modified=to_datetime(obj.get('last_modified')),119writable=bool(obj['writable']),120)121out._location = location122return out123124def __str__(self) -> str:125"""Return string representation."""126return vars_to_str(self)127128def __repr__(self) -> str:129"""Return string representation."""130return str(self)131132def open(133self,134mode: str = 'r',135encoding: Optional[str] = None,136) -> Union[io.StringIO, io.BytesIO]:137"""138Open a file path for reading or writing.139140Parameters141----------142mode : str, optional143The read / write mode. The following modes are supported:144* 'r' open for reading (default)145* 'w' open for writing, truncating the file first146* 'x' create a new file and open it for writing147The data type can be specified by adding one of the following:148* 'b' binary mode149* 't' text mode (default)150encoding : str, optional151The string encoding to use for text152153Returns154-------155FilesObjectBytesReader - 'rb' or 'b' mode156FilesObjectBytesWriter - 'wb' or 'xb' mode157FilesObjectTextReader - 'r' or 'rt' mode158FilesObjectTextWriter - 'w', 'x', 'wt' or 'xt' mode159160"""161if self._location is None:162raise ManagementError(163msg='No FileLocation object is associated with this object.',164)165166if self.is_dir():167raise IsADirectoryError(168f'directories can not be read or written: {self.path}',169)170171return self._location.open(self.path, mode=mode, encoding=encoding)172173def download(174self,175local_path: Optional[PathLike] = None,176*,177overwrite: bool = False,178encoding: Optional[str] = None,179) -> Optional[Union[bytes, str]]:180"""181Download the content of a file path.182183Parameters184----------185local_path : Path or str186Path to local file target location187overwrite : bool, optional188Should an existing file be overwritten if it exists?189encoding : str, optional190Encoding used to convert the resulting data191192Returns193-------194bytes or str or None195196"""197if self._location is None:198raise ManagementError(199msg='No FileLocation object is associated with this object.',200)201202return self._location.download_file(203self.path, local_path=local_path,204overwrite=overwrite, encoding=encoding,205)206207download_file = download208209def remove(self) -> None:210"""Delete the file."""211if self._location is None:212raise ManagementError(213msg='No FileLocation object is associated with this object.',214)215216if self.type == 'directory':217raise IsADirectoryError(218f'path is a directory; use rmdir or removedirs {self.path}',219)220221self._location.remove(self.path)222223def rmdir(self) -> None:224"""Delete the empty directory."""225if self._location is None:226raise ManagementError(227msg='No FileLocation object is associated with this object.',228)229230if self.type != 'directory':231raise NotADirectoryError(232f'path is not a directory: {self.path}',233)234235self._location.rmdir(self.path)236237def removedirs(self) -> None:238"""Delete the directory recursively."""239if self._location is None:240raise ManagementError(241msg='No FileLocation object is associated with this object.',242)243244if self.type != 'directory':245raise NotADirectoryError(246f'path is not a directory: {self.path}',247)248249self._location.removedirs(self.path)250251def rename(self, new_path: PathLike, *, overwrite: bool = False) -> None:252"""253Move the file to a new location.254255Parameters256----------257new_path : Path or str258The new location of the file259overwrite : bool, optional260Should path be overwritten if it already exists?261262"""263if self._location is None:264raise ManagementError(265msg='No FileLocation object is associated with this object.',266)267out = self._location.rename(self.path, new_path, overwrite=overwrite)268self.name = out.name269self.path = out.path270return None271272def exists(self) -> bool:273"""Does the file / folder exist?"""274if self._location is None:275raise ManagementError(276msg='No FileLocation object is associated with this object.',277)278return self._location.exists(self.path)279280def is_dir(self) -> bool:281"""Is the object a directory?"""282return self.type == 'directory'283284def is_file(self) -> bool:285"""Is the object a file?"""286return self.type != 'directory'287288def abspath(self) -> str:289"""Return the full path of the object."""290return str(self.path)291292def basename(self) -> str:293"""Return the basename of the object."""294return self.name295296def dirname(self) -> str:297"""Return the directory name of the object."""298return re.sub(r'/*$', r'', os.path.dirname(re.sub(r'/*$', r'', self.path))) + '/'299300def getmtime(self) -> float:301"""Return the last modified datetime as a UNIX timestamp."""302if self.last_modified_at is None:303return 0.0304return self.last_modified_at.timestamp()305306def getctime(self) -> float:307"""Return the creation datetime as a UNIX timestamp."""308if self.created_at is None:309return 0.0310return self.created_at.timestamp()311312313class FilesObjectTextWriter(io.StringIO):314"""StringIO wrapper for writing to FileLocation."""315316def __init__(self, buffer: Optional[str], location: FileLocation, path: PathLike):317self._location = location318self._path = path319super().__init__(buffer)320321def close(self) -> None:322"""Write the content to the path."""323self._location._upload(self.getvalue(), self._path)324super().close()325326327class FilesObjectTextReader(io.StringIO):328"""StringIO wrapper for reading from FileLocation."""329330331class FilesObjectBytesWriter(io.BytesIO):332"""BytesIO wrapper for writing to FileLocation."""333334def __init__(self, buffer: bytes, location: FileLocation, path: PathLike):335self._location = location336self._path = path337super().__init__(buffer)338339def close(self) -> None:340"""Write the content to the file path."""341self._location._upload(self.getvalue(), self._path)342super().close()343344345class FilesObjectBytesReader(io.BytesIO):346"""BytesIO wrapper for reading from FileLocation."""347348349class FileLocation(ABC):350351@abstractmethod352def open(353self,354path: PathLike,355mode: str = 'r',356encoding: Optional[str] = None,357) -> Union[io.StringIO, io.BytesIO]:358pass359360@abstractmethod361def upload_file(362self,363local_path: Union[PathLike, io.IOBase],364path: PathLike,365*,366overwrite: bool = False,367) -> FilesObject:368pass369370@abstractmethod371def upload_folder(372self,373local_path: PathLike,374path: PathLike,375*,376overwrite: bool = False,377recursive: bool = True,378include_root: bool = False,379ignore: Optional[Union[PathLike, List[PathLike]]] = None,380) -> FilesObject:381pass382383@abstractmethod384def _upload(385self,386content: Union[str, bytes, io.IOBase],387path: PathLike,388*,389overwrite: bool = False,390) -> FilesObject:391pass392393@abstractmethod394def mkdir(self, path: PathLike, overwrite: bool = False) -> FilesObject:395pass396397@abstractmethod398def rename(399self,400old_path: PathLike,401new_path: PathLike,402*,403overwrite: bool = False,404) -> FilesObject:405pass406407@abstractmethod408def info(self, path: PathLike) -> FilesObject:409pass410411@abstractmethod412def exists(self, path: PathLike) -> bool:413pass414415@abstractmethod416def is_dir(self, path: PathLike) -> bool:417pass418419@abstractmethod420def is_file(self, path: PathLike) -> bool:421pass422423@abstractmethod424def listdir(425self,426path: PathLike = '/',427*,428recursive: bool = False,429) -> List[str]:430pass431432@abstractmethod433def download_file(434self,435path: PathLike,436local_path: Optional[PathLike] = None,437*,438overwrite: bool = False,439encoding: Optional[str] = None,440) -> Optional[Union[bytes, str]]:441pass442443@abstractmethod444def download_folder(445self,446path: PathLike,447local_path: PathLike = '.',448*,449overwrite: bool = False,450) -> None:451pass452453@abstractmethod454def remove(self, path: PathLike) -> None:455pass456457@abstractmethod458def removedirs(self, path: PathLike) -> None:459pass460461@abstractmethod462def rmdir(self, path: PathLike) -> None:463pass464465@abstractmethod466def __str__(self) -> str:467pass468469@abstractmethod470def __repr__(self) -> str:471pass472473474class FilesManager(Manager):475"""476SingleStoreDB files manager.477478This class should be instantiated using :func:`singlestoredb.manage_files`.479480Parameters481----------482access_token : str, optional483The API key or other access token for the files management API484version : str, optional485Version of the API to use486base_url : str, optional487Base URL of the files management API488489See Also490--------491:func:`singlestoredb.manage_files`492493"""494495#: Management API version if none is specified.496default_version = config.get_option('management.version') or 'v1'497498#: Base URL if none is specified.499default_base_url = config.get_option('management.base_url') \500or 'https://api.singlestore.com'501502#: Object type503obj_type = 'file'504505@property506def personal_space(self) -> FileSpace:507"""Return the personal file space."""508return FileSpace(PERSONAL_SPACE, self)509510@property511def shared_space(self) -> FileSpace:512"""Return the shared file space."""513return FileSpace(SHARED_SPACE, self)514515@property516def models_space(self) -> FileSpace:517"""Return the models file space."""518return FileSpace(MODELS_SPACE, self)519520521def manage_files(522access_token: Optional[str] = None,523version: Optional[str] = None,524base_url: Optional[str] = None,525*,526organization_id: Optional[str] = None,527) -> FilesManager:528"""529Retrieve a SingleStoreDB files manager.530531Parameters532----------533access_token : str, optional534The API key or other access token for the files management API535version : str, optional536Version of the API to use537base_url : str, optional538Base URL of the files management API539organization_id : str, optional540ID of organization, if using a JWT for authentication541542Returns543-------544:class:`FilesManager`545546"""547return FilesManager(548access_token=access_token, base_url=base_url,549version=version, organization_id=organization_id,550)551552553class FileSpace(FileLocation):554"""555FileSpace manager.556557This object is not instantiated directly.558It is returned by ``FilesManager.personal_space``, ``FilesManager.shared_space``559or ``FileManger.models_space``.560561"""562563def __init__(self, location: str, manager: FilesManager):564self._location = location565self._manager = manager566567def open(568self,569path: PathLike,570mode: str = 'r',571encoding: Optional[str] = None,572) -> Union[io.StringIO, io.BytesIO]:573"""574Open a file path for reading or writing.575576Parameters577----------578path : Path or str579The file path to read / write580mode : str, optional581The read / write mode. The following modes are supported:582* 'r' open for reading (default)583* 'w' open for writing, truncating the file first584* 'x' create a new file and open it for writing585The data type can be specified by adding one of the following:586* 'b' binary mode587* 't' text mode (default)588encoding : str, optional589The string encoding to use for text590591Returns592-------593FilesObjectBytesReader - 'rb' or 'b' mode594FilesObjectBytesWriter - 'wb' or 'xb' mode595FilesObjectTextReader - 'r' or 'rt' mode596FilesObjectTextWriter - 'w', 'x', 'wt' or 'xt' mode597598"""599if '+' in mode or 'a' in mode:600raise ManagementError(msg='modifying an existing file is not supported')601602if 'w' in mode or 'x' in mode:603exists = self.exists(path)604if exists:605if 'x' in mode:606raise FileExistsError(f'file path already exists: {path}')607self.remove(path)608if 'b' in mode:609return FilesObjectBytesWriter(b'', self, path)610return FilesObjectTextWriter('', self, path)611612if 'r' in mode:613content = self.download_file(path)614if isinstance(content, bytes):615if 'b' in mode:616return FilesObjectBytesReader(content)617encoding = 'utf-8' if encoding is None else encoding618return FilesObjectTextReader(content.decode(encoding))619620if isinstance(content, str):621return FilesObjectTextReader(content)622623raise ValueError(f'unrecognized file content type: {type(content)}')624625raise ValueError(f'must have one of create/read/write mode specified: {mode}')626627def upload_file(628self,629local_path: Union[PathLike, io.IOBase],630path: PathLike,631*,632overwrite: bool = False,633) -> FilesObject:634"""635Upload a local file.636637Parameters638----------639local_path : Path or str or file-like640Path to the local file or an open file object641path : Path or str642Path to the file643overwrite : bool, optional644Should the ``path`` be overwritten if it exists already?645646"""647if isinstance(local_path, io.IOBase):648pass649elif not os.path.isfile(local_path):650raise IsADirectoryError(f'local path is not a file: {local_path}')651652if self.exists(path):653if not overwrite:654raise OSError(f'file path already exists: {path}')655656self.remove(path)657658if isinstance(local_path, io.IOBase):659return self._upload(local_path, path, overwrite=overwrite)660661return self._upload(open(local_path, 'rb'), path, overwrite=overwrite)662663def upload_folder(664self,665local_path: PathLike,666path: PathLike,667*,668overwrite: bool = False,669recursive: bool = True,670include_root: bool = False,671ignore: Optional[Union[PathLike, List[PathLike]]] = None,672) -> FilesObject:673"""674Upload a folder recursively.675676Only the contents of the folder are uploaded. To include the677folder name itself in the target path use ``include_root=True``.678679Parameters680----------681local_path : Path or str682Local directory to upload683path : Path or str684Path of folder to upload to685overwrite : bool, optional686If a file already exists, should it be overwritten?687recursive : bool, optional688Should nested folders be uploaded?689include_root : bool, optional690Should the local root folder itself be uploaded as the top folder?691ignore : Path or str or List[Path] or List[str], optional692Glob patterns of files to ignore, for example, '**/*.pyc` will693ignore all '*.pyc' files in the directory tree694695"""696if not os.path.isdir(local_path):697raise NotADirectoryError(f'local path is not a directory: {local_path}')698699if not path:700path = local_path701702ignore_files = set()703if ignore:704if isinstance(ignore, list):705for item in ignore:706ignore_files.update(glob.glob(str(item), recursive=recursive))707else:708ignore_files.update(glob.glob(str(ignore), recursive=recursive))709710for dir_path, _, files in os.walk(str(local_path)):711for fname in files:712if ignore_files and fname in ignore_files:713continue714715local_file_path = os.path.join(dir_path, fname)716remote_path = os.path.join(717path,718local_file_path.lstrip(str(local_path)),719)720self.upload_file(721local_path=local_file_path,722path=remote_path,723overwrite=overwrite,724)725return self.info(path)726727def _upload(728self,729content: Union[str, bytes, io.IOBase],730path: PathLike,731*,732overwrite: bool = False,733) -> FilesObject:734"""735Upload content to a file.736737Parameters738----------739content : str or bytes or file-like740Content to upload741path : Path or str742Path to the file743overwrite : bool, optional744Should the ``path`` be overwritten if it exists already?745746"""747if self.exists(path):748if not overwrite:749raise OSError(f'file path already exists: {path}')750self.remove(path)751752self._manager._put(753f'files/fs/{self._location}/{path}',754files={'file': content},755headers={'Content-Type': None},756)757758return self.info(path)759760def mkdir(self, path: PathLike, overwrite: bool = False) -> FilesObject:761"""762Make a directory in the file space.763764Parameters765----------766path : Path or str767Path of the folder to create768overwrite : bool, optional769Should the file path be overwritten if it exists already?770771Returns772-------773FilesObject774775"""776raise ManagementError(777msg='Operation not supported: directories are currently not allowed '778'in Files API',779)780781mkdirs = mkdir782783def rename(784self,785old_path: PathLike,786new_path: PathLike,787*,788overwrite: bool = False,789) -> FilesObject:790"""791Move the file to a new location.792793Parameters794-----------795old_path : Path or str796Original location of the path797new_path : Path or str798New location of the path799overwrite : bool, optional800Should the ``new_path`` be overwritten if it exists already?801802"""803if not self.exists(old_path):804raise OSError(f'file path does not exist: {old_path}')805806if str(old_path).endswith('/') or str(new_path).endswith('/'):807raise ManagementError(808msg='Operation not supported: directories are currently not allowed '809'in Files API',810)811812if self.exists(new_path):813if not overwrite:814raise OSError(f'file path already exists: {new_path}')815816self.remove(new_path)817818self._manager._patch(819f'files/fs/{self._location}/{old_path}',820json=dict(newPath=new_path),821)822823return self.info(new_path)824825def info(self, path: PathLike) -> FilesObject:826"""827Return information about a file location.828829Parameters830----------831path : Path or str832Path to the file833834Returns835-------836FilesObject837838"""839res = self._manager._get(840re.sub(r'/+$', r'/', f'files/fs/{self._location}/{path}'),841params=dict(metadata=1),842).json()843844return FilesObject.from_dict(res, self)845846def exists(self, path: PathLike) -> bool:847"""848Does the given file path exist?849850Parameters851----------852path : Path or str853Path to file object854855Returns856-------857bool858859"""860try:861self.info(path)862return True863except ManagementError as exc:864if exc.errno == 404:865return False866raise867868def is_dir(self, path: PathLike) -> bool:869"""870Is the given file path a directory?871872Parameters873----------874path : Path or str875Path to file object876877Returns878-------879bool880881"""882try:883return self.info(path).type == 'directory'884except ManagementError as exc:885if exc.errno == 404:886return False887raise888889def is_file(self, path: PathLike) -> bool:890"""891Is the given file path a file?892893Parameters894----------895path : Path or str896Path to file object897898Returns899-------900bool901902"""903try:904return self.info(path).type != 'directory'905except ManagementError as exc:906if exc.errno == 404:907return False908raise909910def _listdir(self, path: PathLike, *, recursive: bool = False) -> List[str]:911"""912Return the names of files in a directory.913914Parameters915----------916path : Path or str917Path to the folder918recursive : bool, optional919Should folders be listed recursively?920921"""922res = self._manager._get(923f'files/fs/{self._location}/{path}',924).json()925926if recursive:927out = []928for item in res['content'] or []:929out.append(item['path'])930if item['type'] == 'directory':931out.extend(self._listdir(item['path'], recursive=recursive))932return out933934return [x['path'] for x in res['content'] or []]935936def listdir(937self,938path: PathLike = '/',939*,940recursive: bool = False,941) -> List[str]:942"""943List the files / folders at the given path.944945Parameters946----------947path : Path or str, optional948Path to the file location949950Returns951-------952List[str]953954"""955path = re.sub(r'^(\./|/)+', r'', str(path))956path = re.sub(r'/+$', r'', path) + '/'957958if not self.is_dir(path):959raise NotADirectoryError(f'path is not a directory: {path}')960961out = self._listdir(path, recursive=recursive)962if path != '/':963path_n = len(path.split('/')) - 1964out = ['/'.join(x.split('/')[path_n:]) for x in out]965return out966967def download_file(968self,969path: PathLike,970local_path: Optional[PathLike] = None,971*,972overwrite: bool = False,973encoding: Optional[str] = None,974) -> Optional[Union[bytes, str]]:975"""976Download the content of a file path.977978Parameters979----------980path : Path or str981Path to the file982local_path : Path or str983Path to local file target location984overwrite : bool, optional985Should an existing file be overwritten if it exists?986encoding : str, optional987Encoding used to convert the resulting data988989Returns990-------991bytes or str - ``local_path`` is None992None - ``local_path`` is a Path or str993994"""995if local_path is not None and not overwrite and os.path.exists(local_path):996raise OSError('target file already exists; use overwrite=True to replace')997if self.is_dir(path):998raise IsADirectoryError(f'file path is a directory: {path}')9991000out = self._manager._get(1001f'files/fs/{self._location}/{path}',1002).content10031004if local_path is not None:1005with open(local_path, 'wb') as outfile:1006outfile.write(out)1007return None10081009if encoding:1010return out.decode(encoding)10111012return out10131014def download_folder(1015self,1016path: PathLike,1017local_path: PathLike = '.',1018*,1019overwrite: bool = False,1020) -> None:1021"""1022Download a FileSpace folder to a local directory.10231024Parameters1025----------1026path : Path or str1027Directory path1028local_path : Path or str1029Path to local directory target location1030overwrite : bool, optional1031Should an existing directory / files be overwritten if they exist?10321033"""10341035if local_path is not None and not overwrite and os.path.exists(local_path):1036raise OSError('target path already exists; use overwrite=True to replace')10371038if not self.is_dir(path):1039raise NotADirectoryError(f'path is not a directory: {path}')10401041files = self.listdir(path, recursive=True)1042for f in files:1043remote_path = os.path.join(path, f)1044if self.is_dir(remote_path):1045continue1046target = os.path.normpath(os.path.join(local_path, f))1047os.makedirs(os.path.dirname(target), exist_ok=True)1048self.download_file(remote_path, target, overwrite=overwrite)10491050def remove(self, path: PathLike) -> None:1051"""1052Delete a file location.10531054Parameters1055----------1056path : Path or str1057Path to the location10581059"""1060if self.is_dir(path):1061raise IsADirectoryError('file path is a directory')10621063self._manager._delete(f'files/fs/{self._location}/{path}')10641065def removedirs(self, path: PathLike) -> None:1066"""1067Delete a folder recursively.10681069Parameters1070----------1071path : Path or str1072Path to the file location10731074"""1075if not self.is_dir(path):1076raise NotADirectoryError('path is not a directory')10771078self._manager._delete(f'files/fs/{self._location}/{path}')10791080def rmdir(self, path: PathLike) -> None:1081"""1082Delete a folder.10831084Parameters1085----------1086path : Path or str1087Path to the file location10881089"""1090raise ManagementError(1091msg='Operation not supported: directories are currently not allowed '1092'in Files API',1093)10941095def __str__(self) -> str:1096"""Return string representation."""1097return vars_to_str(self)10981099def __repr__(self) -> str:1100"""Return string representation."""1101return str(self)110211031104