mirror of
https://github.com/morpheus65535/bazarr.git
synced 2026-07-30 17:48:04 -04:00
1867 lines
60 KiB
Python
1867 lines
60 KiB
Python
# BSD 2-Clause License
|
|
#
|
|
# Apprise - Push Notification Library.
|
|
# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>
|
|
#
|
|
# Redistribution and use in source and binary forms, with or without
|
|
# modification, are permitted provided that the following conditions are met:
|
|
#
|
|
# 1. Redistributions of source code must retain the above copyright notice,
|
|
# this list of conditions and the following disclaimer.
|
|
#
|
|
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
# this list of conditions and the following disclaimer in the documentation
|
|
# and/or other materials provided with the distribution.
|
|
#
|
|
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
# POSSIBILITY OF SUCH DAMAGE.
|
|
|
|
import base64
|
|
import binascii
|
|
import builtins
|
|
import contextlib
|
|
from datetime import datetime, timedelta, timezone
|
|
import glob
|
|
import gzip
|
|
import hashlib
|
|
from itertools import chain
|
|
import json
|
|
import os
|
|
import re
|
|
import tempfile
|
|
import time
|
|
from typing import Any, Optional, Union
|
|
import zlib
|
|
|
|
from . import exception
|
|
from .common import (
|
|
AWARE_DATE_ISO_FORMAT,
|
|
NAIVE_DATE_ISO_FORMAT,
|
|
PersistentStoreMode,
|
|
)
|
|
from .logger import logger
|
|
from .utils.disk import path_decode
|
|
|
|
# Used for writing/reading time stored in cache file
|
|
EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
|
def _ntf_tidy(ntf):
|
|
"""Reusable NamedTemporaryFile Cleanup."""
|
|
if ntf:
|
|
# Cleanup
|
|
with contextlib.suppress(OSError):
|
|
ntf.close()
|
|
|
|
try:
|
|
os.unlink(ntf.name)
|
|
logger.trace("Persistent temporary file removed: %s", ntf.name)
|
|
|
|
except (FileNotFoundError, AttributeError):
|
|
# AttributeError: something weird was passed in, no action required
|
|
# FileNotFound: no worries; we were removing it anyway
|
|
pass
|
|
|
|
except OSError as e:
|
|
logger.error(
|
|
"Persistent temporary file removal failed: %s", ntf.name
|
|
)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
|
|
|
|
class CacheObject:
|
|
hash_engine = hashlib.sha256
|
|
hash_length = 6
|
|
|
|
def __init__(
|
|
self,
|
|
value: Any = None,
|
|
expires: Union[bool, float, int, datetime, None] = False,
|
|
persistent: bool = True,
|
|
) -> None:
|
|
"""Tracks our objects and associates a time limit with them."""
|
|
|
|
self.__value = value
|
|
self.__class_name = value.__class__.__name__
|
|
self.__expires = None
|
|
|
|
if expires:
|
|
self.set_expiry(expires)
|
|
|
|
# Whether or not we persist this object to disk or not
|
|
self.__persistent = bool(persistent)
|
|
|
|
def set(
|
|
self,
|
|
value: Any,
|
|
expires: Union[bool, float, int, datetime, None] = None,
|
|
persistent: Optional[bool] = None,
|
|
) -> None:
|
|
"""Sets fields on demand, if set to none, then they are left as is.
|
|
|
|
The intent of set is that it allows you to set a new a value and
|
|
optionally alter meta information against it.
|
|
|
|
If expires or persistent isn't specified then their previous values are
|
|
used.
|
|
"""
|
|
|
|
self.__value = value
|
|
self.__class_name = value.__class__.__name__
|
|
if expires is not None:
|
|
self.set_expiry(expires)
|
|
|
|
if persistent is not None:
|
|
self.__persistent = bool(persistent)
|
|
|
|
def set_expiry(
|
|
self, expires: Union[datetime, bool, float, int, None] = None
|
|
) -> None:
|
|
"""Sets a new expiry."""
|
|
|
|
if isinstance(expires, datetime):
|
|
self.__expires = expires.astimezone(timezone.utc)
|
|
|
|
elif expires in (None, False):
|
|
# Accepted - no expiry
|
|
self.__expires = None
|
|
|
|
elif expires is True:
|
|
# Force expiry to now
|
|
self.__expires = datetime.now(tz=timezone.utc)
|
|
|
|
elif isinstance(expires, (float, int)):
|
|
self.__expires = datetime.now(tz=timezone.utc) + timedelta(
|
|
seconds=expires
|
|
)
|
|
|
|
else: # Unsupported
|
|
raise AttributeError(
|
|
f"An invalid expiry time ({expires} was specified"
|
|
)
|
|
|
|
def hash(self) -> str:
|
|
"""Our checksum to track the validity of our data."""
|
|
return self.hash_engine(
|
|
str(self).encode("utf-8"), usedforsecurity=False
|
|
).hexdigest()
|
|
|
|
def json(self) -> Optional[dict[str, Any]]:
|
|
"""Returns our preparable json object."""
|
|
|
|
return {
|
|
"v": self.__value,
|
|
"x": (
|
|
(self.__expires - EPOCH).total_seconds()
|
|
if self.__expires
|
|
else None
|
|
),
|
|
"c": (
|
|
self.__class_name
|
|
if not isinstance(self.__value, datetime)
|
|
else (
|
|
"aware_datetime"
|
|
if self.__value.tzinfo
|
|
else "naive_datetime"
|
|
)
|
|
),
|
|
"!": self.hash()[: self.hash_length],
|
|
}
|
|
|
|
@staticmethod
|
|
def instantiate(
|
|
content: dict[str, Any],
|
|
persistent: bool = True,
|
|
verify: bool = True,
|
|
) -> Optional["CacheObject"]:
|
|
"""Loads back data read in and returns a CacheObject or None if it
|
|
could not be loaded.
|
|
|
|
You can pass in the contents of CacheObject.json() and you'll receive a
|
|
copy assuming the hash checks okay
|
|
"""
|
|
try:
|
|
value = content["v"]
|
|
expires = content["x"]
|
|
if expires is not None:
|
|
expires = datetime.fromtimestamp(expires, timezone.utc)
|
|
|
|
# Acquire some useful integrity objects
|
|
class_name = content.get("c", "")
|
|
if not isinstance(class_name, str):
|
|
raise TypeError("Class name not expected string")
|
|
|
|
hashsum = content.get("!", "")
|
|
if not isinstance(hashsum, str):
|
|
raise TypeError("SHA1SUM not expected string")
|
|
|
|
except (TypeError, KeyError) as e:
|
|
logger.trace(f"CacheObject could not be parsed from {content}")
|
|
logger.trace("CacheObject exception: %s", str(e))
|
|
return None
|
|
|
|
if class_name in ("aware_datetime", "naive_datetime", "datetime"):
|
|
# If datetime is detected, it will fall under the naive category
|
|
iso_format = (
|
|
AWARE_DATE_ISO_FORMAT
|
|
if class_name[0] == "a"
|
|
else NAIVE_DATE_ISO_FORMAT
|
|
)
|
|
try:
|
|
# Python v3.6 Support
|
|
value = datetime.strptime(value, iso_format)
|
|
|
|
except (TypeError, ValueError):
|
|
# TypeError is thrown if content is not string
|
|
# ValueError is thrown if the string is not a valid format
|
|
logger.trace(
|
|
f"CacheObject (dt) corrupted loading from {content}"
|
|
)
|
|
return None
|
|
|
|
elif class_name == "bytes":
|
|
try:
|
|
# Convert our object back to a bytes
|
|
value = base64.b64decode(value)
|
|
|
|
except binascii.Error:
|
|
logger.trace(
|
|
f"CacheObject (bin) corrupted loading from {content}"
|
|
)
|
|
return None
|
|
|
|
# Initialize our object
|
|
co = CacheObject(value, expires, persistent=persistent)
|
|
if verify and co.hash()[: co.hash_length] != hashsum:
|
|
# Our object was tampered with
|
|
logger.debug(f"Tampering detected with cache entry {co}")
|
|
del co
|
|
return None
|
|
|
|
return co
|
|
|
|
@property
|
|
def value(self) -> Any:
|
|
"""Returns our value."""
|
|
return self.__value
|
|
|
|
@property
|
|
def persistent(self) -> bool:
|
|
"""Returns our persistent value."""
|
|
return self.__persistent
|
|
|
|
@property
|
|
def expires(self) -> Optional[datetime]:
|
|
"""Returns the datetime the object will expire."""
|
|
return self.__expires
|
|
|
|
@property
|
|
def expires_sec(self) -> Optional[float]:
|
|
"""Returns the number of seconds from now the object will expire."""
|
|
|
|
return (
|
|
None
|
|
if self.__expires is None
|
|
else max(
|
|
0.0,
|
|
(
|
|
self.__expires - datetime.now(tz=timezone.utc)
|
|
).total_seconds(),
|
|
)
|
|
)
|
|
|
|
def __bool__(self) -> bool:
|
|
"""Returns True it the object hasn't expired, and False if it has."""
|
|
if self.__expires is None:
|
|
# No Expiry
|
|
return True
|
|
|
|
# Calculate if we've expired or not
|
|
return self.__expires > datetime.now(tz=timezone.utc)
|
|
|
|
def __eq__(self, other) -> bool:
|
|
"""Handles equality == flag."""
|
|
if isinstance(other, CacheObject):
|
|
return str(self) == str(other)
|
|
|
|
return self.__value == other
|
|
|
|
def __str__(self) -> str:
|
|
"""String output of our data."""
|
|
persistent = "+" if self.persistent else "-"
|
|
return f"{self.__class_name}:{persistent}:{self.__value} expires: " + (
|
|
"never"
|
|
if self.__expires is None
|
|
else self.__expires.strftime(NAIVE_DATE_ISO_FORMAT)
|
|
)
|
|
|
|
|
|
class CacheJSONEncoder(json.JSONEncoder):
|
|
"""A JSON Encoder for handling each of our cache objects."""
|
|
|
|
def default(self, entry):
|
|
if isinstance(entry, datetime):
|
|
return entry.strftime(
|
|
AWARE_DATE_ISO_FORMAT
|
|
if entry.tzinfo is not None
|
|
else NAIVE_DATE_ISO_FORMAT
|
|
)
|
|
|
|
elif isinstance(entry, CacheObject):
|
|
return entry.json()
|
|
|
|
elif isinstance(entry, bytes):
|
|
return base64.b64encode(entry).decode("utf-8")
|
|
|
|
return super().default(entry)
|
|
|
|
|
|
class PersistentStore:
|
|
"""An object to make working with persistent storage easier.
|
|
|
|
read() and write() are used for direct file i/o
|
|
|
|
set(), get() are used for caching
|
|
"""
|
|
|
|
# The maximum file-size we will allow the persistent store to grow to
|
|
# 1 MB = 1048576 bytes
|
|
max_file_size = 1048576
|
|
|
|
# 30 days in seconds
|
|
default_file_expiry = 2678400
|
|
|
|
# File encoding to use
|
|
encoding = "utf-8"
|
|
|
|
# Default data set
|
|
base_key = "default"
|
|
|
|
# Directory to store cache
|
|
__cache_key = "cache"
|
|
|
|
# Our Temporary working directory
|
|
temp_dir = "tmp"
|
|
|
|
# The directory our persistent store content gets placed in
|
|
data_dir = "var"
|
|
|
|
# Our Persistent Store File Extension
|
|
__extension = ".psdata"
|
|
|
|
# Identify our backup file extension
|
|
__backup_extension = "._psbak"
|
|
|
|
# Used to verify the key specified is valid
|
|
# - must start with an alpha_numeric
|
|
# - following optional characters can include period, underscore and
|
|
# equal
|
|
__valid_key = re.compile(r"[a-z0-9][a-z0-9._-]*", re.I)
|
|
|
|
# Reference only
|
|
__not_found_ref = (None, None)
|
|
|
|
def __init__(
|
|
self,
|
|
path: Optional[str] = None,
|
|
namespace: str = "default",
|
|
mode: Optional[Union[str, PersistentStoreMode]] = None,
|
|
) -> None:
|
|
"""Provide the namespace to work within.
|
|
|
|
namespaces can only contain alpha-numeric characters with the exception
|
|
of '-' (dash), '_' (underscore), and '.' (period). The namespace must
|
|
be be relative to the current URL being controlled.
|
|
"""
|
|
# Initalize our mode so __del__() calls don't go bad on the
|
|
# error checking below
|
|
self.__mode = None
|
|
|
|
# Populated only once and after size() is called
|
|
self.__exclude_list = None
|
|
|
|
# Files to renew on calls to flush
|
|
self.__renew = set()
|
|
|
|
if not isinstance(namespace, str) or not self.__valid_key.match(
|
|
namespace
|
|
):
|
|
raise AttributeError(
|
|
f"Persistent Storage namespace ({namespace}) provided is"
|
|
" invalid"
|
|
)
|
|
|
|
if isinstance(path, str):
|
|
# A storage path has been defined
|
|
if mode is None:
|
|
# Store Default if no mode was provided along side of it
|
|
mode = PersistentStoreMode.AUTO
|
|
|
|
# Store our information
|
|
self.__base_path = os.path.join(path_decode(path), namespace)
|
|
self.__temp_path = os.path.join(self.__base_path, self.temp_dir)
|
|
self.__data_path = os.path.join(self.__base_path, self.data_dir)
|
|
|
|
else: # If no storage path is provide we set our mode to MEMORY
|
|
mode = PersistentStoreMode.MEMORY
|
|
self.__base_path = None
|
|
self.__temp_path = None
|
|
self.__data_path = None
|
|
|
|
# Tracks when we have content to flush
|
|
self.__dirty = False
|
|
|
|
# A caching value to track persistent storage disk size
|
|
self.__cache_size = None
|
|
self.__cache_files = {}
|
|
|
|
# Internal Cache
|
|
self._cache = None
|
|
|
|
try:
|
|
# Store our mode
|
|
self.__mode = (
|
|
mode
|
|
if isinstance(mode, PersistentStoreMode)
|
|
else PersistentStoreMode(mode.lower())
|
|
)
|
|
|
|
except (AttributeError, ValueError):
|
|
err = (
|
|
f"An invalid persistent storage mode ({mode}) was specified.",
|
|
)
|
|
logger.warning(err)
|
|
raise AttributeError(err) from None
|
|
|
|
# Prepare our environment
|
|
self.__prepare()
|
|
|
|
def read(
|
|
self,
|
|
key: Optional[str] = None,
|
|
compress: bool = True,
|
|
expires: Union[bool, float, int] = False,
|
|
) -> Optional[bytes]:
|
|
"""Returns the content of the persistent store object.
|
|
|
|
if refresh is set to True, then the file's modify time is updated
|
|
preventing it from getting caught in prune calls. It's a means of
|
|
allowing it to persist and not get cleaned up in later prune calls.
|
|
|
|
Content is always returned as a byte object
|
|
"""
|
|
try:
|
|
with self.open(key, mode="rb", compress=compress) as fd:
|
|
results = fd.read(self.max_file_size)
|
|
if expires is False:
|
|
self.__renew.add(
|
|
os.path.join(
|
|
self.__data_path, f"{key}{self.__extension}"
|
|
)
|
|
)
|
|
|
|
return results
|
|
|
|
except (FileNotFoundError, exception.AppriseDiskIOError):
|
|
# FileNotFoundError: No problem
|
|
# exception.AppriseDiskIOError:
|
|
# - Logging of error already occurred inside self.open()
|
|
pass
|
|
|
|
except (OSError, zlib.error, EOFError, UnicodeDecodeError) as e:
|
|
# We can't access the file or it does not exist
|
|
logger.warning("Could not read with persistent key: %s", key)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
|
|
# return none
|
|
return None
|
|
|
|
def write(
|
|
self,
|
|
data: Union[bytes, str, Any],
|
|
key: Optional[str] = None,
|
|
compress: bool = True,
|
|
_recovery: bool = False,
|
|
) -> bool:
|
|
"""Writes the content to the persistent store if it doesn't exceed our
|
|
filesize limit.
|
|
|
|
Content is always written as a byte object
|
|
|
|
_recovery is reserved for internal usage and should not be changed
|
|
"""
|
|
|
|
if key is None:
|
|
key = self.base_key
|
|
|
|
elif not isinstance(key, str) or not self.__valid_key.match(key):
|
|
raise AttributeError(
|
|
f"Persistent Storage key ({key} provided is invalid"
|
|
)
|
|
|
|
if not isinstance(data, (bytes, str)):
|
|
# One last check, we will accept read() objets with the expectation
|
|
# it will return a binary dataset
|
|
if not (hasattr(data, "read") and callable(data.read)):
|
|
raise AttributeError(
|
|
f"Invalid data type {type(data)} provided to Persistent"
|
|
" Storage"
|
|
)
|
|
|
|
try:
|
|
# Read in our data
|
|
data = data.read()
|
|
if not isinstance(data, (bytes, str)):
|
|
raise AttributeError(
|
|
f"Invalid data type {type(data)} provided to"
|
|
" Persistent Storage"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Could read() from potential iostream with persistent "
|
|
"key: %s",
|
|
key,
|
|
)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
raise exception.AppriseDiskIOError(
|
|
f"Invalid data type {type(data)} provided to Persistent"
|
|
" Storage"
|
|
) from None
|
|
|
|
if self.__mode == PersistentStoreMode.MEMORY:
|
|
# Nothing further can be done
|
|
return False
|
|
|
|
if _recovery:
|
|
# Attempt to recover from a bad directory structure or setup
|
|
self.__prepare()
|
|
|
|
# generate our filename based on the key provided
|
|
io_file = os.path.join(self.__data_path, f"{key}{self.__extension}")
|
|
|
|
# Calculate the files current filesize
|
|
try:
|
|
prev_size = os.stat(io_file).st_size
|
|
|
|
except FileNotFoundError:
|
|
# No worries, no size to accommodate
|
|
prev_size = 0
|
|
|
|
except OSError as e:
|
|
# Permission error of some kind or disk problem...
|
|
# There is nothing we can do at this point
|
|
logger.warning("Could not write with persistent key: %s", key)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
return False
|
|
|
|
# Create a temporary file to write our content into
|
|
# ntf = NamedTemporaryFile
|
|
ntf = None
|
|
new_file_size = 0
|
|
try:
|
|
if isinstance(data, str):
|
|
data = data.encode(self.encoding)
|
|
|
|
ntf = tempfile.NamedTemporaryFile( # noqa: SIM115
|
|
mode="wb", dir=self.__temp_path, delete=False
|
|
)
|
|
|
|
# Close our file
|
|
ntf.close()
|
|
|
|
# Pointer to our open call
|
|
open_ = open if not compress else gzip.open
|
|
|
|
with open_(ntf.name, mode="wb") as fd:
|
|
# Write our content
|
|
fd.write(data)
|
|
|
|
# Get our file size
|
|
new_file_size = os.stat(ntf.name).st_size
|
|
|
|
# Log our progress
|
|
logger.trace(
|
|
"Wrote %d bytes of data to persistent key: %s",
|
|
new_file_size,
|
|
key,
|
|
)
|
|
|
|
except FileNotFoundError:
|
|
# This happens if the directory path is gone preventing the file
|
|
# from being created...
|
|
if not _recovery:
|
|
return self.write(
|
|
data=data, key=key, compress=compress, _recovery=True
|
|
)
|
|
|
|
# We've already made our best effort to recover if we are here in
|
|
# our code base... we're going to have to exit
|
|
|
|
# Tidy our Named Temporary File
|
|
_ntf_tidy(ntf)
|
|
|
|
# Early Exit
|
|
return False
|
|
|
|
except (OSError, UnicodeEncodeError, zlib.error) as e:
|
|
# We can't access the file or it does not exist
|
|
logger.warning("Could not write to persistent key: %s", key)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
|
|
# Tidy our Named Temporary File
|
|
_ntf_tidy(ntf)
|
|
|
|
return False
|
|
|
|
if (
|
|
self.max_file_size > 0
|
|
and (new_file_size + self.size() - prev_size) > self.max_file_size
|
|
):
|
|
# The content to store is to large
|
|
logger.warning(
|
|
"Persistent content exceeds allowable maximum file length"
|
|
f" ({int(self.max_file_size / 1024)}KB); provide"
|
|
f" {int(new_file_size / 1024)}KB"
|
|
)
|
|
return False
|
|
|
|
# Return our final move
|
|
if not self.__move(ntf.name, io_file):
|
|
# Attempt to restore things as they were
|
|
|
|
# Tidy our Named Temporary File
|
|
_ntf_tidy(ntf)
|
|
return False
|
|
|
|
# Resetour reference variables
|
|
self.__cache_size = None
|
|
self.__cache_files.clear()
|
|
|
|
# Content installed
|
|
return True
|
|
|
|
def __move(self, src, dst):
|
|
"""Moves the new file in place and handles the old if it exists already
|
|
If the transaction fails in any way, the old file is swapped back.
|
|
|
|
Function returns True if successful and False if not.
|
|
"""
|
|
|
|
# A temporary backup of the file we want to move in place
|
|
dst_backup = (
|
|
dst[: -len(self.__backup_extension)] + self.__backup_extension
|
|
)
|
|
|
|
#
|
|
# Backup the old file (if it exists) allowing us to have a restore
|
|
# point in the event of a failure
|
|
#
|
|
try:
|
|
# make sure the file isn't already present; if it is; remove it
|
|
os.unlink(dst_backup)
|
|
logger.trace(
|
|
"Removed previous persistent backup file: %s", dst_backup
|
|
)
|
|
|
|
except FileNotFoundError:
|
|
# no worries; we were removing it anyway
|
|
pass
|
|
|
|
except OSError as e:
|
|
# Permission error of some kind or disk problem...
|
|
# There is nothing we can do at this point
|
|
logger.warning(
|
|
"Could not previous persistent data backup: %s", dst_backup
|
|
)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
return False
|
|
|
|
try:
|
|
# Back our file up so we have a fallback
|
|
os.rename(dst, dst_backup)
|
|
logger.trace(
|
|
"Persistent storage backup file created: %s", dst_backup
|
|
)
|
|
|
|
except FileNotFoundError:
|
|
# Not a problem; this is a brand new file we're writing
|
|
# There is nothing to backup
|
|
pass
|
|
|
|
except OSError as e:
|
|
# This isn't good... we couldn't put our new file in place
|
|
logger.warning(
|
|
"Could not install persistent content %s -> %s",
|
|
dst,
|
|
os.path.basename(dst_backup),
|
|
)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
return False
|
|
|
|
#
|
|
# Now place the new file
|
|
#
|
|
try:
|
|
os.rename(src, dst)
|
|
logger.trace("Persistent file installed: %s", dst)
|
|
|
|
except OSError as e:
|
|
# This isn't good... we couldn't put our new file in place
|
|
# Begin fall-back process before leaving the funtion
|
|
logger.warning(
|
|
"Could not install persistent content %s -> %s",
|
|
src,
|
|
os.path.basename(dst),
|
|
)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
try:
|
|
# Restore our old backup (if it exists)
|
|
os.rename(dst_backup, dst)
|
|
logger.trace("Restoring original persistent content: %s", dst)
|
|
|
|
except FileNotFoundError:
|
|
# Not a problem
|
|
pass
|
|
|
|
except OSError as e:
|
|
# Permission error of some kind or disk problem...
|
|
# There is nothing we can do at this point
|
|
logger.warning(
|
|
"Failed to restore original persistent file: %s", dst
|
|
)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
|
|
return False
|
|
|
|
return True
|
|
|
|
def open(
|
|
self,
|
|
key: Optional[str] = None,
|
|
mode: str = "r",
|
|
buffering: int = -1,
|
|
encoding: Optional[str] = None,
|
|
errors: Optional[str] = None,
|
|
newline: Optional[str] = None,
|
|
closefd: bool = True,
|
|
opener: Optional[Any] = None,
|
|
compress: bool = False,
|
|
compresslevel: int = 9,
|
|
) -> Any:
|
|
"""Returns an iterator to our our file within our namespace identified
|
|
by the key provided.
|
|
|
|
If no key is provided, then the default is used
|
|
"""
|
|
|
|
if key is None:
|
|
key = self.base_key
|
|
|
|
elif not isinstance(key, str) or not self.__valid_key.match(key):
|
|
raise AttributeError(
|
|
f"Persistent Storage key ({key} provided is invalid"
|
|
)
|
|
|
|
if self.__mode == PersistentStoreMode.MEMORY:
|
|
# Nothing further can be done
|
|
raise FileNotFoundError()
|
|
|
|
io_file = os.path.join(self.__data_path, f"{key}{self.__extension}")
|
|
try:
|
|
return (
|
|
open(
|
|
io_file,
|
|
mode=mode,
|
|
buffering=buffering,
|
|
encoding=encoding,
|
|
errors=errors,
|
|
newline=newline,
|
|
closefd=closefd,
|
|
opener=opener,
|
|
)
|
|
if not compress
|
|
else gzip.open(
|
|
io_file,
|
|
compresslevel=compresslevel,
|
|
encoding=encoding,
|
|
errors=errors,
|
|
newline=newline,
|
|
)
|
|
)
|
|
|
|
except FileNotFoundError:
|
|
# pass along (but wrap with Apprise exception)
|
|
raise exception.AppriseFileNotFound(
|
|
f"No such file or directory: '{io_file}'"
|
|
) from None
|
|
|
|
except (OSError, zlib.error) as e:
|
|
# We can't access the file or it does not exist
|
|
logger.warning("Could not read with persistent key: %s", key)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
raise exception.AppriseDiskIOError(str(e)) from None
|
|
|
|
def get(
|
|
self,
|
|
key: str,
|
|
default: Any = None,
|
|
lazy: bool = True,
|
|
) -> Any:
|
|
"""Fetches from cache."""
|
|
|
|
if self._cache is None and not self.__load_cache():
|
|
return default
|
|
|
|
if (
|
|
key in self._cache
|
|
and self.__mode != PersistentStoreMode.MEMORY
|
|
and not self.__dirty
|
|
):
|
|
# ensure we renew our content
|
|
self.__renew.add(self.cache_file)
|
|
|
|
return self._cache[key].value if self._cache.get(key) else default
|
|
|
|
def set(
|
|
self,
|
|
key: str,
|
|
value: Any,
|
|
expires: Union[float, int, datetime, bool, None] = None,
|
|
persistent: bool = True,
|
|
lazy: bool = True,
|
|
) -> bool:
|
|
"""Cache reference."""
|
|
|
|
if self._cache is None and not self.__load_cache():
|
|
return False
|
|
|
|
cache = CacheObject(value, expires, persistent=persistent)
|
|
# Fetch our cache value
|
|
try:
|
|
if lazy and cache == self._cache[key]:
|
|
# We're done; nothing further to do
|
|
return True
|
|
|
|
except KeyError:
|
|
pass
|
|
|
|
# Store our new cache
|
|
self._cache[key] = CacheObject(value, expires, persistent=persistent)
|
|
|
|
# Set our dirty flag
|
|
self.__dirty = persistent
|
|
|
|
if self.__dirty and self.__mode == PersistentStoreMode.FLUSH:
|
|
# Flush changes to disk
|
|
return self.flush()
|
|
|
|
return True
|
|
|
|
def clear(self, *args: str) -> Optional[bool]:
|
|
"""Remove one or more cache entry by it's key.
|
|
|
|
e.g: clear('key')
|
|
clear('key1', 'key2', key-12')
|
|
|
|
Or clear everything:
|
|
clear()
|
|
"""
|
|
if self._cache is None and not self.__load_cache():
|
|
return False
|
|
|
|
if args:
|
|
for arg in args:
|
|
try:
|
|
del self._cache[arg]
|
|
|
|
# Set our dirty flag (if not set already)
|
|
self.__dirty = True
|
|
|
|
except KeyError:
|
|
pass
|
|
|
|
elif self._cache:
|
|
# Request to remove everything and there is something to remove
|
|
|
|
# Set our dirty flag (if not set already)
|
|
self.__dirty = True
|
|
|
|
# Reset our object
|
|
self._cache.clear()
|
|
|
|
if self.__dirty and self.__mode == PersistentStoreMode.FLUSH:
|
|
# Flush changes to disk
|
|
return self.flush()
|
|
|
|
def prune(self) -> bool:
|
|
"""Eliminates expired cache entries."""
|
|
if self._cache is None and not self.__load_cache():
|
|
return False
|
|
|
|
change = False
|
|
for key in list(self._cache.keys()):
|
|
if key not in self:
|
|
# It's identified as being expired
|
|
if not change and self._cache[key].persistent:
|
|
# track change only if content was persistent
|
|
change = True
|
|
|
|
# Set our dirty flag
|
|
self.__dirty = True
|
|
|
|
del self._cache[key]
|
|
|
|
if self.__dirty and self.__mode == PersistentStoreMode.FLUSH:
|
|
# Flush changes to disk
|
|
return self.flush()
|
|
|
|
return change
|
|
|
|
def __load_cache(self, _recovery=False):
|
|
"""Loads our cache.
|
|
|
|
_recovery is reserved for internal usage and should not be changed
|
|
"""
|
|
|
|
# Prepare our dirty flag
|
|
self.__dirty = False
|
|
|
|
if self.__mode == PersistentStoreMode.MEMORY:
|
|
# Nothing further to do
|
|
self._cache = {}
|
|
return True
|
|
|
|
# Prepare our cache file
|
|
cache_file = self.cache_file
|
|
try:
|
|
with gzip.open(cache_file, "rb") as f:
|
|
# Read our ontent from disk
|
|
self._cache = {}
|
|
for k, v in json.loads(f.read().decode(self.encoding)).items():
|
|
co = CacheObject.instantiate(v)
|
|
if co:
|
|
# Verify our object before assigning it
|
|
self._cache[k] = co
|
|
|
|
elif not self.__dirty:
|
|
# Track changes from our loadset
|
|
self.__dirty = True
|
|
|
|
except (
|
|
UnicodeDecodeError,
|
|
json.decoder.JSONDecodeError,
|
|
zlib.error,
|
|
TypeError,
|
|
AttributeError,
|
|
EOFError,
|
|
):
|
|
# Let users known there was a problem
|
|
logger.warning(
|
|
"Corrupted access persistent cache content: %s", cache_file
|
|
)
|
|
|
|
if not _recovery:
|
|
try:
|
|
os.unlink(cache_file)
|
|
logger.trace(
|
|
"Removed previous persistent cache content: %s",
|
|
cache_file,
|
|
)
|
|
|
|
except FileNotFoundError:
|
|
# no worries; we were removing it anyway
|
|
pass
|
|
|
|
except OSError as e:
|
|
# Permission error of some kind or disk problem...
|
|
# There is nothing we can do at this point
|
|
logger.warning(
|
|
"Could not remove persistent cache content: %s",
|
|
cache_file,
|
|
)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
return False
|
|
return self.__load_cache(_recovery=True)
|
|
|
|
return False
|
|
|
|
except FileNotFoundError:
|
|
# No problem; no cache to load
|
|
self._cache = {}
|
|
|
|
except OSError as e:
|
|
# Permission error of some kind or disk problem...
|
|
# There is nothing we can do at this point
|
|
logger.warning(
|
|
"Could not load persistent cache for namespace %s",
|
|
os.path.basename(self.__base_path),
|
|
)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
return False
|
|
|
|
# Ensure our dirty flag is set to False
|
|
return True
|
|
|
|
def __prepare(self, flush=True):
|
|
"""Prepares a working environment."""
|
|
if self.__mode != PersistentStoreMode.MEMORY:
|
|
# Ensure our path exists
|
|
try:
|
|
os.makedirs(self.__base_path, mode=0o770, exist_ok=True)
|
|
|
|
except OSError as e:
|
|
# Permission error
|
|
logger.debug(
|
|
"Could not create persistent store directory %s",
|
|
self.__base_path,
|
|
)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
|
|
# Mode changed back to MEMORY
|
|
self.__mode = PersistentStoreMode.MEMORY
|
|
|
|
# Ensure our path exists
|
|
try:
|
|
os.makedirs(self.__temp_path, mode=0o770, exist_ok=True)
|
|
|
|
except OSError as e:
|
|
# Permission error
|
|
logger.debug(
|
|
"Could not create persistent store directory %s",
|
|
self.__temp_path,
|
|
)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
|
|
# Mode changed back to MEMORY
|
|
self.__mode = PersistentStoreMode.MEMORY
|
|
|
|
try:
|
|
os.makedirs(self.__data_path, mode=0o770, exist_ok=True)
|
|
|
|
except OSError as e:
|
|
# Permission error
|
|
logger.debug(
|
|
"Could not create persistent store directory %s",
|
|
self.__data_path,
|
|
)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
|
|
# Mode changed back to MEMORY
|
|
self.__mode = PersistentStoreMode.MEMORY
|
|
|
|
if self.__mode is PersistentStoreMode.MEMORY:
|
|
logger.warning(
|
|
"The persistent storage could not be fully initialized; "
|
|
"operating in MEMORY mode"
|
|
)
|
|
|
|
else:
|
|
if self._cache:
|
|
# Recovery taking place
|
|
self.__dirty = True
|
|
logger.warning(
|
|
"The persistent storage environment was disrupted"
|
|
)
|
|
|
|
if self.__mode is PersistentStoreMode.FLUSH and flush:
|
|
# Flush changes to disk
|
|
return self.flush(_recovery=True)
|
|
|
|
def flush(
|
|
self,
|
|
force: bool = False,
|
|
_recovery: bool = False,
|
|
) -> bool:
|
|
"""Save's our cache to disk."""
|
|
|
|
if self._cache is None or self.__mode == PersistentStoreMode.MEMORY:
|
|
# nothing to do
|
|
return True
|
|
|
|
while self.__renew:
|
|
# update our files
|
|
path = self.__renew.pop()
|
|
ftime = time.time()
|
|
|
|
try:
|
|
# (access_time, modify_time)
|
|
os.utime(path, (ftime, ftime))
|
|
logger.trace("file timestamp updated: %s", path)
|
|
|
|
except FileNotFoundError:
|
|
# No worries... move along
|
|
pass
|
|
|
|
except OSError as e:
|
|
# We can't access the file or it does not exist
|
|
logger.debug("Could not update file timestamp: %s", path)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
|
|
if not force and self.__dirty is False:
|
|
# Nothing further to do
|
|
logger.trace("Persistent cache is consistent with memory map")
|
|
return True
|
|
|
|
if _recovery:
|
|
# Attempt to recover from a bad directory structure or setup
|
|
self.__prepare(flush=False)
|
|
|
|
# Unset our size lazy setting
|
|
self.__cache_size = None
|
|
self.__cache_files.clear()
|
|
|
|
# Prepare our cache file
|
|
cache_file = self.cache_file
|
|
if not self._cache:
|
|
#
|
|
# We're deleting the cache file s there are no entries left in it
|
|
#
|
|
backup_file = (
|
|
cache_file[: -len(self.__backup_extension)]
|
|
+ self.__backup_extension
|
|
)
|
|
|
|
try:
|
|
os.unlink(backup_file)
|
|
logger.trace(
|
|
"Removed previous persistent cache backup: %s", backup_file
|
|
)
|
|
|
|
except FileNotFoundError:
|
|
# no worries; we were removing it anyway
|
|
pass
|
|
|
|
except OSError as e:
|
|
# Permission error of some kind or disk problem...
|
|
# There is nothing we can do at this point
|
|
logger.warning(
|
|
"Could not remove persistent cache backup: %s", backup_file
|
|
)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
return False
|
|
|
|
try:
|
|
os.rename(cache_file, backup_file)
|
|
logger.trace(
|
|
"Persistent cache backup file created: %s", backup_file
|
|
)
|
|
|
|
except FileNotFoundError:
|
|
# Not a problem; do not create a log entry
|
|
pass
|
|
|
|
except OSError as e:
|
|
# This isn't good... we couldn't put our new file in place
|
|
logger.warning(
|
|
"Could not remove stale persistent cache file: %s",
|
|
cache_file,
|
|
)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
return False
|
|
return True
|
|
|
|
#
|
|
# If we get here, we need to update our file based cache
|
|
#
|
|
|
|
# ntf = NamedTemporaryFile
|
|
ntf = None
|
|
|
|
try:
|
|
ntf = tempfile.NamedTemporaryFile( # noqa: SIM115
|
|
mode="w+",
|
|
encoding=self.encoding,
|
|
dir=self.__temp_path,
|
|
delete=False,
|
|
)
|
|
|
|
ntf.close()
|
|
|
|
except FileNotFoundError:
|
|
# This happens if the directory path is gone preventing the file
|
|
# from being created...
|
|
if not _recovery:
|
|
return self.flush(force=True, _recovery=True)
|
|
|
|
# We've already made our best effort to recover if we are here in
|
|
# our code base... we're going to have to exit
|
|
|
|
# Tidy our Named Temporary File
|
|
_ntf_tidy(ntf)
|
|
|
|
# Early Exit
|
|
return False
|
|
|
|
except OSError as e:
|
|
logger.error(
|
|
"Persistent temporary directory inaccessible: %s",
|
|
self.__temp_path,
|
|
)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
|
|
# Tidy our Named Temporary File
|
|
_ntf_tidy(ntf)
|
|
|
|
# Early Exit
|
|
return False
|
|
|
|
try:
|
|
# write our content currently saved to disk to our temporary file
|
|
with gzip.open(ntf.name, "wb") as f:
|
|
# Write our content to disk
|
|
f.write(
|
|
json.dumps(
|
|
{
|
|
k: v
|
|
for k, v in self._cache.items()
|
|
if v and v.persistent
|
|
},
|
|
separators=(",", ":"),
|
|
cls=CacheJSONEncoder,
|
|
).encode(self.encoding)
|
|
)
|
|
|
|
except TypeError as e:
|
|
# JSON object contains content that can not be encoded to disk
|
|
logger.error(
|
|
"Persistent temporary file can not be written to "
|
|
"due to bad input data: %s",
|
|
ntf.name,
|
|
)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
|
|
# Tidy our Named Temporary File
|
|
_ntf_tidy(ntf)
|
|
|
|
# Early Exit
|
|
return False
|
|
|
|
except (OSError, EOFError, zlib.error) as e:
|
|
logger.error(
|
|
"Persistent temporary file inaccessible: %s", ntf.name
|
|
)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
|
|
# Tidy our Named Temporary File
|
|
_ntf_tidy(ntf)
|
|
|
|
# Early Exit
|
|
return False
|
|
|
|
if not self.__move(ntf.name, cache_file):
|
|
# Attempt to restore things as they were
|
|
|
|
# Tidy our Named Temporary File
|
|
_ntf_tidy(ntf)
|
|
return False
|
|
|
|
# Ensure our dirty flag is set to False
|
|
self.__dirty = False
|
|
|
|
return True
|
|
|
|
def files(
|
|
self,
|
|
exclude: bool = True,
|
|
lazy: bool = True,
|
|
) -> list[str]:
|
|
"""Returns the total files."""
|
|
|
|
if lazy and exclude in self.__cache_files:
|
|
# Take an early exit with our cached results
|
|
return self.__cache_files[exclude]
|
|
|
|
elif self.__mode == PersistentStoreMode.MEMORY:
|
|
# Take an early exit
|
|
# exclude is our cache switch and can be either True or False.
|
|
# For the below, we just set both cases and set them up as an
|
|
# empty record
|
|
self.__cache_files.update({True: [], False: []})
|
|
return []
|
|
|
|
if not lazy or self.__exclude_list is None:
|
|
# A list of criteria that should be excluded from the size count
|
|
self.__exclude_list = (
|
|
# Exclude backup cache file from count
|
|
re.compile(
|
|
re.escape(
|
|
os.path.join(
|
|
self.__base_path,
|
|
f"{self.__cache_key}{self.__backup_extension}",
|
|
)
|
|
)
|
|
),
|
|
# Exclude temporary files
|
|
re.compile(re.escape(self.__temp_path) + r"[/\\].+"),
|
|
# Exclude custom backup persistent files
|
|
re.compile(
|
|
re.escape(self.__data_path)
|
|
+ r"[/\\].+"
|
|
+ re.escape(self.__backup_extension)
|
|
),
|
|
)
|
|
|
|
try:
|
|
if exclude:
|
|
self.__cache_files[exclude] = [
|
|
path
|
|
for path in filter(
|
|
os.path.isfile,
|
|
glob.glob(
|
|
os.path.join(self.__base_path, "**", "*"),
|
|
recursive=True,
|
|
),
|
|
)
|
|
if next(
|
|
(False for p in self.__exclude_list if p.match(path)),
|
|
True,
|
|
)
|
|
]
|
|
|
|
else: # No exclusion list applied
|
|
self.__cache_files[exclude] = list(
|
|
filter(
|
|
os.path.isfile,
|
|
glob.glob(
|
|
os.path.join(self.__base_path, "**", "*"),
|
|
recursive=True,
|
|
),
|
|
)
|
|
)
|
|
|
|
except OSError:
|
|
# We can't access the directory or it does not exist
|
|
self.__cache_files[exclude] = []
|
|
|
|
return self.__cache_files[exclude]
|
|
|
|
@staticmethod
|
|
def disk_scan(
|
|
path: str,
|
|
namespace: Optional[Union[str, list[str]]] = None,
|
|
closest: bool = True,
|
|
) -> list[str]:
|
|
"""Scansk a path provided and returns namespaces detected."""
|
|
|
|
logger.trace("Persistent path can of: %s", path)
|
|
|
|
def is_namespace(x):
|
|
"""Validate what was detected is a valid namespace."""
|
|
return os.path.isdir(
|
|
os.path.join(path, x)
|
|
) and PersistentStore.__valid_key.match(x)
|
|
|
|
# Handle our namespace searching
|
|
if namespace:
|
|
if isinstance(namespace, str):
|
|
namespace = [namespace]
|
|
|
|
elif not isinstance(namespace, (tuple, set, list)):
|
|
raise AttributeError(
|
|
"namespace must be None, a string, or a tuple/set/list "
|
|
"of strings"
|
|
)
|
|
|
|
try:
|
|
# Acquire all of the files in question
|
|
namespaces = (
|
|
[
|
|
ns
|
|
for ns in filter(is_namespace, os.listdir(path))
|
|
if not namespace
|
|
or next(
|
|
(True for n in namespace if ns.startswith(n)), False
|
|
)
|
|
]
|
|
if closest
|
|
else [
|
|
ns
|
|
for ns in filter(is_namespace, os.listdir(path))
|
|
if not namespace or ns in namespace
|
|
]
|
|
)
|
|
|
|
except FileNotFoundError:
|
|
# no worries; Nothing to do
|
|
logger.debug("Disk Prune path not found; nothing to clean.")
|
|
return []
|
|
|
|
except OSError as e:
|
|
# Permission error of some kind or disk problem...
|
|
# There is nothing we can do at this point
|
|
logger.error("Disk Scan detetcted inaccessible path: %s", path)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
return []
|
|
|
|
return namespaces
|
|
|
|
@staticmethod
|
|
def disk_prune(
|
|
path: str,
|
|
namespace: Optional[Union[str, list[str]]] = None,
|
|
expires: Optional[Union[int, float]] = None,
|
|
action: bool = False,
|
|
) -> dict[str, list[dict[str, Union[str, bool]]]]:
|
|
"""Prune persistent disk storage entries that are old and/or
|
|
unreferenced.
|
|
|
|
you must specify a path to perform the prune within
|
|
|
|
if one or more namespaces are provided, then pruning focuses ONLY on
|
|
those entries (if matched).
|
|
|
|
if action is not set to False, directories to be removed are returned
|
|
only
|
|
"""
|
|
|
|
# Prepare our File Expiry
|
|
expires = (
|
|
datetime.now() - timedelta(seconds=expires)
|
|
if isinstance(expires, (float, int)) and expires >= 0
|
|
else PersistentStore.default_file_expiry
|
|
)
|
|
|
|
# Get our namespaces
|
|
namespaces = PersistentStore.disk_scan(path, namespace)
|
|
|
|
# Track matches
|
|
map_ = {}
|
|
|
|
for namespace in namespaces:
|
|
# Prepare our map
|
|
map_[namespace] = []
|
|
|
|
# Reference Directories
|
|
base_dir = os.path.join(path, namespace)
|
|
data_dir = os.path.join(base_dir, PersistentStore.data_dir)
|
|
temp_dir = os.path.join(base_dir, PersistentStore.temp_dir)
|
|
|
|
# Careful to only focus on files created by this Persistent Store
|
|
# object
|
|
files = [
|
|
os.path.join(
|
|
base_dir,
|
|
f"{PersistentStore.__cache_key}"
|
|
f"{PersistentStore.__extension}",
|
|
),
|
|
os.path.join(
|
|
base_dir,
|
|
f"{PersistentStore.__cache_key}"
|
|
f"{PersistentStore.__backup_extension}",
|
|
),
|
|
]
|
|
|
|
# Update our files (applying what was defined above too)
|
|
valid_data_re = re.compile(
|
|
r".*("
|
|
+ re.escape(PersistentStore.__extension)
|
|
+ r"|"
|
|
+ re.escape(PersistentStore.__backup_extension)
|
|
+ r")$"
|
|
)
|
|
|
|
files = [
|
|
path
|
|
for path in filter(
|
|
os.path.isfile,
|
|
chain(
|
|
glob.glob(
|
|
os.path.join(data_dir, "*"), recursive=False
|
|
),
|
|
files,
|
|
),
|
|
)
|
|
if valid_data_re.match(path)
|
|
]
|
|
|
|
# Now all temporary files
|
|
files.extend(
|
|
list(
|
|
filter(
|
|
os.path.isfile,
|
|
glob.glob(
|
|
os.path.join(temp_dir, "*"), recursive=False
|
|
),
|
|
)
|
|
)
|
|
)
|
|
|
|
# Track if we should do a directory sweep later on
|
|
dir_sweep = True
|
|
|
|
# Scan our files
|
|
for file in files:
|
|
try:
|
|
mtime = datetime.fromtimestamp(os.path.getmtime(file))
|
|
|
|
except FileNotFoundError:
|
|
# no worries; we were removing it anyway
|
|
continue
|
|
|
|
except OSError as e:
|
|
# Permission error of some kind or disk problem...
|
|
# There is nothing we can do at this point
|
|
logger.error(
|
|
"Disk Prune (ns=%s, clean=%s) detetcted inaccessible "
|
|
"file: %s",
|
|
namespace,
|
|
"yes" if action else "no",
|
|
file,
|
|
)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
|
|
# No longer worth doing a directory sweep
|
|
dir_sweep = False
|
|
continue
|
|
|
|
if expires < mtime:
|
|
continue
|
|
|
|
#
|
|
# Handle Removing
|
|
#
|
|
record = {
|
|
"path": file,
|
|
"removed": False,
|
|
}
|
|
|
|
if action:
|
|
try:
|
|
os.unlink(file)
|
|
# Update our record
|
|
record["removed"] = True
|
|
logger.info(
|
|
"Disk Prune (ns=%s, clean=%s) removed persistent "
|
|
"file: %s",
|
|
namespace,
|
|
"yes" if action else "no",
|
|
file,
|
|
)
|
|
|
|
except FileNotFoundError:
|
|
# no longer worth doing a directory sweep
|
|
dir_sweep = False
|
|
|
|
# otherwise, no worries; we were removing the file
|
|
# anyway
|
|
|
|
except OSError as e:
|
|
# Permission error of some kind or disk problem...
|
|
# There is nothing we can do at this point
|
|
logger.error(
|
|
"Disk Prune (ns=%s, clean=%s) failed to remove "
|
|
"persistent file: %s",
|
|
namespace,
|
|
"yes" if action else "no",
|
|
file,
|
|
)
|
|
|
|
logger.debug(
|
|
"Persistent Storage Exception: %s", str(e)
|
|
)
|
|
|
|
# No longer worth doing a directory sweep
|
|
dir_sweep = False
|
|
|
|
# Store our record
|
|
map_[namespace].append(record)
|
|
|
|
# Memory tidy
|
|
del files
|
|
|
|
if dir_sweep:
|
|
# Gracefully cleanup our namespace directory. It's okay if we
|
|
# fail; This just means there were files in the directory.
|
|
for dirpath in (temp_dir, data_dir, base_dir):
|
|
if action:
|
|
try:
|
|
os.rmdir(dirpath)
|
|
logger.info(
|
|
"Disk Prune (ns=%s, clean=%s) removed "
|
|
"persistent dir: %s",
|
|
namespace,
|
|
"yes" if action else "no",
|
|
dirpath,
|
|
)
|
|
except OSError:
|
|
# do nothing;
|
|
pass
|
|
return map_
|
|
|
|
def size(
|
|
self,
|
|
exclude: bool = True,
|
|
lazy: bool = True,
|
|
) -> int:
|
|
"""Returns the total size of the persistent storage in bytes."""
|
|
|
|
if lazy and self.__cache_size is not None:
|
|
# Take an early exit
|
|
return self.__cache_size
|
|
|
|
elif self.__mode == PersistentStoreMode.MEMORY:
|
|
# Take an early exit
|
|
self.__cache_size = 0
|
|
return self.__cache_size
|
|
|
|
# Get a list of files (file paths) in the given directory
|
|
try:
|
|
self.__cache_size = sum(
|
|
os.stat(path).st_size
|
|
for path in self.files(exclude=exclude, lazy=lazy)
|
|
)
|
|
|
|
except OSError:
|
|
# We can't access the directory or it does not exist
|
|
self.__cache_size = 0
|
|
|
|
return self.__cache_size
|
|
|
|
def __del__(self) -> None:
|
|
"""Deconstruction of our object."""
|
|
|
|
if self.__mode == PersistentStoreMode.AUTO:
|
|
# Flush changes to disk
|
|
self.flush()
|
|
|
|
def __delitem__(self, key: str) -> None:
|
|
"""Remove a cache entry by it's key."""
|
|
if self._cache is None and not self.__load_cache():
|
|
raise KeyError("Could not initialize cache")
|
|
|
|
try:
|
|
if self._cache[key].persistent:
|
|
# Set our dirty flag in advance
|
|
self.__dirty = True
|
|
|
|
# Store our new cache
|
|
del self._cache[key]
|
|
|
|
except KeyError:
|
|
# Nothing to do
|
|
raise
|
|
|
|
if self.__dirty and self.__mode == PersistentStoreMode.FLUSH:
|
|
# Flush changes to disk
|
|
self.flush()
|
|
|
|
return
|
|
|
|
def __contains__(self, key: str) -> bool:
|
|
"""Verify if our storage contains the key specified or not.
|
|
|
|
In additiont to this, if the content is expired, it is considered to be
|
|
not contained in the storage.
|
|
"""
|
|
if self._cache is None and not self.__load_cache():
|
|
return False
|
|
|
|
return key in self._cache and self._cache[key]
|
|
|
|
def __setitem__(self, key: str, value: Any) -> None:
|
|
"""Sets a cache value without disrupting existing settings in place."""
|
|
|
|
if self._cache is None and not self.__load_cache():
|
|
raise KeyError("Could not initialize cache")
|
|
|
|
if key not in self._cache and not self.set(key, value):
|
|
raise KeyError("Could not set cache")
|
|
|
|
else:
|
|
# Update our value
|
|
self._cache[key].set(value)
|
|
|
|
if self._cache[key].persistent:
|
|
# Set our dirty flag in advance
|
|
self.__dirty = True
|
|
|
|
if self.__dirty and self.__mode == PersistentStoreMode.FLUSH:
|
|
# Flush changes to disk
|
|
self.flush()
|
|
|
|
return
|
|
|
|
def __getitem__(self, key: str) -> Any:
|
|
"""Returns the indexed value."""
|
|
|
|
if self._cache is None and not self.__load_cache():
|
|
raise KeyError("Could not initialize cache")
|
|
|
|
result = self.get(key, default=self.__not_found_ref, lazy=False)
|
|
if result is self.__not_found_ref:
|
|
raise KeyError(f" {key} not found in cache")
|
|
|
|
return result
|
|
|
|
def keys(self) -> builtins.set[str]:
|
|
"""Returns our keys."""
|
|
if self._cache is None and not self.__load_cache():
|
|
# There are no keys to return
|
|
return {}.keys()
|
|
|
|
return self._cache.keys()
|
|
|
|
def delete(
|
|
self,
|
|
*args: str,
|
|
all: Optional[bool] = None,
|
|
temp: Optional[bool] = None,
|
|
cache: Optional[bool] = None,
|
|
validate: bool = True,
|
|
) -> bool:
|
|
"""Manages our file space and tidys it up.
|
|
|
|
delete('key', 'key2') delete(all=True) delete(temp=True, cache=True)
|
|
"""
|
|
|
|
# Our failure flag
|
|
has_error = False
|
|
|
|
valid_key_re = re.compile(
|
|
r"^(?P<key>.+)("
|
|
+ re.escape(self.__backup_extension)
|
|
+ r"|"
|
|
+ re.escape(self.__extension)
|
|
+ r")$",
|
|
re.I,
|
|
)
|
|
|
|
# Default asignments
|
|
if all is None:
|
|
all = bool(not (len(args) or temp or cache))
|
|
if temp is None:
|
|
temp = bool(all)
|
|
if cache is None:
|
|
cache = bool(all)
|
|
|
|
if cache and self._cache:
|
|
# Reset our object
|
|
self._cache.clear()
|
|
# Reset dirt flag
|
|
self.__dirty = False
|
|
|
|
for path in self.files(exclude=False):
|
|
# Some information we use to validate the actions of our clean()
|
|
# call. This is so we don't remove anything we shouldn't
|
|
base = os.path.dirname(path)
|
|
fname = os.path.basename(path)
|
|
|
|
# Clean printable path details
|
|
ppath = os.path.join(os.path.dirname(base), fname)
|
|
|
|
if base == self.__base_path and cache:
|
|
# We're handling a cache file (hopefully)
|
|
result = valid_key_re.match(fname)
|
|
key = (
|
|
None
|
|
if not result
|
|
else (
|
|
result["key"]
|
|
if self.__valid_key.match(result["key"])
|
|
else None
|
|
)
|
|
)
|
|
|
|
if validate and key != self.__cache_key:
|
|
# We're not dealing with a cache key
|
|
logger.debug(
|
|
"Persistent File cleanup ignoring file: %s", path
|
|
)
|
|
continue
|
|
|
|
#
|
|
# We should proceed with removing the file if we get here
|
|
#
|
|
|
|
elif base == self.__data_path and (args or all):
|
|
# We're handling a file found in our custom data path
|
|
result = valid_key_re.match(fname)
|
|
key = (
|
|
None
|
|
if not result
|
|
else (
|
|
result["key"]
|
|
if self.__valid_key.match(result["key"])
|
|
else None
|
|
)
|
|
)
|
|
|
|
if validate and key is None:
|
|
# we're set to validate and a non-valid file was found
|
|
logger.debug(
|
|
"Persistent File cleanup ignoring file: %s", path
|
|
)
|
|
continue
|
|
|
|
elif not all and (key is None or key not in args):
|
|
# no match found
|
|
logger.debug(
|
|
"Persistent File cleanup ignoring file: %s", path
|
|
)
|
|
continue
|
|
|
|
#
|
|
# We should proceed with removing the file if we get here
|
|
#
|
|
|
|
elif base == self.__temp_path and temp:
|
|
#
|
|
# This directory is a temporary path and nothing in here needs
|
|
# to be further verified. Proceed with the removing of the file
|
|
#
|
|
pass
|
|
|
|
else:
|
|
# No match; move on
|
|
logger.debug("Persistent File cleanup ignoring file: %s", path)
|
|
continue
|
|
|
|
try:
|
|
os.unlink(path)
|
|
logger.info("Removed persistent file: %s", ppath)
|
|
|
|
except FileNotFoundError:
|
|
# no worries; we were removing it anyway
|
|
pass
|
|
|
|
except OSError as e:
|
|
# Permission error of some kind or disk problem...
|
|
# There is nothing we can do at this point
|
|
has_error = True
|
|
logger.error("Failed to remove persistent file: %s", ppath)
|
|
logger.debug("Persistent Storage Exception: %s", str(e))
|
|
|
|
# Reset our reference variables
|
|
self.__cache_size = None
|
|
self.__cache_files.clear()
|
|
|
|
return not has_error
|
|
|
|
@property
|
|
def cache_file(self) -> str:
|
|
"""Returns the full path to the namespace directory."""
|
|
return os.path.join(
|
|
self.__base_path,
|
|
f"{self.__cache_key}{self.__extension}",
|
|
)
|
|
|
|
@property
|
|
def path(self) -> Optional[str]:
|
|
"""Returns the full path to the namespace directory."""
|
|
return self.__base_path
|
|
|
|
@property
|
|
def mode(self) -> PersistentStoreMode:
|
|
"""Returns the Persistent Storage mode."""
|
|
return self.__mode
|