Files
controller-wisetopdataserver/python/biopro/file/manager.py
T
2021-12-20 14:52:55 +08:00

1053 lines
31 KiB
Python

"""File storage and Manager.
directory structure
-------------------
::
STORAGE_ROOT (set by -C)
|_ storage
| |_ .export.cache.txt
| |_ DATE
| | |_ RECORDING_FILES
| |_ external
| | |_ LINK
| |_ .trash
| |_ DATE
| |_ RECORDING_FILES
|_ export (EXPORT_ROOT, set by -E)
| |_ DATE
| |_ EXPORT_FILES
|_ user_repository (set by -U)
|_ USER
|_ .repository
|_ SETUP_FILES
Command line
------------
Usage ::
env PYTHONPATH=RaspBerryPi3/python python -m biopro.file.manager -C .BPS/ ...
debugging User Account System ::
... --debug-account-system user ...
debugging verbose output ::
... -v ...
Class
-----
"""
from datetime import date
from pprint import pprint
from shutil import rmtree
from biopro.util.console import CYAN
from biopro.util.lock import Synchronized, synchronized
from biopro.util.logger import LoggerFlag, logging_verbose
from .api import *
from .export import *
from .user.manager import UserSettingManager
from .util import *
from json import loads as json_parse, dumps as _json_stringify
import json
def json_stringify(o) -> str:
return _json_stringify(o, separators=(',', ':'))
class FileManager(FileAPI, LoggerFlag, Synchronized):
"""file manager."""
DEFAULT_EXPORT_CACHE_FILE = '.export.cache.txt'
def __init__(self, options: FileOptions):
"""
:param options: file manager options
"""
LoggerFlag.__init__(self, 'FileManager', CYAN)
Synchronized.__init__(self)
if options.storage_root is None:
raise RuntimeError('storage root not set')
s = Path(options.storage_root).absolute()
if options.export_root is None:
e = s / 'export'
else:
e = Path(options.export_root).absolute()
if options.user_repository is None:
options.user_repository = s / 'user_repository'
self._storage_root = s / 'storage'
self._export_root = e
self._external_root = (s / EXTERNAL_STORAGE_DIRECTORY)
self._user_setting = UserSettingManager(options)
self._current_storage_root = None
self._current_used_files = {}
self._export_manager = FileExporter()
self._export_storage_file = None
self._export_storage_save_request = False
self._mount_external = []
@property
def storage_root(self) -> Path:
return self._storage_root
@property
def export_root(self) -> Path:
return self._export_root
@property
def external_root(self) -> Path:
return self._external_root
@synchronized
@logging_verbose
def setup_storage(self):
"""setup storage directory.
:raises NotADirectoryError: if storage root or export root not a directory.
"""
storage_root = self._storage_root
export_root = self._export_root
# storage root
if not storage_root.exists():
if storage_root.is_symlink():
storage_root.unlink()
self.log_info('create directory', storage_root)
storage_root.mkdir(parents=True)
elif not storage_root.is_dir():
raise NotADirectoryError(str(storage_root))
# export root
if not export_root.exists():
if export_root.is_symlink():
export_root.unlink()
self.log_info('create directory', export_root)
export_root.mkdir(parents=True)
elif not export_root.is_dir():
raise NotADirectoryError(str(export_root))
# export cache
if self._export_storage_file is None:
self._export_storage_file = self._storage_root / self.DEFAULT_EXPORT_CACHE_FILE
if self._export_storage_file.exists():
self._load_export_cache()
# logging
self.log_info('storage root', storage_root)
self.log_info('export root', export_root)
self.log_info('user repository', self._user_setting.user_repository)
@synchronized
@logging_verbose
def update_storage(self):
"""update default storage path
:return:
"""
storage_root = self._storage_root
t = datetime.now()
d = '%d-%02d-%02d' % (t.year, t.month, t.day)
self._current_storage_root = current_root = storage_root / d
self.log_verbose('set save directory', current_root)
# storage root
if not current_root.exists():
self.log_info('create directory', current_root)
current_root.mkdir(parents=True)
elif not current_root.is_dir():
raise NotADirectoryError(str(storage_root))
@synchronized
@logging_verbose
def mount_external_auto(self):
if not self._external_root.exists():
self._external_root.mkdir(parents=True)
for p in Partition.list_block_device(sync=True):
if p.is_external_storage:
try:
q = p.mount_with_label(self._external_root)
except BaseException as e:
self.log_warn('cannot mount ' + p.fs_path + ' : ' + str(e))
else:
self.log_info('mount', str(q))
self._mount_external.append(PartitionInfo(p))
@synchronized
def mount_external(self, name: str, path: Path):
if not path.exists():
self.log_warn('mount path not found', str(path))
return
if name == '':
name = path.name
self.log_verbose('mount_external', name, '->', str(path))
if name == '*':
if path.is_dir():
for p in path.iterdir():
self.mount_external('', p)
else:
self.log_warn('mount path not directory', str(path))
else:
if not self._external_root.exists():
self._external_root.mkdir(parents=True)
f = self._external_root / name
if f.exists():
if not f.is_symlink():
raise ValueError('file existed')
f.unlink()
f.symlink_to(path.absolute(), target_is_directory=True)
@synchronized
def umount_external(self):
self.log_verbose('umount_external')
if not self._external_root.exists():
return
for p in self._mount_external:
m = Path(p.partition.mount_path)
self.log_verbose('umount', self.shadow_path(m))
p.partition.umount()
if m.exists():
try:
m.rmdir()
except OSError:
pass
self._mount_external.clear()
for p in list(self._external_root.iterdir()):
if p.is_symlink():
p.unlink()
@synchronized
@logging_verbose
def shutdown(self, umount_external=False):
self._export_manager.shutdown()
self._user_setting.shutdown()
if umount_external:
self.umount_external()
if self._export_storage_save_request:
self._save_export_cache()
def _load_export_cache(self):
cache = self._export_storage_file
if cache is None:
return
if not cache.exists():
return
self.log_verbose('load_export_cache', cache)
tmp = {}
with cache.open('r') as f:
for line in f:
line = line.strip()
if len(line) > 0:
part = line.split('\t', 3)
try:
file_path = part[0]
file_info = tmp.get(file_path, None)
export_type = part[1]
export_path = Path(part[2])
except IndexError:
pass
else:
if file_info is None:
try:
info_path = self.validate_path_root(file_path)
if info_path.is_file():
tmp[file_path] = file_info = FileInfo.get_info(self.storage_root, info_path)
else:
continue
except FileNotFoundError:
continue
try:
export_path = (self.export_root / export_path).resolve()
except FileNotFoundError:
pass
else:
if export_path.exists() and path_start_with(export_path, self.export_root):
file_info.add_export_path(export_type,
export_path.relative_to(self.export_root))
def _save_export_cache(self):
cache = self._export_storage_file
if cache is None:
return
self.log_verbose('save_export_cache', cache)
with cache.open('w') as f:
for file_info in FileInfo.CACHE.values():
if file_info.is_meta_file:
for export_type, export_path in file_info.export.items():
if export_path is not None:
file_path = file_info.file_path
if len(file_path) > 0:
try:
print(file_path,
export_type,
export_path,
sep='\t', file=f)
except ValueError:
pass
self._export_storage_save_request = False
def _validate_directory_not_used(self, path: Path):
"""check none of files under directory **path** is opened
:param path: directory path
:raise RuntimeError: file in directory is opened
"""
for p in self._current_used_files.values():
if path_start_with(p, path):
raise RuntimeError('directory in use : ' + self.shadow_path(path))
def _validate_file_not_used(self, path: Path):
"""check file **path** is not opened
:param path: file path
:raise RuntimeError: file is opened
"""
for p in self._current_used_files.values():
if p == path:
raise RuntimeError('file in use : ' + self.shadow_path(path))
def _validate_path_not_external(self, path: Path):
if path_in_external(path):
raise RuntimeError('file in external storage : ' + self.shadow_path(path))
@property
def user_setting(self) -> UserSettingManager:
return self._user_setting
@synchronized
@logging_verbose
def index(self) -> RootInfo:
return RootInfo(self.storage_root)
@synchronized
@logging_verbose
@validate_path_root
def info(self, path: str) -> FileInfo:
path = resolve_path_if_trashed(path)
result = FileInfo.get_info(self.storage_root, path)
return result
@synchronized
@logging_verbose
@validate_path_root
def meta(self, path: str) -> RecordingMetaFile:
path = resolve_path_if_trashed(path)
info = FileInfo.get_info(self.storage_root, path)
meta = info.meta_file
if meta is None:
raise RuntimeError('not a meta file', self.shadow_path(path))
return meta
@synchronized
@logging_verbose
@validate_path_root
def export_info(self, path: Union[str, Path]) -> Dict[str, Optional[Path]]:
info = FileInfo.get_info(self.storage_root, path)
if info.meta_file is None:
raise RuntimeError('not a meta file', self.shadow_path(path))
return info.export
@synchronized
@logging_verbose
@validate_path_root
@validate_path_is_directory(resolve_in_trash=True)
def list(self, path: Union[str, str]) -> DirectoryInfo:
### file list
return DirectoryInfo(self.storage_root, path)
@synchronized
@logging_verbose
@validate_path_root
@validate_path_is_directory(resolve_in_trash=True)
def list_range(self, path: Union[str, str], start, end) -> DirectoryInfo:
### file list
return DirectoryInfo(self.storage_root, path)
@synchronized
@logging_verbose
@validate_path_root
def mkdir(self, path: Union[str, Path]) -> DirectoryInfo:
if path.exists():
if path.is_file():
raise FileExistsError(self.shadow_path(path))
else:
path.mkdir(parents=True)
return DirectoryInfo(self.storage_root, path)
def use(self, device: Union[int, Device]) -> FileInfo:
if isinstance(device, Device):
device = device.device_id
self.log_verbose('use', 'device', device)
path = self._current_used_files.get(device, None)
def _remove_used():
try:
del self._current_used_files[device]
except KeyError:
pass
if path is not None:
try:
info = FileInfo.get_info(self.storage_root, path)
except FileNotFoundError:
_remove_used()
else:
if not info.is_meta_file or not info.file_path_origin.exists():
_remove_used()
else:
return info
return FileInfo.get_info(self.storage_root, self._current_storage_root)
def unset(self, device: Union[int, Device]):
if isinstance(device, Device):
device = device.device_id
self.log_verbose('unset', 'device', device)
try:
del self._current_used_files[device]
except KeyError:
pass
@synchronized
def save(self,
device: CompletedDevice,
filepath: Union[str, Path, FileInfo],
overwrite=False) -> FileInfo:
if isinstance(filepath, FileInfo):
filepath = filepath.file_path
overwrite = True
elif isinstance(filepath, Path):
filepath = str(filepath)
self.log_verbose('save', filepath, '(overwrite)' if overwrite else '')
# create meta file
if "/" in filepath:
filepath = self.validate_path_root(filepath)
meta = RecordingMetaFile(filepath)
else:
# use default directory
meta = RecordingMetaFile(self._current_storage_root / filepath)
# check file path
if meta.filepath.exists() and not overwrite:
raise FileExistsError(self.shadow_path(meta.filepath))
p = meta.filepath.parent
if not p.exists():
p.mkdir(parents=True)
# set meta and save
meta.device = json_stringify(device.as_json())
meta.configuration = device.configuration
meta.write()
# file info load
info = FileInfo.get_info(self.storage_root, meta.filepath)
info.meta_file = meta
# set used
self._current_used_files[device.device_id] = meta.filepath
return info
@synchronized
@logging_verbose
@validate_path_root
def rename(self, path: Union[str, Path], name: str) -> FileInfo:
source_path = path
target_path = source_path.with_name(name)
if source_path.is_dir():
self._validate_directory_not_used(source_path)
self._validate_directory_not_used(target_path)
# XXX overwrite checking?
source_path.rename(target_path)
FileInfo.invalid_cache(source_path)
else:
self._validate_file_not_used(source_path)
self._validate_file_not_used(target_path)
info = FileInfo.get_info(self.storage_root, source_path)
if info.is_meta_file:
# XXX overwrite checking?
target_path = RecordingMetaFile.load(source_path).change_filepath(target_path)
FileInfo.invalid_cache(source_path)
else:
raise RuntimeError('unsupported rename for file type : ' + info.file_type)
return FileInfo.get_info(self.storage_root, target_path)
@logging_verbose
@validate_path_root
def delete(self, path: Union[str, Path]) -> bool:
self._validate_path_not_external(path)
path = resolve_path_if_trashed(path)
return self._delete(path)
@synchronized
def _delete(self, path: Path) -> bool:
if path_start_with(path, self._external_root):
return False
if not path.exists():
return False
if RecordingFile.is_recording_file(path):
return False
self.log_verbose('deleted', self.shadow_path(path))
if path.is_dir() and not path.is_symlink():
rmtree(str(path), ignore_errors=True)
elif RecordingMetaFile.is_recording_meta_file(path):
RecordingMetaFile.remove_meta_file(path)
else:
path.unlink()
# invalid the file under this directory in cache
FileInfo.invalid_cache(path)
return True
@logging_verbose
@validate_path_root
def trash(self, path: Union[str, Path]) -> bool:
self._validate_path_not_external(path)
if not path.exists():
return False
return self._trash(path)
@synchronized
def _trash(self, path: Path) -> bool:
if path_in_trash(path):
return False
if path_start_with(path, self._external_root):
return False
if RecordingFile.is_recording_file(path):
return False
trash_root = path.parent / TRASH_DIRECTORY
if not trash_root.exists():
trash_root.mkdir()
target_path = trash_root / path.name
self.log_verbose(self.shadow_path(path), '->', self.shadow_path(target_path))
if RecordingMetaFile.is_recording_meta_file(path):
RecordingMetaFile.load(path).change_filepath(target_path)
else:
path.rename(target_path)
# invalid the file under this directory in cache
FileInfo.invalid_cache(path)
if target_path.is_dir():
self._untrash(target_path)
return True
def _untrash(self, path: Path) -> bool:
trash_dir = path / TRASH_DIRECTORY
if trash_dir.exists():
for p in list(trash_dir.iterdir()):
if RecordingFile.is_recording_file(p):
continue
t = path / p.name
if t.exists():
i = 0
while t.exists():
t = t.with_name(p.name + '_%d' % i)
i += 1
self.log_verbose(self.shadow_path(p), '->', self.shadow_path(t))
if RecordingMetaFile.is_recording_meta_file(p):
RecordingMetaFile.load(p).change_filepath(t)
else:
p.rename(t)
rmtree(str(trash_dir), ignore_errors=True)
for p in path.iterdir():
if p.is_dir() and not p.is_symlink():
self._untrash(p)
return True
@logging_verbose
@validate_path_root
def restore(self, path: Union[str, Path]) -> bool:
self.log_verbose('restore', str(path))
path = resolve_path_if_trashed(path)
if not path_in_trash(path):
return False
else:
return self._restore(path)
@synchronized
def _restore(self, path: Path) -> bool:
if RecordingFile.is_recording_file(path):
return False
target_path = resolve_path_un_trashed(path)
# trashed_root = get_trashed_root(path)
if target_path.exists():
self.log_verbose('file existed', self.shadow_path(target_path))
return False
target_directory = target_path.parent
if not target_directory.exists():
target_directory.mkdir(parents=True)
self.log_verbose(self.shadow_path(path), '->', self.shadow_path(target_path))
if RecordingMetaFile.is_recording_meta_file(path):
RecordingMetaFile.load(path).change_filepath(target_path)
else:
path.rename(target_path)
# invalid the file under this directory in cache
FileInfo.invalid_cache(target_path.parent)
return True
@logging_verbose
def perform_trashing(self, expire: int):
self._perform_trashing(self.storage_root, time() - 86400 * expire)
def _perform_trashing(self, path: Path, expire_time: float):
next_dir = []
for p in list(path.iterdir()):
if not p.exists():
# been trash, not existed.
continue
if p.name == TRASH_DIRECTORY:
continue
if path_start_with(p, self._external_root):
# do not do this in external root
continue
mt = p.stat().st_mtime
if mt < expire_time:
self._trash(p)
elif p.is_dir() and not p.is_symlink():
next_dir.append(p)
for p in next_dir:
self._perform_trashing(p, expire_time)
@synchronized
@logging_verbose
def clean_trash(self, expire: Optional[int] = None):
if expire is None:
expire_time = None
else:
expire_time = time() - 86400 * expire
self._trash_clean(self.storage_root, expire_time, in_trash=False)
def _trash_clean(self, path: Path, expire_time: Optional[int], in_trash: bool):
if path_start_with(path, self._external_root):
return
if in_trash:
for p in list(path.iterdir()):
if p.exists():
mt = p.stat().st_mtime
if mt < expire_time:
self._delete(p)
else:
for p in path.iterdir():
if p.is_dir():
if p.name == TRASH_DIRECTORY:
self._trash_clean(p, expire_time, in_trash=True)
else:
self._trash_clean(p, expire_time, in_trash=False)
@logging_verbose
def perform_archive(self, expire: Optional[int] = None):
# XXX when or what period should we use?
if expire is None:
expire_time = None
else:
expire_time = time() - 86400 * expire
self._perform_archive(self.storage_root, expire_time)
def _perform_archive(self, path: Path, expire_time: float):
next_dir = []
for p in list(path.iterdir()):
if p.name == TRASH_DIRECTORY:
# do not perform archive in trashing
continue
if p.is_dir() and not p.is_symlink():
next_dir.append(p)
elif RecordingMetaFile.is_recording_meta_file(p):
mt = p.stat().st_mtime
if mt < expire_time:
self._compress_archive(p)
for p in next_dir:
self._perform_archive(p, expire_time)
# def _compress_archive(self, path: Path):
# from biopro.recording.tar import compress_meta_file
# compress_meta_file(RecordingMetaFile.load(path), delete_source=True)
# FileInfo.invalid_cache(path)
# def _extra_archive(self, path: Path):
# from biopro.recording.tar import extra_meta_file
# # XXX how do we perform file action on a archive?
# extra_meta_file(path, overwrite=True, delete_source=True)
# FileInfo.invalid_cache(path)
@synchronized
@logging_verbose
def clean_storage(self) -> bool:
self.umount_external()
rmtree(str(self.storage_root), ignore_errors=True)
rmtree(str(self.export_root), ignore_errors=True)
FileInfo.invalid_cache_all()
self._export_storage_save_request = False
self._storage_root.mkdir()
self._export_root.mkdir()
self._external_root.mkdir()
return True
def get_export_functions(self) -> Tuple[ExternalExportFunction, ...]:
return self._export_manager.get_export_functions()
def get_export_parameters(self, export_type: str) -> Tuple[ExternalParameter, ...]:
return self._export_manager.get_export_parameters(export_type)
@validate_path_root(path='source_path')
def export_file_path(self, export_type: str, source_path: Union[str, Path]) -> Path:
return self._export_file_path(export_type, source_path)
def _export_file_path(self, export_type: str, source_path: Path) -> Path:
export_func = self._export_manager.get_export_func(export_type)
if not source_path.is_file():
raise FileNotFoundError(self.shadow_path(source_path))
info = FileInfo.get_info(self.storage_root, source_path)
if not info.is_meta_file:
raise RuntimeError('not a meta file : ' + self.shadow_path(source_path))
source_directory = source_path.parent.relative_to(self._storage_root)
export_directory = self._export_root / source_directory
return (export_directory / source_path.name).with_suffix(export_func.ext)
@validate_path_root(path='source_path')
def export_file(self,
source_path: Union[str, Path],
export_path: Union[None, str, Path],
export_type: str,
async_call=False,
overwrite=False,
**options) -> Path:
self.log_verbose('export_file', export_type, source_path)
source_path = source_path.with_suffix(RecordingMetaFile.FILE_EXT)
_export_path = self._export_file_path(export_type, source_path)
# create export target directory
if export_path is None:
export_path = _export_path
else:
if isinstance(export_path, str):
export_path = Path(export_path)
export_path = export_path.absolute()
if export_path.exists() and not overwrite:
return export_path.relative_to(self.export_root)
export_directory = export_path.parent
if not export_directory.exists():
export_directory.mkdir(parents=True)
# set cache process
info = FileInfo.get_info(self.storage_root, source_path)
info.add_export_path(export_type, None)
self._export_storage_save_request = True
try:
ret = self._export_manager.export_file(source_path, export_path, export_type,
async_call=async_call,
overwrite=overwrite,
**options)
except BaseException:
info.del_export_path(export_type)
raise
else:
ret = ret.relative_to(self.export_root)
info.add_export_path(export_type, ret)
return ret
@validate_path_is_file(path='import_path')
def import_file(self,
import_path: Union[str, Path],
target_path: Union[None, str, Path],
import_type: str,
async_call=False,
overwrite=False, **options) -> Path:
self._export_manager.get_import_func(import_type)
# create import result directory
if target_path is None:
import_root = self._storage_root / 'import'
import_directory = import_root / str(date.today())
if not import_directory.exists():
import_directory.mkdir(parents=True)
target_path = (import_directory / import_path.name)
else:
target_path = self.validate_path_root(target_path)
target_path = target_path.with_suffix(RecordingMetaFile.FILE_EXT)
if target_path.exists() and not overwrite:
return target_path.relative_to(self.storage_root)
ret = self._export_manager.import_file(import_path, target_path, import_type,
async_call=async_call,
overwrite=overwrite,
**options)
return ret.relative_to(self.storage_root)
@synchronized
#@validate_export_root(path='export_path')
@validate_path_root(path='export_path')
def segment_info(self, export_path: Union[str, Path], create=False) -> Optional[FileSegment]:
print('*****3', export_path)
return FileSegment.get(export_path, create=create)
# noinspection PyUnusedLocal
class Main(CliMain, FileOptions, metaclass=CliCommandDelegateMacro(FileAPI, 'file_manager', '_result')):
"""Controller Server File Manager.
"""
def __init__(self):
super().__init__()
FileOptions.__init__(self)
self.storage_root = '.'
self.output_format = 'raw'
self.file_action = None
self.file_path = None
self.file_argv = []
self._file_manager = None
@cli_flags('-h', '--help', force_return=True)
def _help(self, opt: str):
"""print help document"""
self.print_help()
@cli_flags('-v', '--verbose')
def _verbose(self, opt: str):
"""verbose mode, enable logger"""
LoggerFlag.set_formatter(LoggerFlag.OPTIONS.STYLE_DEFAULT)
@cli_options('-f', '--format', value='FORMAT')
def _output_format(self, opt: str, value: str):
"""output format. could be:
raw : raw data format
detail : detail raw data format
json : json text
"""
if value not in ('raw', 'detail', 'json'):
raise RuntimeError('illegal output format : ' + value)
self.output_format = value
@property
def file_manager(self) -> FileManager:
return self._file_manager
def __enter__(self):
self._file_manager = FileManager(self)
self._file_manager.setup_storage()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self._file_manager is not None:
self._file_manager.shutdown()
self._file_manager = None
def _result(self, command: str, result: Any):
if isinstance(result, list):
for o in result:
self._result(command, o)
print()
elif self.output_format == 'raw':
if isinstance(result, BaseInfo):
result.print()
else:
print(result)
elif self.output_format == 'detail':
if isinstance(result, BaseInfo):
result.print(detail_mode=True)
else:
print(result)
elif self.output_format == 'json':
if isinstance(result, JsonSerialize):
result = result.as_json()
pprint(result)
@cli_command('help')
def _help_command(self, command: str, argv: List[str]):
"""print help document"""
self.print_help()
@cli_command('user')
def _user_setting(self, command: str, argv: List[str]):
"""user setting"""
from .user.main import Main
with self:
Main(self.file_manager.user_setting).main(argv)
@cli_command("export", value='...')
def _export_file(self, command: str, argv: List[str]):
"""export file through the file manager"""
return FileExportMain(command, self)
@cli_command("import", value='...')
def _import_file(self, command: str, argv: List[str]):
"""import file through the manager"""
return FileImportMain(command, self)
def run(self):
self.print_help()
if __name__ == '__main__':
LoggerFlag.set_formatter(LoggerFlag.OPTIONS.STYLE_QUIET)
Main().main()