Add an offline backup/restore engine in `tools.backup`: - format based on XEP-0227 (pie), extended with Libervia sections - handle several data types (params, private, roster, pep , gpg, files and db) - per profile export/import and global handlers - a manifest is used to have metadata and checksums - full database snapshot/restore with the `db` type - sqla: add `get_db_url` - tools.common.utils: add `is_safe_relpath` and `is_process_running` rel 480
213 lines
5.9 KiB
Python
213 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
||
|
||
# Libervia: an XMPP client
|
||
# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)
|
||
|
||
# This program is free software: you can redistribute it and/or modify
|
||
# it under the terms of the GNU Affero General Public License as published by
|
||
# the Free Software Foundation, either version 3 of the License, or
|
||
# (at your option) any later version.
|
||
|
||
# This program is distributed in the hope that it will be useful,
|
||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
# GNU Affero General Public License for more details.
|
||
|
||
# You should have received a copy of the GNU Affero General Public License
|
||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||
|
||
"""Misc utils for both backend and frontends"""
|
||
|
||
import collections.abc
|
||
import os
|
||
from pathlib import Path
|
||
|
||
size_units = {
|
||
"b": 1,
|
||
"kb": 1000,
|
||
"mb": 1000**2,
|
||
"gb": 1000**3,
|
||
"tb": 1000**4,
|
||
"pb": 1000**5,
|
||
"eb": 1000**6,
|
||
"zb": 1000**7,
|
||
"yb": 1000**8,
|
||
"o": 1,
|
||
"ko": 1000,
|
||
"mo": 1000**2,
|
||
"go": 1000**3,
|
||
"to": 1000**4,
|
||
"po": 1000**5,
|
||
"eo": 1000**6,
|
||
"zo": 1000**7,
|
||
"yo": 1000**8,
|
||
"kib": 1024,
|
||
"mib": 1024**2,
|
||
"gib": 1024**3,
|
||
"tib": 1024**4,
|
||
"pib": 1024**5,
|
||
"eib": 1024**6,
|
||
"zib": 1024**7,
|
||
"yib": 1024**8,
|
||
"kio": 1024,
|
||
"mio": 1024**2,
|
||
"gio": 1024**3,
|
||
"tio": 1024**4,
|
||
"pio": 1024**5,
|
||
"eio": 1024**6,
|
||
"zio": 1024**7,
|
||
"yio": 1024**8,
|
||
}
|
||
|
||
|
||
def per_luminance(red, green, blue):
|
||
"""Caculate the perceived luminance of a RGB color
|
||
|
||
@param red(int): 0-1 normalized value of red
|
||
@param green(int): 0-1 normalized value of green
|
||
@param blue(int): 0-1 normalized value of blue
|
||
@return (float): 0-1 value of luminance (<0.5 is dark, else it's light)
|
||
"""
|
||
# cf. https://stackoverflow.com/a/1855903, thanks Gacek
|
||
|
||
return 0.299 * red + 0.587 * green + 0.114 * blue
|
||
|
||
|
||
def recursive_update(ori: dict, update: dict):
|
||
"""Recursively update a dictionary"""
|
||
# cf. https://stackoverflow.com/a/3233356, thanks Alex Martelli
|
||
for k, v in update.items():
|
||
if isinstance(v, collections.abc.Mapping):
|
||
ori[k] = recursive_update(ori.get(k, {}), v)
|
||
else:
|
||
ori[k] = v
|
||
return ori
|
||
|
||
|
||
class OrderedSet(collections.abc.MutableSet):
|
||
"""A mutable sequence which doesn't keep duplicates"""
|
||
|
||
# TODO: complete missing set methods
|
||
|
||
def __init__(self, values=None):
|
||
self._dict = {}
|
||
if values is not None:
|
||
self.update(values)
|
||
|
||
def __len__(self):
|
||
return len(self._dict)
|
||
|
||
def __iter__(self):
|
||
return iter(self._dict.keys())
|
||
|
||
def __contains__(self, item):
|
||
return item in self._dict
|
||
|
||
def add(self, item):
|
||
self._dict[item] = None
|
||
|
||
def discard(self, item):
|
||
try:
|
||
del self._dict[item]
|
||
except KeyError:
|
||
pass
|
||
|
||
def update(self, items):
|
||
self._dict.update({i: None for i in items})
|
||
|
||
|
||
def parse_size(size: str) -> int:
|
||
"""Parse a file size with optional unit.
|
||
|
||
The size string can contain an optional unit suffix. Units are case-insensitive.
|
||
Spaces within the size string are ignored.
|
||
|
||
Examples::
|
||
|
||
"1024" -> 1024
|
||
"1 kb" -> 1000
|
||
"1 ko" -> 1000
|
||
"2 Mb" -> 2000000
|
||
"1 MiB" -> 1048576
|
||
|
||
@param size: Size string with optional unit (e.g. "1024", "1 kb", "2 Mb", "1 MiB")
|
||
@return: Size in bytes
|
||
@raise ValueError: If the size string is invalid or contains unknown units
|
||
"""
|
||
try:
|
||
return int(size)
|
||
except ValueError:
|
||
number = []
|
||
symbol = []
|
||
try:
|
||
for c in size:
|
||
if c == " ":
|
||
continue
|
||
if c.isdigit():
|
||
number.append(c)
|
||
elif c.isalpha():
|
||
symbol.append(c)
|
||
else:
|
||
raise ValueError("unexpected char in size: {c!r} (size: {size!r})")
|
||
number = int("".join(number))
|
||
symbol = "".join(symbol)
|
||
if symbol:
|
||
try:
|
||
multiplier = size_units[symbol.lower()]
|
||
except KeyError:
|
||
raise ValueError(
|
||
"unknown size multiplier symbol: {symbol!r} (size: {size!r})"
|
||
)
|
||
else:
|
||
return number * multiplier
|
||
return number
|
||
except Exception as e:
|
||
raise ValueError(f"invalid size ({size!r}): {e}")
|
||
|
||
|
||
def get_size_multiplier(size: int | str, suffix="o"):
|
||
"""Get multiplier of a file size"""
|
||
size = int(size)
|
||
# cf. https://stackoverflow.com/a/1094933 (thanks)
|
||
for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]:
|
||
if abs(size) < 1024.0:
|
||
return size, f"{unit}{suffix}"
|
||
size /= 1024.0
|
||
return size, f"Yi{suffix}"
|
||
|
||
|
||
def get_human_size(size: int | str, suffix: str = "o", sep: str = " ") -> str:
|
||
"""Return data size in a human readable format."""
|
||
size, symbol = get_size_multiplier(size, suffix)
|
||
return f"{size:.2f}{sep}{symbol}"
|
||
|
||
|
||
def is_safe_relpath(name: str) -> bool:
|
||
"""Check if a path is safe to join to a directory (as in a tar archive)
|
||
|
||
@param name: path to check
|
||
@return: True if the path is relative and has no parent reference
|
||
"""
|
||
return not name.startswith("/") and ".." not in name.split("/")
|
||
|
||
|
||
def is_process_running(pid_file: Path) -> bool:
|
||
"""Check if the process in a PID file is running
|
||
|
||
@param pid_file: path to the PID file
|
||
@return: True if a live process matches the PID
|
||
"""
|
||
if not pid_file.is_file():
|
||
return False
|
||
try:
|
||
pid = int(pid_file.read_text().strip())
|
||
except (OSError, ValueError):
|
||
return False
|
||
try:
|
||
os.kill(pid, 0)
|
||
except ProcessLookupError:
|
||
return False
|
||
except PermissionError:
|
||
# the process exists but belongs to another user
|
||
pass
|
||
return True
|