Исходный код EJIO.utils.system.file_utils
## /utils/system/file_utils.py
import datetime
import os
import platform
from pathlib import Path
from contextlib import contextmanager
from collections.abc import Callable, Generator
from loguru import logger
__all__ = [
'change_cwd',
'generate_timestamped_filename',
'save_atomic',
'rotate_and_save',
]
def _make_file_hidden(file_path: Path) -> None:
"""
Внутренний хелпер: делает файл скрытым на уровне операционной системы.
:param file_path: Путь к файлу.
"""
try:
if platform.system().lower() == "windows":
import ctypes
kernel32 = ctypes.windll.kernel32
set_attr_func = getattr(kernel32, 'SetFileAttributesW', None)
if set_attr_func is not None:
set_attr_func.argtypes = [ctypes.c_wchar_p, ctypes.c_ulong]
set_attr_func.restype = ctypes.c_bool
# Константа 0x02 — это FILE_ATTRIBUTE_HIDDEN в Win32 API
set_attr_func(str(file_path), 0x02)
except Exception as e:
logger.debug(f"Failed to set the stealth attribute to the file {file_path}: {e}")
[документация]
def save_atomic(target_path: str | Path, write_callback: Callable[[Path], None]) -> None:
"""
Выполняет транзакционную (атомарную) запись в файл через скрытый временный файл.
Гарантирует, что оригинальный файл не будет поврежден в случае сбоя программы или ОС.
:param target_path: Целевой путь к файлу (куда нужно сохранить итог).
:param write_callback: Функция-коллбэк, принимающая Path временного файла, куда нужно писать данные.
"""
dest = Path(target_path).resolve()
# Создаем временное имя. На Linux/Mac точка на конце сделает его скрытым по умолчанию.
temp_name = f".tmp_{generate_timestamped_filename(dest.stem, dest.suffix)}"
temp_path = dest.parent.joinpath(temp_name)
try:
# 1. Обеспечиваем существование родительской папки
dest.parent.mkdir(parents=True, exist_ok=True)
# 2. Создаем пустой временный файл
temp_path.touch(exist_ok=True)
# 3. Передаем временный файл в callback пользователя для записи данных
write_callback(temp_path)
# 4. Выставляем файлу скрытость
_make_file_hidden(temp_path)
# 5. АТОМАРНАЯ СМЕНА (ТРАНЗАКЦИЯ):
# Переименовываем временный файл в целевой. На уровне ядра ОС
# это атомарная операция замены указателей. Старый файл затрется безболезненно.
os.replace(temp_path, dest)
except Exception as e:
logger.error(f"Critical failure of atomic write to file {dest.name}: {e}")
# В случае падения аккуратно убираем временный мусор, если он успел создаться
if temp_path.exists():
try:
os.remove(temp_path)
except OSError:
pass
raise
[документация]
def rotate_and_save(
target_path: str | Path,
max_backups: int,
write_callback: Callable[[Path], None]
) -> None:
"""
Управляет ротацией бэкапов файла по индексам (file.1.ext, file.2.ext)
и атомарно сохраняет новое состояние под основным именем.
:param target_path: Путь к основному файлу (например, 'config.json').
:param max_backups: Максимальное количество удерживаемых файлов бэкапа (>= 1).
:param write_callback: Функция-коллбэк для безопасной записи нового файла.
"""
dest = Path(target_path).resolve()
if max_backups < 1:
raise ValueError(f"Number of backups must be no less than 1, got {max_backups} instead.")
# Если основного файла еще нет — просто делаем атомарную запись
if not dest.exists():
save_atomic(dest, write_callback)
logger.info(f"Backup rotation and atomic file update successfully performed: {dest.name}")
return
# --- ФАЗА РОТАЦИИ БЭКАПОВ ---
# Обход делаем С КОНЦА (от старых к новым), чтобы не перезаписать файлы до сдвига
for i in range(max_backups, 0, -1):
# Формируем имя текущего индекса бэкапа (например, config.1.json)
current_backup = dest.parent.joinpath(f"{dest.stem}.{i}{dest.suffix}")
if current_backup.exists():
if i == max_backups:
# Самый старый бэкап за пределами лимита просто удаляем
os.remove(current_backup)
else:
# Остальные сдвигаем на 1 шаг вверх (config.1.json -> config.2.json)
next_backup = dest.parent.joinpath(f"{dest.stem}.{i + 1}{dest.suffix}")
os.replace(current_backup, next_backup)
# Основной файл сдвигаем в самый первый бэкап (config.json -> config.1.json)
first_backup = dest.parent.joinpath(f"{dest.stem}.1{dest.suffix}")
os.replace(dest, first_backup)
# --- ФАЗА СОХРАНЕНИЯ ---
# Записываем новое состояние под оригинальным именем через нашу атомарную транзакцию
save_atomic(dest, write_callback)
logger.info(f"Backup rotation and atomic file update successfully performed: {dest.name}")
[документация]
def generate_timestamped_filename(base_name: str, extension: str, prefix_date: bool = False) -> str:
"""
Генерирует имя файла, интегрируя текущую дату и время в безопасном формате ISO.
Пример: generate_timestamped_filename("report", "json")
Результат: "report_20260621_153045.json"
:param base_name: Базовое имя файла (например, 'backup', 'log').
:param extension: Расширение файла без точки (например, 'txt', 'csv').
:param prefix_date: Если True, дата-время встанет в начало: '20260621_153045_report.json'.
:return: Строка с уникальным именем файла.
"""
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
clean_ext = extension.lstrip('.')
if prefix_date:
return f"{timestamp}_{base_name}.{clean_ext}"
return f"{base_name}_{timestamp}.{clean_ext}"
[документация]
@contextmanager
def change_cwd(target_path: str | Path) -> Generator[Path, None, None]:
"""
Контекстный менеджер для временной смены текущей рабочей директории (CWD).
По завершении блока кода или при возникновении исключения гарантированно
возвращает рабочую папку в исходное состояние.
Пример использования:
with change_cwd("./resources"):
# Код выполняется внутри папки resources
open("theme.json", "r")
:param target_path: Путь к директории.
:yields: Путь к директории.
"""
original_cwd = Path(os.getcwd()).resolve()
resolved_target = Path(target_path).resolve()
if not resolved_target.exists():
raise FileNotFoundError(f"Cannot change directory. Path '{resolved_target}' does not exist.")
if not resolved_target.is_dir():
raise NotADirectoryError(f"Cannot change directory. Path '{resolved_target}' is not a directory.")
try:
os.chdir(resolved_target)
logger.debug(f"The working directory is temporarily changed to: '{resolved_target}'")
yield resolved_target
finally:
os.chdir(original_cwd)
logger.debug(f"The working directory was successfully restored to its original: '{original_cwd}'")