[docs]@dataclassclassFileSystemUpdateContext(Dictable):"""The context given to a :class:`.FileSystemUpdate`. """resource:Resource=field()"""The :class:`.Resource` that created this context and updating the file system. """check_path:Path=field()"""The installer relative :obj:`.Resource.check_path`."""target:Path=field()"""The installer relative target path from :class:`.Resource`."""
[docs]@dataclassclassFileSystemUpdate(Dictable,metaclass=ABCMeta):"""A command (GoF pattern) to udpate the file system after a resource has decompressed a file. First experiment with :class:`.ListUpdate`, then find the corresponding command with :obj:`dry_run` turned on, then turn it off once you've validated its doing the right thing. Path fields (i.e. :obj:`.ListUpdate.path`) are formatted with the dictionary version of :class:`.FileSystemUpdateContext` and also a ``target`` property with the uncompressed path. """dry_run:bool=field()"""If ``True`` don't do anything, just act like it."""def_format_str(self,context:FileSystemUpdateContext,val:str)->str:returnval.format(**context.asdict())def_format_path(self,context:FileSystemUpdateContext,attr:str,make_path:bool=True)-> \
Union[Path,str]:val:str=getattr(self,attr)path_str:str=self._format_str(context,val)iflogger.isEnabledFor(logging.DEBUG):logger.debug(f'{attr}({val}) -> {path_str}')returnPath(path_str)ifmake_pathelsepath_str
[docs]@dataclassclassMoveUpdate(FileSystemUpdate):"""Move file(s) from :obj:`source` to :obj:`target`. """source:str=field()"""The source glob (i.e. ``{target}/../*)`` from where to move the files."""target:str=field()"""The target directory where the files end up."""
[docs]@dataclassclassRemoveUpdate(FileSystemUpdate):"""Remove/clean up files to help keep the file system "clean". """paths:Sequence[str]=field()"""The list of path formatted files to remove. For example ``{target}/../__MACOSX``. """def_rm_path(self,path:Path):ifpath.is_dir():iflogger.isEnabledFor(logging.INFO):logger.info(f'removing clean up dir: {path}')ifnotself.dry_run:shutil.rmtree(path)elifpath.is_file():iflogger.isEnabledFor(logging.INFO):logger.info(f'removing clean up file: {path}')ifnotself.dry_run:path.unlink()eliflogger.isEnabledFor(logging.INFO):logger.info(f'skipping non-existant clean up dir: {path}')
[docs]@dataclassclassResource(Dictable):"""A resource that is installed by downloading from the Internet and then optionally uncompressed. Once the file is downloaded, it is only uncompressed if it is an archive file. This is determined by the file extension. """_DICTABLE_ATTRIBUTES:ClassVar[List[str]]= \
'remote_name is_compressed compressed_name'.split()_FILE_REGEX:ClassVar[re.Pattern]=re.compile(r'^(.+)\.(tar\.gz|tgz|tar\.bz2|gz|bz2|'+'|'.join(patoolib.ArchiveFormats)+')$')_NO_FILE_REGEX:ClassVar[re.Pattern]=re.compile(r'^(?:.+/)?(.+?)\.(.+)?$')url:str=field()"""The URL that locates the file to install."""name:str=field(default=None)"""Used for local file naming."""remote_name:str=field(default=None)"""The name of extracted file (or root directory if a compressed file) after being downloaded. If this isn't set, it is taken from the file name portion of the path of the URL. """is_compressed:bool=field(default=None)"""Whether or not the file is compressed. If this isn't set, it is derived from the file name. """rename:bool=field(default=True)"""If ``True`` then rename the directory to the :obj:`name`."""check_path:str=field(default=None)"""The file to check for existance before doing uncompressing."""sub_path:Path=field(default=None)"""The path to a file in the compressed file after it is extracted. This is only used to obtain the file name in :meth:`get_file_name` when used to locate the uncompressed resource file. """clean_up:bool=field(default=True)"""Whether or not to remove the downloaded compressed after finished."""updates:Sequence[FileSystemUpdate]=field(default=())"""The file system updates to apply after the file has been decompressed."""def__post_init__(self):url:ParseResult=urllib.parse.urlparse(self.url)remote_path:Path=Path(url.path)remote_name:strm=self._FILE_REGEX.match(remote_path.name)ifmisNone:m=self._NO_FILE_REGEX.match(remote_path.name)self._extension=NoneifmisNone:remote_name=remote_path.nameelse:remote_name=m.group(1)ifself.nameisNone:self.name=remote_path.nameelse:remote_name,self._extension=m.groups()ifself.nameisNone:self.name=remote_nameifself.remote_nameisNone:self.remote_name=remote_nameifself.is_compressedisNone:self.is_compressed=self._extensionisnotNone
[docs]defuncompress(self,path:Path=None,out_dir:Path=None)->bool:"""Uncompress the file. :param path: the file to uncompress :param out_dir: where the uncompressed files are extracted """uncompressed=FalseifpathisNone:src=Path(self.compressed_name)out_dir=Path('.')else:src=pathifout_dirisNone:out_dir=path.parent# the target is the name we want after the process completestarget:Path=out_dir/self.name# this is the name of the resulting file of what we expect, or the user# can override it if they know what the real resulting file isifself.check_pathisNone:check_path=targetelse:check_path=out_dir/self.check_pathiflogger.isEnabledFor(logging.DEBUG):logger.debug(f'check path: {check_path}')# uncompress if we can't find where the output is suppose to goifnotcheck_path.exists():iflogger.isEnabledFor(logging.INFO):logger.info(f'uncompressing {src} to {out_dir}')out_dir.mkdir(parents=True,exist_ok=True)patoolib.extract_archive(str(src),outdir=str(out_dir))uncompressed=Trueiflogger.isEnabledFor(logging.DEBUG):logger.debug(f'rename: {self.rename}, '+f'path ({check_path}) exists: {check_path.exists()}')# the extracted data can either be a file (gz/bz2) or a directory;# compare to what we want to rename the target directory## note: the check path has to be what extracts as, otherwise it it will# unextract it again next time it checks; if the directory extracts as# something other than the file name, set both the name and the check# path to whatever that path isifself.renameandnotcheck_path.exists():# the source is where it was extractedextracted:Path=out_dir/self.remote_nameifnotextracted.exists():raiseInstallError(f'Trying to create {check_path} but '+f'missing extracted path: {extracted}')iflogger.isEnabledFor(logging.INFO):logger.info(f'renaming {extracted} to {target}')extracted.rename(target)ifself.clean_up:iflogger.isEnabledFor(logging.INFO):logger.info(f'cleaning up downloaded file: {src}')src.unlink()update_context=FileSystemUpdateContext(self,check_path,target)update:FileSystemUpdateforupdateinself.updates:logger.info(f'updating: {update}')update.invoke(update_context)returnuncompressed
@propertydefcompressed_name(self)->str:"""The file name with the extension and used to uncompress. If the resource isn't compressed, just the name is returned. """ifself.is_compressed:name=f'{self.name}'ifself._extensionisnotNone:name=f'{name}.{self._extension}'else:name=self.namereturnname
[docs]defget_file_name(self,compressed:bool=False)->str:"""Return the path where a resource is installed. :param compressed: if ``True``, return the path where its compressed file (if any) lives :return: the path of the resource """fname=self.compressed_nameifcompressedelseself.nameiffnameisNone:fname=self.remote_nameifnotcompressedandself.sub_pathisnotNone:fname=str(Path(fname,self.sub_path))returnfname