tools.win_pg_dump_controller
2022-01-30
Child:a22dd63ba19e
0:be791d354d2a Browse Files
..init
.hgignore README.md examples/config.ini win_pg_dump_controller/__init__.py win_pg_dump_controller/__main__.py win_pg_dump_controller/config.py win_pg_dump_controller/error.py win_pg_dump_controller/executor.py win_pg_dump_controller/log_controller.py win_pg_dump_controller/store_controller.py
1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/.hgignore Sun Jan 30 22:17:39 2022 +0300 1.3 @@ -0,0 +1,2 @@ 1.4 +syntax: glob 1.5 +.idea/*
2.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 2.2 +++ b/README.md Sun Jan 30 22:17:39 2022 +0300 2.3 @@ -0,0 +1,55 @@ 2.4 +Мотивация 2.5 +========= 2.6 + 2.7 +Есть прекрасная платформа `windows`. И есть в ней сервисы разные, но нет консоли нормальной. Либо `cmd`, 2.8 +либо `powershell` и крутись как хочешь? 2.9 + 2.10 +Для того чтобы не крутиться хотя бы в вопросах рещервных копий `PostgreSQL` создан этот модуль. 2.11 + 2.12 +Почему не другие решения? Поскольку хорошее решение требует нормальной инфраструктуры, а не `PostgreSQL` на `Windows`. 2.13 +В нормальной инфраструктуре это всё делается проще и скрипты уже есть. Но здесь `windows` 2.14 + 2.15 +Кроме того, имея эту основу можно попытаться сделать нечто более сложное. 2.16 + 2.17 + 2.18 +Как пользоваться? 2.19 +================= 2.20 + 2.21 +Запуск модуля через `c:\python38\python.exe -m win_pg_dump_controller c:\etc\pg_backup.config`. 2.22 +`c:\etc\pg_backup.config` файл в формате `INI`. Пример файла в `examples` 2.23 + 2.24 + 2.25 +Описание Конфигурационного файла 2.26 +-------------------------------- 2.27 + 2.28 +### Секции 2.29 + 2.30 +* `main` - основная конфигурация скрипта 2.31 +* `common` - общие параметры заданий 2.32 +* `${Имя задания}` - параметры задания. `${Имя задания}` задаётся пользователем, чтобы ему было понятно. 2.33 + Оно фигурирует в журналах, используется в файлах резерных копий. Поэтому есть смысл избегать в нём русских букв 2.34 + и специальных символов 2.35 + 2.36 + 2.37 +### Параметры в `main` 2.38 + 2.39 +* `pg_bin_path` - расположение директории `bin` нужной инстраляции `PostgreSQL`. 2.40 + Можно поискать файл `pg_dump.exe` и внести сюда ту директорию, в которой он лежит 2.41 +* `pg_dump_flags` - если не хватает флагов `pg_dump`, недостающие можно перечислить здесь. 2.42 +* `log_dir` - каталог, куда будут писаться логи всего что происходит. Если не задан, логи писаться не будут. 2.43 +* `teir1_days` - период самых последних резервных копий. Количество дней, за которые они вообще чиститься не будут. 2.44 + По умолчанию 7 дней. 2.45 +* `teir2_copies_interval` - архивный период резервных копий, количество дней между сохранёнными копиями. То есть 2.46 + за этот период будет храниться только одна, самая старая копия. По умолчанию 7 дней 2.47 +* `tier2_store_days` - за этим количеством дней копии чистятся. По умолчанию 30 дней 2.48 +* `keep_logs_days` - количество дней за который хранить журналы. 2.49 + 2.50 + 2.51 +### Параметры `common` и раздела задач 2.52 + 2.53 +* `host_name` - имя или IP узла баз данных. По умолчанию `127.0.0.1` 2.54 +* `db_name` - имя базы данных 2.55 +* `user_name` - имя пользователя для подключения к серверу 2.56 +* `passwd` - пароль пользователя 2.57 +* `port` - порт сервера баз данных 2.58 +* `dst_dir` - каталог, к котором будут хранится резервные копии задания.
3.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 3.2 +++ b/examples/config.ini Sun Jan 30 22:17:39 2022 +0300 3.3 @@ -0,0 +1,13 @@ 3.4 +[main] 3.5 +pg_bin_path = c:\program files\postgreSQL\14\bin 3.6 +log_dir=c:\var\dblog 3.7 + 3.8 +[common] 3.9 +host_name=192.168.0.1 3.10 +port=5433 3.11 +dst_dir=c:\var\backup 3.12 + 3.13 +[todolist] 3.14 +db_name=my_prod_database 3.15 +user_name=some_user 3.16 +passwd=P@ssW0rd 3.17 \ No newline at end of file
4.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 4.2 +++ b/win_pg_dump_controller/__init__.py Sun Jan 30 22:17:39 2022 +0300 4.3 @@ -0,0 +1,1 @@ 4.4 +# coding: utf-8
5.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 5.2 +++ b/win_pg_dump_controller/__main__.py Sun Jan 30 22:17:39 2022 +0300 5.3 @@ -0,0 +1,38 @@ 5.4 +# coding: utf-8 5.5 + 5.6 +from .log_controller import LogController 5.7 +from .config import Config 5.8 +from .executor import backup 5.9 +from .error import Error 5.10 + 5.11 +config = Config() 5.12 +log_controller = LogController(config) 5.13 + 5.14 +log = log_controller.get_logger('main') 5.15 +try: 5.16 + log_t = log.get_timing() 5.17 + 5.18 + log(log_t(f'Начало процесса')) 5.19 + 5.20 + for task in config.tasks: 5.21 + log(log_t(f'Обработка: {task.name}')) 5.22 + backup(task, config, log_controller) 5.23 + 5.24 + log(log_t(f'Завершение обработки: {task.name}')) 5.25 + 5.26 + log(log_t('Очистка старых журналов...')) 5.27 + log_controller.clean() 5.28 + log(log_t('Завершено')) 5.29 + 5.30 +except Error as e: 5.31 + log.err(str(e)) 5.32 + print('FAIL') 5.33 + exit(1) 5.34 + 5.35 +except: 5.36 + log.excpt('Неизвестная ошибка') 5.37 + print('FAIL') 5.38 + exit(2) 5.39 + 5.40 +else: 5.41 + print('ok') 5.42 \ No newline at end of file
6.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 6.2 +++ b/win_pg_dump_controller/config.py Sun Jan 30 22:17:39 2022 +0300 6.3 @@ -0,0 +1,103 @@ 6.4 +# coding: utf-8 6.5 +from configparser import ConfigParser, SectionProxy 6.6 +from sys import argv 6.7 +from os.path import isdir 6.8 +from typing import Optional, Iterable 6.9 + 6.10 + 6.11 +from .error import Error 6.12 + 6.13 + 6.14 +class ConfigError(Error): 6.15 + pass 6.16 + 6.17 + 6.18 +COMMON_SECTION = 'common' 6.19 +MAIN = 'main' 6.20 +DEFAULT_PG_PORT = 5432 6.21 + 6.22 + 6.23 +class DBTask(object): 6.24 + def __init__(self, task_name: str, host_name: str, db_name: str, user_name: str, passwd: Optional[str], 6.25 + dst_dir: str, port: int = DEFAULT_PG_PORT): 6.26 + self.host_name = host_name 6.27 + self.db_name = db_name 6.28 + self.user_name = user_name 6.29 + self.passwd = passwd 6.30 + self.dst_dir = dst_dir 6.31 + self.name = task_name 6.32 + self.port = port 6.33 + 6.34 + if not (self.db_name or self.user_name or self.dst_dir or self.host_name): 6.35 + raise ConfigError(f'Some important config parameters not set: ' 6.36 + f'db_name="{db_name}" ' 6.37 + f'user_name="{user_name}" ' 6.38 + f'dst_dir="{dst_dir}" ' 6.39 + f'host_name="{host_name}"') 6.40 + 6.41 + 6.42 +class CommonTaskDescription(object): 6.43 + def __init__(self, host_name: Optional[str], user_name: Optional[str], 6.44 + passwd: Optional[str], dst_dir: Optional[str], port: int = DEFAULT_PG_PORT): 6.45 + self.user_name = user_name 6.46 + self.passwd = passwd 6.47 + self.dst_dir = dst_dir 6.48 + self.host_name = host_name 6.49 + self.port = port 6.50 + 6.51 + def parse_section(self, config_section: SectionProxy) -> DBTask: 6.52 + db_name = config_section.get('db_name') 6.53 + user_name = config_section.get('user_name', self.user_name) 6.54 + passwd = config_section.get('passwd', self.passwd) 6.55 + dst_dir = config_section.get('dst_dir', self.dst_dir) 6.56 + host_name = config_section.get('host_name', self.host_name) 6.57 + port = config_section.getint('port', self.port) 6.58 + 6.59 + if not isdir(dst_dir): 6.60 + raise ConfigError(f'Destionation directory not exists for "{config_section.name}": {dst_dir}') 6.61 + 6.62 + return DBTask(task_name=config_section.name, host_name=host_name, db_name=db_name, 6.63 + user_name=user_name, passwd=passwd, port=port, dst_dir=dst_dir) 6.64 + 6.65 + @classmethod 6.66 + def parse_config(cls, config: ConfigParser) -> Iterable[DBTask]: 6.67 + if not config.has_section(COMMON_SECTION): 6.68 + tmpl = cls(host_name=None, user_name=None, passwd=None, dst_dir=None) 6.69 + 6.70 + else: 6.71 + _section = config['common'] 6.72 + host_name = _section.get('host_name', '127.0.0.1') 6.73 + user_name = _section.get('user_name') 6.74 + passwd = _section.get('passwd') 6.75 + dst_dir = _section.get('dst_dir') 6.76 + port = _section.getint('port', DEFAULT_PG_PORT) 6.77 + 6.78 + tmpl = cls(host_name=host_name, user_name=user_name, passwd=passwd, dst_dir=dst_dir, port=port) 6.79 + 6.80 + for _section in filter(lambda x: x not in (COMMON_SECTION, MAIN), config.sections()): 6.81 + yield tmpl.parse_section(config[_section]) 6.82 + 6.83 + 6.84 +class Config(object): 6.85 + def __init__(self): 6.86 + try: 6.87 + config_file = argv[1] 6.88 + except IndexError: 6.89 + raise ConfigError(f'No config file specified') 6.90 + 6.91 + _config = ConfigParser() 6.92 + _config.read(config_file) 6.93 + 6.94 + _main_section = _config[MAIN] 6.95 + self.pg_bin_path = _main_section.get('pg_bin_path') 6.96 + self.pg_dump_flags = _main_section.get('pg_dump_flags', '') 6.97 + self.log_dir = _main_section.get('log_dir') 6.98 + self.teir1_days = _main_section.getint('teir1_days', 7) 6.99 + self.teir2_copies_interval = _main_section.getint('teir2_copies_interval', 7) 6.100 + self.tier2_store_days = _main_section.getint('tier2_store_days', 30) 6.101 + self.keep_logs_days = _main_section.getint('keep_logs_days', 30) 6.102 + 6.103 + if not isdir(self.pg_bin_path): 6.104 + raise ConfigError(f'No valid directory with binnary files of PostgreSQL is set: {self.pg_bin_path}') 6.105 + 6.106 + self.tasks = list(CommonTaskDescription.parse_config(_config))
7.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 7.2 +++ b/win_pg_dump_controller/error.py Sun Jan 30 22:17:39 2022 +0300 7.3 @@ -0,0 +1,9 @@ 7.4 +# coding: utf-8 7.5 + 7.6 +class Error(Exception): 7.7 + @staticmethod 7.8 + def err_fmt(e: Exception) -> str: 7.9 + return f'{type(e)}({e})' 7.10 + 7.11 + def __str__(self): 7.12 + return f'{super().__str__()} - [{type(self).__name__}]'
8.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 8.2 +++ b/win_pg_dump_controller/executor.py Sun Jan 30 22:17:39 2022 +0300 8.3 @@ -0,0 +1,64 @@ 8.4 +# coding: utf-8 8.5 + 8.6 +import subprocess as sp 8.7 +from os.path import join as p_join 8.8 +from os import environ 8.9 + 8.10 +from .store_controller import StoreController 8.11 +from .log_controller import LogController 8.12 +from .config import Config, DBTask 8.13 +from .error import Error 8.14 + 8.15 + 8.16 +class ExecutorError(Error): 8.17 + pass 8.18 + 8.19 + 8.20 +def backup(task: DBTask, config: Config, log_controller: LogController): 8.21 + log = log_controller.get_logger(f'{task.name}.executor') 8.22 + log_t = log.get_timing() 8.23 + log(log_t(f'Начинаем копирование {task.name}')) 8.24 + 8.25 + 8.26 + stor = StoreController(task) 8.27 + backup_item = stor.new_item() 8.28 + 8.29 + log(log_t(f'Копируется "{task.db_name}" -> "{backup_item.get_path()}" ' 8.30 + f'с параметрами pg_dump "{config.pg_dump_flags}"')) 8.31 + 8.32 + pg_dump = p_join(config.pg_bin_path, 'pg_dump.exe') 8.33 + pg_dump = ( 8.34 + f'"{pg_dump}" {config.pg_dump_flags} -F c -v --clean ' 8.35 + f'-U "{task.user_name}" -h "{task.host_name}" -p "{task.port}" ' 8.36 + f'-f "{backup_item.get_path()}" {task.db_name}' 8.37 + ) 8.38 + 8.39 + environ['PGPASSWORD'] = task.passwd 8.40 + 8.41 + pglog_name = log_controller.get_filename(task.name) 8.42 + pglog_fd = sp.DEVNULL if pglog_name is None else open(pglog_name, 'w') 8.43 + 8.44 + log(log_t(f'Пробуем запустить: {pg_dump}')) 8.45 + 8.46 + try: 8.47 + cmd = sp.Popen(pg_dump, stderr=sp.STDOUT, stdout=pglog_fd, shell=True) 8.48 + cmd.wait() 8.49 + 8.50 + log(log_t(f'Команда выполнена, статус возврата "{cmd.returncode}", журнал: "{pglog_name}"')) 8.51 + 8.52 + if cmd.returncode != 0: 8.53 + raise ExecutorError(f'Не нулевой код возврата, подробности в "{pglog_name}"') 8.54 + 8.55 + except: 8.56 + stor.remove(backup_item) 8.57 + raise 8.58 + 8.59 + else: 8.60 + stor.add_item(backup_item) 8.61 + 8.62 + log(log_t('Очистка старых копий')) 8.63 + 8.64 + log(log_t('\n'.join(map(lambda x: f'- {x}', stor.clean(config.teir1_days, config.teir2_copies_interval, 8.65 + config.tier2_store_days))))) 8.66 + 8.67 + stor.save_index()
9.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 9.2 +++ b/win_pg_dump_controller/log_controller.py Sun Jan 30 22:17:39 2022 +0300 9.3 @@ -0,0 +1,167 @@ 9.4 +# coding: utf-8 9.5 +"""\ 9.6 +Модуль логирования 9.7 + 9.8 +Метки в журнале о уровне сообщения: 9.9 + "`": Debug 9.10 + ".": Info 9.11 + "*": Warning 9.12 + "!": Error 9.13 + "#": Alert 9.14 +""" 9.15 + 9.16 +from time import time, ctime 9.17 +from datetime import timedelta, datetime 9.18 +from sys import exc_info 9.19 +from os.path import join as p_join 9.20 +from os import listdir, remove as file_remove, stat 9.21 +from traceback import extract_tb, extract_stack 9.22 +from typing import Optional 9.23 + 9.24 +from .config import Config 9.25 + 9.26 + 9.27 +class Timing(object): 9.28 + def __init__(self, name: Optional[str] = None): 9.29 + if name is None: 9.30 + self.prefix = '' 9.31 + else: 9.32 + self.prefix = f'{name} :: ' 9.33 + self.tsAll = time() 9.34 + self.ts = self.tsAll 9.35 + 9.36 + def get_time(self): 9.37 + return time() - self.ts 9.38 + 9.39 + def reset(self): 9.40 + self.ts = time() 9.41 + self.tsAll = self.ts 9.42 + 9.43 + def __str__(self): 9.44 + ts = time() 9.45 + return self.prefix + '%s(%.4f)' % (timedelta(seconds=(ts - self.tsAll)), ts - self.ts) 9.46 + 9.47 + def __call__(self, msg): 9.48 + _buf = f'{self} | {msg}' 9.49 + self.ts = time() 9.50 + return _buf 9.51 + 9.52 + 9.53 +class BaseLogger(object): 9.54 + def __init__(self, appname='main'): 9.55 + self.appname = appname 9.56 + 9.57 + @staticmethod 9.58 + def _write(itr_content): 9.59 + raise NotImplemented() 9.60 + 9.61 + def __call__(self, msg): 9.62 + self._write(map( 9.63 + lambda x: '%3s | %s :: %s' % ('.', self.appname, x), 9.64 + str(msg).splitlines() 9.65 + )) 9.66 + 9.67 + def err(self, msg): 9.68 + self._write(map( 9.69 + lambda x: '%3s | %s :: %s' % ('!', self.appname, x), 9.70 + str(msg).splitlines() 9.71 + )) 9.72 + 9.73 + def warn(self, msg): 9.74 + self._write(map( 9.75 + lambda x: '%3s | %s :: %s' % ('*', self.appname, x), 9.76 + str(msg).splitlines() 9.77 + )) 9.78 + 9.79 + def alert(self, msg): 9.80 + self._write(map( 9.81 + lambda x: '%3s | %s :: %s' % ('#', self.appname, x), 9.82 + str(msg).splitlines() 9.83 + )) 9.84 + 9.85 + def debug(self, msg): 9.86 + self._write(map( 9.87 + lambda x: '%3s | %s :: %s' % ('`', self.appname, x), 9.88 + str(msg).splitlines() 9.89 + )) 9.90 + 9.91 + @staticmethod 9.92 + def get_timing(name: Optional[str] = None): 9.93 + return Timing(name) 9.94 + 9.95 + def sublog(self, name): 9.96 + return self.__class__(f'{self.appname}/{name}') 9.97 + 9.98 + def excpt(self, msg, e_class=None, e_obj=None, e_tb=None, stack_skip=0): 9.99 + if e_class is None: 9.100 + e_class, e_obj, e_tb = exc_info() 9.101 + 9.102 + tb_data_tb = list(extract_tb(e_tb))[::-1] 9.103 + tb_data_stack = list(extract_stack())[::-1][(2 + stack_skip):] 9.104 + self.err(msg) 9.105 + self.err('--- EXCEPTION ---') 9.106 + self.err(' %s (%s)' % (e_class.__name__, e_obj)) 9.107 + self.err('--- TRACEBACK ---') 9.108 + for _tbFile, _tbLine, _tbFunc, _tbText in tb_data_tb: 9.109 + self.err('File: %s, line %s in %s' % (_tbFile, _tbLine, _tbFunc)) 9.110 + self.err(' %s' % _tbText) 9.111 + self.err('>>> Exception Handler <<<') 9.112 + for _tbFile, _tbLine, _tbFunc, _tbText in tb_data_stack: 9.113 + self.err('File: %s, line %s in %s' % (_tbFile, _tbLine, _tbFunc)) 9.114 + self.err(' %s' % _tbText) 9.115 + self.err('--- END EXCEPTION ---') 9.116 + 9.117 + 9.118 +class NullLogger(BaseLogger): 9.119 + @staticmethod 9.120 + def _write(itr_content): 9.121 + pass 9.122 + 9.123 + 9.124 +class FileLogger(BaseLogger): 9.125 + def __init__(self, filename: str, appname: str = 'main'): 9.126 + super().__init__(appname) 9.127 + self.fd = open(filename, 'a') 9.128 + 9.129 + def _write(self, itr_content): 9.130 + cur_time = ctime() 9.131 + for i in itr_content: 9.132 + self.fd.write(f'{cur_time}{i}\n') 9.133 + 9.134 + self.fd.flush() 9.135 + 9.136 + def __del__(self): 9.137 + try: 9.138 + self.fd.close() 9.139 + except: 9.140 + pass 9.141 + 9.142 + 9.143 +class LogController(object): 9.144 + def __init__(self, config: Config): 9.145 + self.log_dir = config.log_dir 9.146 + self.keep_logs_days = config.keep_logs_days 9.147 + 9.148 + @staticmethod 9.149 + def _get_timeprefix() -> str: 9.150 + return datetime.now().strftime('%Y-%m-%d_%H-%M-%S') 9.151 + 9.152 + def get_logger(self, name: str) -> BaseLogger: 9.153 + if self.log_dir is not None: 9.154 + return FileLogger(p_join(self.log_dir, f'{self._get_timeprefix()} - {name}.log'), appname=name) 9.155 + else: 9.156 + return NullLogger(appname=name) 9.157 + 9.158 + def get_filename(self, name: str) -> Optional[str]: 9.159 + if self.log_dir is not None: 9.160 + return str(p_join(self.log_dir, f'{self._get_timeprefix()} - {name}.log')) 9.161 + 9.162 + def clean(self): 9.163 + if self.log_dir is not None: 9.164 + now = time() 9.165 + for f in listdir(self.log_dir): 9.166 + f_path = p_join(self.log_dir, f) 9.167 + f_stat = stat(f_path) 9.168 + 9.169 + if divmod(now - f_stat.st_ctime, 86400)[0] > self.keep_logs_days: 9.170 + file_remove(f_path)
10.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 10.2 +++ b/win_pg_dump_controller/store_controller.py Sun Jan 30 22:17:39 2022 +0300 10.3 @@ -0,0 +1,150 @@ 10.4 +# coding: utf-8 10.5 + 10.6 +import json 10.7 +from datetime import datetime 10.8 +from os.path import exists, join as p_join, basename 10.9 +from os import remove as file_remove 10.10 +from typing import Dict, List 10.11 + 10.12 +from .error import Error 10.13 +from .config import DBTask 10.14 + 10.15 + 10.16 +class StoreError(Error): 10.17 + pass 10.18 + 10.19 + 10.20 +class StoreControllerBase(object): 10.21 + def get_filename(self, filename: str) -> str: 10.22 + raise NotImplemented() 10.23 + 10.24 + 10.25 +class IndexItem(object): 10.26 + def __init__(self, controller: StoreControllerBase, time: int, filename: str): 10.27 + self._controller = controller 10.28 + self.filename = filename 10.29 + self.time = time 10.30 + self._my_path = None 10.31 + 10.32 + def get_path(self): 10.33 + if self._my_path is None: 10.34 + self._my_path = self._controller.get_filename(self.filename) 10.35 + 10.36 + return self._my_path 10.37 + 10.38 + def get_datetime(self): 10.39 + return datetime.fromtimestamp(self.time) 10.40 + 10.41 + def is_exists(self) -> bool: 10.42 + return exists(self.get_path()) 10.43 + 10.44 + def to_dict(self) -> dict: 10.45 + return dict((i, getattr(self, i)) for i in ('filename', 'time')) 10.46 + 10.47 + @classmethod 10.48 + def from_dict(cls, controller: StoreControllerBase, d: Dict): 10.49 + return cls( 10.50 + controller=controller, 10.51 + **d 10.52 + ) 10.53 + 10.54 + @classmethod 10.55 + def new(cls, controller: StoreControllerBase, short_filename: str): 10.56 + time = datetime.now() 10.57 + time_prefix = time.strftime('%Y-%m-%d_%H-%M-%S') 10.58 + filename = f'{time_prefix} - {short_filename}' 10.59 + 10.60 + return cls( 10.61 + controller=controller, 10.62 + time=int(time.timestamp()), 10.63 + filename=filename 10.64 + ) 10.65 + 10.66 + 10.67 +class StoreController(StoreControllerBase): 10.68 + def __init__(self, task: DBTask): 10.69 + self.task = task 10.70 + self.index_name = self.get_filename(f'{task.name}.index') 10.71 + self.idx: Dict[int, IndexItem] = {} 10.72 + 10.73 + if exists(self.index_name): 10.74 + self.load_index() 10.75 + 10.76 + def get_filename(self, filename: str) -> str: 10.77 + return str(p_join(self.task.dst_dir, filename)) 10.78 + 10.79 + def load_index(self) -> None: 10.80 + with open(self.index_name, 'r') as IN: 10.81 + result = {} 10.82 + for itm in json.load(IN): 10.83 + itm = IndexItem.from_dict(self, itm) 10.84 + if itm.is_exists(): 10.85 + result[itm.time] = itm 10.86 + 10.87 + self.idx = result 10.88 + 10.89 + def save_index(self) -> None: 10.90 + with open(self.index_name, 'w') as OUT: 10.91 + json.dump(list(map(lambda x: x.to_dict(), self.idx.values())), OUT) 10.92 + 10.93 + def new_item(self) -> IndexItem: 10.94 + return IndexItem.new(self, f'{self.task.name}.backup') 10.95 + 10.96 + def add_item(self, item: IndexItem) -> None: 10.97 + item_path = item.get_path() 10.98 + if not item.is_exists(): 10.99 + raise StoreError(f'Storing to index file not found: {item.get_path()}') 10.100 + 10.101 + if item.time in self.idx and self.idx[item.time].filename != item.filename: 10.102 + if self.idx[item.time].is_exists(): 10.103 + file_remove(self.idx[item.time].get_path()) 10.104 + 10.105 + self.idx[item.time] = item 10.106 + 10.107 + def remove(self, item: IndexItem) -> None: 10.108 + if item.time in self.idx: 10.109 + del self.idx[item.time] 10.110 + 10.111 + if item.is_exists(): 10.112 + file_remove(item.get_path()) 10.113 + 10.114 + def __iter__(self): 10.115 + for i in sorted(self.idx): 10.116 + yield self.idx[i] 10.117 + 10.118 + def clean(self, tier1_days: int, tier2_copies_interval: int, tier2_store_days: int) -> List[str]: 10.119 + to_remove = [] 10.120 + tier2_idx = {} 10.121 + now = datetime.now() 10.122 + 10.123 + for item in self: 10.124 + if not item.is_exists(): 10.125 + to_remove.append(item) 10.126 + 10.127 + else: 10.128 + storing_days = (now - item.get_datetime()).days 10.129 + 10.130 + if not storing_days <= tier1_days: 10.131 + if storing_days > tier2_store_days: 10.132 + to_remove.append(item) 10.133 + 10.134 + else: 10.135 + # Магия: Делим старые резервные копии на эпохи. Эпоха - целая часть от деления количества 10.136 + # дней, которое лежит резервная копия, на интервал, за который нам нужно хранить 10.137 + # хотя бы одну копию. В одной эпохе старший файл вытесняет младший. Из вычислений 10.138 + # убираем период tier1 10.139 + 10.140 + storing_days -= tier1_days 10.141 + 10.142 + _epoch = divmod(storing_days, tier2_copies_interval)[0] 10.143 + if _epoch in tier2_idx: 10.144 + to_remove.append(tier2_idx[_epoch]) 10.145 + 10.146 + tier2_idx[_epoch] = item 10.147 + 10.148 + result = [] 10.149 + for item in to_remove: 10.150 + file_remove(item.get_path()) 10.151 + result.append(item.filename) 10.152 + 10.153 + return result