Structure code in logically separated submodules (#188)

This commit is contained in:
Michael Osthege
2024-11-19 21:47:43 +01:00
committed by GitHub
parent 345e78888f
commit 5e2eae7331
31 changed files with 75 additions and 84 deletions

View File

View File

@@ -0,0 +1,635 @@
"""cachefilestore.py.
This module provides a class for in-memory managing of cache files.
The `CacheFileStore` class is a singleton-based, thread-safe key-value store for managing
temporary file objects, allowing the creation, retrieval, and management of cache files.
Classes:
--------
- CacheFileStore: A thread-safe, singleton class for in-memory managing of file-like cache objects.
- CacheFileStoreMeta: Metaclass for enforcing the singleton behavior in `CacheFileStore`.
Example usage:
--------------
# CacheFileStore usage
>>> cache_store = CacheFileStore()
>>> cache_store.create('example_key')
>>> cache_file = cache_store.get('example_key')
>>> cache_file.write('Some data')
>>> cache_file.seek(0)
>>> print(cache_file.read()) # Output: 'Some data'
Notes:
------
- Cache files are automatically associated with the current date unless specified.
"""
import hashlib
import inspect
import os
import pickle
import tempfile
import threading
from datetime import date, datetime, time, timedelta
from typing import List, Optional, Union
from akkudoktoreos.utils.datetimeutil import to_datetime, to_timedelta
from akkudoktoreos.utils.logutil import get_logger
logger = get_logger(__file__)
class CacheFileStoreMeta(type):
"""A thread-safe implementation of CacheFileStore."""
_instances = {}
_lock: threading.Lock = threading.Lock()
"""Lock object to synchronize threads on first access to CacheFileStore."""
def __call__(cls):
"""Return CacheFileStore instance."""
with cls._lock:
if cls not in cls._instances:
instance = super().__call__()
cls._instances[cls] = instance
return cls._instances[cls]
class CacheFileStore(metaclass=CacheFileStoreMeta):
"""A key-value store that manages file-like tempfile objects to be used as cache files.
Cache files are associated with a date. If no date is specified, the cache files are
associated with the current date by default. The class provides methods to create
new cache files, retrieve existing ones, delete specific files, and clear all cache
entries.
CacheFileStore is a thread-safe singleton. Only one store instance will ever be created.
Attributes:
store (dict): A dictionary that holds the in-memory cache file objects
with their associated keys and dates.
Example usage:
>>> cache_store = CacheFileStore()
>>> cache_store.create('example_file')
>>> cache_file = cache_store.get('example_file')
>>> cache_file.write('Some data')
>>> cache_file.seek(0)
>>> print(cache_file.read()) # Output: 'Some data'
"""
def __init__(self):
"""Initializes the CacheFileStore instance.
This constructor sets up an empty key-value store (a dictionary) where each key
corresponds to a cache file that is associated with a given key and an optional date.
"""
self._store = {}
self._store_lock = threading.Lock()
def _generate_cache_file_key(
self, key: str, until_datetime: Union[datetime, None]
) -> (str, datetime):
"""Generates a unique cache file key based on the key and date.
The cache file key is a combination of the input key and the date (if provided),
hashed using SHA-256 to ensure uniqueness.
Args:
key (str): The key that identifies the cache file.
until_datetime (Union[datetime, date, str, int, float, None]): The datetime
until the cache file is valid. The default is the current date at maximum time
(23:59:59).
Returns:
A tuple of:
str: A hashed string that serves as the unique identifier for the cache file.
datetime: The datetime until the the cache file is valid.
"""
if until_datetime is None:
until_datetime = datetime.combine(date.today(), time.max)
key_datetime = to_datetime(until_datetime, as_string="UTC")
cache_key = hashlib.sha256(f"{key}{key_datetime}".encode("utf-8")).hexdigest()
return (f"{cache_key}", until_datetime)
def _get_file_path(self, file_obj):
"""Retrieve the file path from a file-like object.
Args:
file_obj: A file-like object (e.g., an instance of
NamedTemporaryFile, BytesIO, StringIO) from which to retrieve the
file path.
Returns:
str or None: The file path if available, or None if the file-like
object does not provide a file path.
"""
file_path = None
if hasattr(file_obj, "name"):
file_path = file_obj.name # Get the file path from the cache file object
return file_path
def _until_datetime_by_options(
self,
until_date: Union[datetime, date, str, int, float, None] = None,
until_datetime: Union[datetime, date, str, int, float, None] = None,
with_ttl: Union[timedelta, str, int, float, None] = None,
):
"""Get until_datetime from the given options."""
if until_datetime:
until_datetime = to_datetime(until_datetime)
elif with_ttl:
with_ttl = to_timedelta(with_ttl)
until_datetime = to_datetime(datetime.now() + with_ttl)
elif until_date:
until_datetime = to_datetime(to_datetime(until_date).date())
else:
# end of today
until_datetime = to_datetime(datetime.combine(date.today(), time.max))
return until_datetime
def _is_valid_cache_item(
self,
cache_item: (),
until_datetime: datetime = None,
at_datetime: datetime = None,
before_datetime: datetime = None,
):
cache_file_datetime = cache_item[1] # Extract the datetime associated with the cache item
if (
(until_datetime and until_datetime == cache_file_datetime)
or (at_datetime and at_datetime <= cache_file_datetime)
or (before_datetime and cache_file_datetime < before_datetime)
):
return True
return False
def _search(
self,
key: str,
until_datetime: Union[datetime, date, str, int, float] = None,
at_datetime: Union[datetime, date, str, int, float] = None,
before_datetime: Union[datetime, date, str, int, float] = None,
):
"""Searches for a cached item that matches the key and falls within the datetime range.
This method looks for a cache item with a key that matches the given `key`, and whose associated
datetime (`cache_file_datetime`) falls on or after the `at_datetime`. If both conditions are met,
it returns the cache item. Otherwise, it returns `None`.
Args:
key (str): The key to identify the cache item.
until_date (Union[datetime, date, str, int, float, None], optional): The date
until the cache file is valid. Time of day is set to maximum time (23:59:59).
at_datetime (Union[datetime, date, str, int, float], optional): The datetime to compare with
the cache item's datetime.
before_datetime (Union[datetime, date, str, int, float], optional): The datetime to compare
the cache item's datetime to be before.
Returns:
Optional[tuple]: Returns the cache_file_key, chache_file, cache_file_datetime if found,
otherwise returns `None`.
"""
# Convert input to datetime if they are not None
if until_datetime:
until_datetime = to_datetime(until_datetime)
if at_datetime:
at_datetime = to_datetime(at_datetime)
if before_datetime:
before_datetime = to_datetime(before_datetime)
for cache_file_key, cache_item in self._store.items():
# Check if the cache file datetime matches the given criteria
if self._is_valid_cache_item(
cache_item,
until_datetime=until_datetime,
at_datetime=at_datetime,
before_datetime=before_datetime,
):
# This cache file is within the given datetime range
# Extract the datetime associated with the cache item
cache_file_datetime = cache_item[1]
# Generate a cache file key based on the given key and the cache file datetime
generated_key, _until_dt = self._generate_cache_file_key(key, cache_file_datetime)
if generated_key == cache_file_key:
# The key matches, return the key and the cache item
return (cache_file_key, cache_item[0], cache_file_datetime)
# Return None if no matching cache item is found
return None
def create(
self,
key: str,
until_date: Union[datetime, date, str, int, float, None] = None,
until_datetime: Union[datetime, date, str, int, float, None] = None,
with_ttl: Union[timedelta, str, int, float, None] = None,
mode: str = "wb+",
delete: bool = False,
suffix: Optional[str] = None,
):
"""Creates a new file-like tempfile object associated with the given key.
If a cache file with the given key and valid timedate already exists, the existing file is
returned. Otherwise, a new tempfile object is created and stored in the key-value store.
Args:
key (str): The key to store the cache file under.
until_date (Union[datetime, date, str, int, float, None], optional): The date
until the cache file is valid. Time of day is set to maximum time (23:59:59).
until_datetime (Union[datetime, date, str, int, float, None], optional): The datetime
until the cache file is valid. Time of day is set to maximum time (23:59:59) if not
provided.
with_ttl (Union[timedelta, str, int, float, None], optional): The time to live that
the cache file is valid. Time starts now.
mode (str, optional): The mode in which the tempfile is opened
(e.g., 'w+', 'r+', 'wb+'). Defaults to 'wb+'.
delete (bool, optional): Whether to delete the file after it is closed.
Defaults to False (keeps the file).
suffix (str, optional): The suffix for the cache file (e.g., '.txt', '.log').
Defaults to None.
Returns:
file_obj: A file-like object representing the cache file.
Example:
>>> cache_file = cache_store.create('example_file', suffix='.txt')
>>> cache_file.write('Some cached data')
>>> cache_file.seek(0)
>>> print(cache_file.read()) # Output: 'Some cached data'
"""
until_datetime = self._until_datetime_by_options(
until_datetime=until_datetime, until_date=until_date, with_ttl=with_ttl
)
cache_file_key, until_date = self._generate_cache_file_key(key, until_datetime)
with self._store_lock: # Synchronize access to _store
if cache_file_key in self._store:
# File already available
cache_file_obj, until_datetime = self._store.get(cache_file_key)
else:
cache_file_obj = tempfile.NamedTemporaryFile(
mode=mode, delete=delete, suffix=suffix
)
self._store[cache_file_key] = (cache_file_obj, until_datetime)
cache_file_obj.seek(0)
return cache_file_obj
def set(
self,
key: str,
file_obj,
until_date: Union[datetime, date, str, int, float, None] = None,
until_datetime: Union[datetime, date, str, int, float, None] = None,
with_ttl: Union[timedelta, str, int, float, None] = None,
):
"""Stores a file-like object in the cache under the specified key and date.
This method allows you to manually set a file-like object into the cache with a specific key
and optional date.
Args:
key (str): The key to store the file object under.
file_obj: The file-like object.
until_date (Union[datetime, date, str, int, float, None], optional): The date
until the cache file is valid. Time of day is set to maximum time (23:59:59).
until_datetime (Union[datetime, date, str, int, float, None], optional): The datetime
until the cache file is valid. Time of day is set to maximum time (23:59:59) if not
provided.
with_ttl (Union[timedelta, str, int, float, None], optional): The time to live that
the cache file is valid. Time starts now.
Raises:
ValueError: If the key is already in store.
Example:
>>> cache_store.set('example_file', io.BytesIO(b'Some binary data'))
"""
until_datetime = self._until_datetime_by_options(
until_datetime=until_datetime, until_date=until_date, with_ttl=with_ttl
)
cache_file_key, until_date = self._generate_cache_file_key(key, until_datetime)
with self._store_lock: # Synchronize access to _store
if cache_file_key in self._store:
raise ValueError(f"Key already in store: `{key}`.")
self._store[cache_file_key] = (file_obj, until_date)
def get(
self,
key: str,
until_date: Union[datetime, date, str, int, float, None] = None,
until_datetime: Union[datetime, date, str, int, float, None] = None,
at_datetime: Union[datetime, date, str, int, float, None] = None,
before_datetime: Union[datetime, date, str, int, float, None] = None,
):
"""Retrieves the cache file associated with the given key and validity datetime.
If no cache file is found for the provided key and datetime, the method returns None.
The retrieved file is a file-like object that can be read from or written to.
Args:
key (str): The key to retrieve the cache file for.
until_date (Union[datetime, date, str, int, float, None], optional): The date
until the cache file is valid. Time of day is set to maximum time (23:59:59).
until_datetime (Union[datetime, date, str, int, float, None], optional): The datetime
until the cache file is valid. Time of day is set to maximum time (23:59:59) if not
provided.
at_datetime (Union[datetime, date, str, int, float, None], optional): The datetime the
cache file shall be valid at. Time of day is set to maximum time (23:59:59) if not
provided. Defaults to the current datetime if None is provided.
before_datetime (Union[datetime, date, str, int, float, None], optional): The datetime
to compare the cache files datetime to be before.
Returns:
file_obj: The file-like cache object, or None if no file is found.
Example:
>>> cache_file = cache_store.get('example_file')
>>> if cache_file:
>>> cache_file.seek(0)
>>> print(cache_file.read()) # Output: Cached data (if exists)
"""
if until_datetime or until_date:
until_datetime = self._until_datetime_by_options(
until_datetime=until_datetime, until_date=until_date
)
elif at_datetime:
at_datetime = to_datetime(at_datetime)
elif before_datetime:
before_datetime = to_datetime(before_datetime)
else:
at_datetime = to_datetime(datetime.now())
with self._store_lock: # Synchronize access to _store
search_item = self._search(key, until_datetime, at_datetime, before_datetime)
if search_item is None:
return None
return search_item[1]
def delete(
self,
key,
until_date: Union[datetime, date, str, int, float, None] = None,
until_datetime: Union[datetime, date, str, int, float, None] = None,
before_datetime: Union[datetime, date, str, int, float, None] = None,
):
"""Deletes the cache file associated with the given key and datetime.
This method removes the cache file from the store.
Args:
key (str): The key of the cache file to delete.
until_date (Union[datetime, date, str, int, float, None], optional): The date
until the cache file is valid. Time of day is set to maximum time (23:59:59).
until_datetime (Union[datetime, date, str, int, float, None], optional): The datetime
until the cache file is valid. Time of day is set to maximum time (23:59:59) if not
provided.
before_datetime (Union[datetime, date, str, int, float, None], optional): The datetime
the cache file shall become or be invalid at. Time of day is set to maximum time
(23:59:59) if not provided. Defaults to tommorow start of day.
"""
if until_datetime or until_date:
until_datetime = self._until_datetime_by_options(
until_datetime=until_datetime, until_date=until_date
)
elif before_datetime:
before_datetime = to_datetime(before_datetime)
else:
today = datetime.now().date() # Get today's date
tomorrow = today + timedelta(days=1) # Add one day to get tomorrow's date
before_datetime = to_datetime(datetime.combine(tomorrow, time.min))
with self._store_lock: # Synchronize access to _store
search_item = self._search(key, until_datetime, None, before_datetime)
if search_item:
cache_file_key = search_item[0]
cache_file = search_item[1]
cache_file_datetime = search_item[2]
file_path = self._get_file_path(cache_file)
if file_path is None:
logger.warning(
f"The cache file with key '{cache_file_key}' is an in memory "
f"file object. Will only delete store entry but not file."
)
self._store.pop(cache_file_key)
return
file_path = cache_file.name # Get the file path from the cache file object
del self._store[cache_file_key]
if os.path.exists(file_path):
try:
os.remove(file_path)
logger.debug(f"Deleted cache file: {file_path}")
except OSError as e:
logger.error(f"Error deleting cache file {file_path}: {e}")
def clear(
self, clear_all=False, before_datetime: Union[datetime, date, str, int, float, None] = None
):
"""Deletes all cache files or those expiring before `before_datetime`.
Args:
clear_all (bool, optional): Delete all cache files. Default is False.
before_datetime (Union[datetime, date, str, int, float, None], optional): The
threshold date. Cache files that are only valid before this date will be deleted.
The default datetime is beginning of today.
Raises:
OSError: If there's an error during file deletion.
"""
delete_keys = [] # List of keys to delete, prevent deleting when traversing the store
clear_timestamp = None
with self._store_lock: # Synchronize access to _store
for cache_file_key, cache_item in self._store.items():
cache_file = cache_item[0]
# Some weired logic to prevent calling to_datetime on clear_all.
# Clear_all may be set on __del__. At this time some info for to_datetime will
# not be available anymore.
clear_file = clear_all
if not clear_all:
if clear_timestamp is None:
before_datetime = to_datetime(before_datetime, to_maxtime=False)
# Convert the threshold date to a timestamp (seconds since epoch)
clear_timestamp = to_datetime(before_datetime).timestamp()
cache_file_timestamp = to_datetime(cache_item[1]).timestamp()
if cache_file_timestamp < clear_timestamp:
clear_file = True
if clear_file:
# We have to clear this cache file
delete_keys.append(cache_file_key)
file_path = self._get_file_path(cache_file)
if file_path is None:
# In memory file like object
logger.warning(
f"The cache file with key '{cache_file_key}' is an in memory "
f"file object. Will only delete store entry but not file."
)
continue
if not os.path.exists(file_path):
# Already deleted
logger.warning(f"The cache file '{file_path}' was already deleted.")
continue
# Finally remove the file
try:
os.remove(file_path)
logger.debug(f"Deleted cache file: {file_path}")
except OSError as e:
logger.error(f"Error deleting cache file {file_path}: {e}")
for delete_key in delete_keys:
del self._store[delete_key]
def cache_in_file(
ignore_params: List[str] = [],
until_date: Union[datetime, date, str, int, float, None] = None,
until_datetime: Union[datetime, date, str, int, float, None] = None,
with_ttl: Union[timedelta, str, int, float, None] = None,
mode: str = "wb+",
delete: bool = False,
suffix: Optional[str] = None,
):
"""Decorator to cache the output of a function into a temporary file.
The decorator caches function output to a cache file based on its inputs as key to identify the
cache file. Ignore parameters are used to avoid key generation on non-deterministic inputs, such
as time values. We can also ignore parameters that are slow to serialize/constant across runs,
such as large objects.
The cache file is created using `CacheFileStore` and stored with the generated key.
If the file exists in the cache and has not expired, it is returned instead of recomputing the
result.
The decorator scans the arguments of the decorated function for a 'until_date' or
'until_datetime` or `with_ttl` or `force_update` parameter. The value of this parameter will be
used instead of the one given in the decorator if available.
Content of cache files without a suffix are transparently pickled to save file space.
Args:
ignore_params (List[str], optional):
until_date (Union[datetime, date, str, int, float, None], optional): The date
until the cache file is valid. Time of day is set to maximum time (23:59:59).
until_datetime (Union[datetime, date, str, int, float, None], optional): The datetime
until the cache file is valid. Time of day is set to maximum time (23:59:59) if not
provided.
with_ttl (Union[timedelta, str, int, float, None], optional): The time to live that
the cache file is valid. Time starts now.
mode (str, optional): The mode in which the file will be opened. Defaults to 'wb+'.
delete (bool, optional): Whether the cache file will be deleted after being closed.
Defaults to False.
suffix (str, optional): A suffix for the cache file, such as an extension (e.g., '.txt').
Defaults to None.
Returns:
callable: A decorated function that caches its result in a file.
Example:
>>> @cache_in_file(suffix = '.txt')
>>> def expensive_computation(until_date = None):
>>> # Perform some expensive computation
>>> return 'Some large result'
>>>
>>> result = expensive_computation(until_date = date.today())
"""
def decorator(func):
nonlocal ignore_params, until_date, until_datetime, with_ttl, mode, delete, suffix
func_source_code = inspect.getsource(func)
def wrapper(*args, **kwargs):
nonlocal ignore_params, until_date, until_datetime, with_ttl, mode, delete, suffix
# Convert args to a dictionary based on the function's signature
args_names = func.__code__.co_varnames[: func.__code__.co_argcount]
args_dict = dict(zip(args_names, args))
# Search for caching parameters of function and remove
force_update = None
for param in ["force_update", "until_datetime", "with_ttl", "until_date"]:
if param in kwargs:
if param == "force_update":
force_update = kwargs[param]
kwargs.pop("force_update")
if param == "until_datetime":
until_datetime = kwargs[param]
until_date = None
with_ttl = None
elif param == "with_ttl":
until_datetime = None
until_date = None
with_ttl = kwargs[param]
elif param == "until_date":
until_datetime = None
until_date = kwargs[param]
with_ttl = None
kwargs.pop("until_datetime", None)
kwargs.pop("until_date", None)
kwargs.pop("with_ttl", None)
break
# Remove ignored params
kwargs_clone = kwargs.copy()
for param in ignore_params:
args_dict.pop(param, None)
kwargs_clone.pop(param, None)
# Create key based on argument names, argument values, and function source code
key = str(args_dict) + str(kwargs_clone) + str(func_source_code)
result = None
# Get cache file that is currently valid
cache_file = CacheFileStore().get(key)
if not force_update and cache_file is not None:
# cache file is available
try:
logger.debug("Used cache file for function: " + func.__name__)
cache_file.seek(0)
if "b" in mode:
result = pickle.load(cache_file)
else:
result = cache_file.read()
except Exception as e:
logger.info(f"Read failed: {e}")
# Fail gracefully - force creation
force_update = True
if force_update or cache_file is None:
# Otherwise, call the function and save its result to the cache
logger.debug("Created cache file for function: " + func.__name__)
cache_file = CacheFileStore().create(
key,
mode=mode,
delete=delete,
suffix=suffix,
until_datetime=until_datetime,
until_date=until_date,
with_ttl=with_ttl,
)
result = func(*args, **kwargs)
try:
# Assure we have an empty file
cache_file.truncate(0)
if "b" in mode:
pickle.dump(result, cache_file)
else:
cache_file.write(result)
except Exception as e:
logger.info(f"Write failed: {e}")
CacheFileStore().delete(key)
return result
return wrapper
return decorator

View File

@@ -0,0 +1,285 @@
"""Utility functions for date-time conversion tasks.
Functions:
----------
- to_datetime: Converts various date or time inputs to a timezone-aware or naive `datetime`
object or formatted string.
- to_timedelta: Converts various time delta inputs to a `timedelta`object.
- to_timezone: Converts position latitude and longitude to a `timezone` object.
Example usage:
--------------
# Date-time conversion
>>> date_str = "2024-10-15"
>>> date_obj = to_datetime(date_str)
>>> print(date_obj) # Output: datetime object for '2024-10-15'
# Time delta conversion
>>> to_timedelta("2 days 5 hours")
# Timezone detection
>>> to_timezone(40.7128, -74.0060)
"""
import re
from datetime import date, datetime, time, timedelta, timezone
from typing import Optional, Union
from zoneinfo import ZoneInfo
from timezonefinder import TimezoneFinder
def to_datetime(
date_input: Union[datetime, date, str, int, float, None],
as_string: Optional[Union[str, bool]] = None,
to_timezone: Optional[Union[timezone, str]] = None,
to_naiv: Optional[bool] = None,
to_maxtime: Optional[bool] = None,
):
"""Converts a date input to a datetime object or a formatted string with timezone support.
Args:
date_input (Union[datetime, date, str, int, float, None]): The date input to convert.
Accepts a date string, a datetime object, a date object or a Unix timestamp.
as_string (Optional[Union[str, bool]]): If as_string is given (a format string or true)
return datetime as a string. Otherwise, return a datetime object, which is the default.
If true is given the string will returned in ISO format.
If a format string is given it may define the special formats "UTC" or "utc"
to return a string in ISO format normalized to UTC. Otherwise the format string must be
given compliant to Python's `datetime.strptime`.
to_timezone (Optional[Union[timezone, str]]):
Optional timezone object or name (e.g., 'UTC', 'Europe/Berlin').
If provided, the datetime will be converted to this timezone.
If not provided, the datetime will be converted to the local timezone.
to_naiv (Optional[bool]):
If True, remove timezone info from datetime after conversion.
If False, keep timezone info after conversion. The default.
to_maxtime (Optional[bool]):
If True, convert to maximum time if no time is given. The default.
If False, convert to minimum time if no time is given.
Example:
to_datetime("2027-12-12 24:13:12", as_string = "%Y-%m-%dT%H:%M:%S.%f%z")
Returns:
datetime or str: Converted date as a datetime object or a formatted string with timezone.
Raises:
ValueError: If the date input is not a valid type or format.
"""
if isinstance(date_input, datetime):
dt_object = date_input
elif isinstance(date_input, date):
# Convert date object to datetime object
if to_maxtime is None or to_maxtime:
dt_object = datetime.combine(date_input, time.max)
else:
dt_object = datetime.combine(date_input, time.max)
elif isinstance(date_input, (int, float)):
# Convert timestamp to datetime object
dt_object = datetime.fromtimestamp(date_input, tz=timezone.utc)
elif isinstance(date_input, str):
# Convert string to datetime object
try:
# Try ISO format
dt_object = datetime.fromisoformat(date_input)
except ValueError as e:
formats = [
"%Y-%m-%d", # Format: 2024-10-13
"%d/%m/%y", # Format: 13/10/24
"%d/%m/%Y", # Format: 13/10/2024
"%m-%d-%Y", # Format: 10-13-2024
"%Y.%m.%d", # Format: 2024.10.13
"%d %b %Y", # Format: 13 Oct 2024
"%d %B %Y", # Format: 13 October 2024
"%Y-%m-%d %H:%M:%S", # Format: 2024-10-13 15:30:00
"%Y-%m-%d %H:%M:%S%z", # Format with timezone: 2024-10-13 15:30:00+0000
"%Y-%m-%d %H:%M:%S%z:00", # Format with timezone: 2024-10-13 15:30:00+0000
"%Y-%m-%dT%H:%M:%S.%f%z", # Format with timezone: 2024-10-13T15:30:00.000+0000
]
for fmt in formats:
try:
dt_object = datetime.strptime(date_input, fmt)
break
except ValueError as e:
dt_object = None
continue
if dt_object is None:
raise ValueError(f"Date string {date_input} does not match any known formats.")
elif date_input is None:
if to_maxtime is None or to_maxtime:
dt_object = datetime.combine(date.today(), time.max)
else:
dt_object = datetime.combine(date.today(), time.min)
else:
raise ValueError(f"Unsupported date input type: {type(date_input)}")
# Get local timezone
local_date = datetime.now().astimezone()
local_tz_name = local_date.tzname()
local_utc_offset = local_date.utcoffset()
local_timezone = timezone(local_utc_offset, local_tz_name)
# Get target timezone
if to_timezone:
if isinstance(to_timezone, timezone):
target_timezone = to_timezone
elif isinstance(to_timezone, str):
try:
target_timezone = ZoneInfo(to_timezone)
except Exception as e:
raise ValueError(f"Invalid timezone: {to_timezone}") from e
else:
raise ValueError(f"Invalid timezone: {to_timezone}")
# Adjust/Add timezone information
if dt_object.tzinfo is None or dt_object.tzinfo.utcoffset(dt_object) is None:
# datetime object is naive (not timezone aware)
# Add timezone
if to_timezone is None:
# Add local timezone
dt_object = dt_object.replace(tzinfo=local_timezone)
else:
# Set to target timezone
dt_object = dt_object.replace(tzinfo=target_timezone)
elif to_timezone:
# Localize the datetime object to given target timezone
dt_object = dt_object.astimezone(target_timezone)
else:
# Localize the datetime object to local timezone
dt_object = dt_object.astimezone(local_timezone)
if to_naiv:
# Remove timezone info to make the datetime naiv
dt_object = dt_object.replace(tzinfo=None)
if as_string:
# Return formatted string as defined by as_string
if isinstance(as_string, bool):
return dt_object.isoformat()
elif as_string == "UTC" or as_string == "utc":
dt_object = dt_object.astimezone(timezone.utc)
return dt_object.isoformat()
else:
return dt_object.strftime(as_string)
else:
return dt_object
def to_timedelta(input_value):
"""Converts various input types into a timedelta object.
Args:
input_value (Union[timedelta, str, int, float, tuple, list]): Input to be converted
timedelta.
- str: A string like "2 days", "5 hours", "30 minutes", or a combination.
- int/float: Number representing seconds.
- tuple/list: A tuple or list in the format (days, hours, minutes, seconds).
Returns:
timedelta: A timedelta object corresponding to the input value.
Raises:
ValueError: If the input format is not supported.
Examples:
>>> to_timedelta("2 days 5 hours")
datetime.timedelta(days=2, seconds=18000)
>>> to_timedelta(3600)
datetime.timedelta(seconds=3600)
>>> to_timedelta((1, 2, 30, 15))
datetime.timedelta(days=1, seconds=90315)
"""
if isinstance(input_value, timedelta):
return input_value
if isinstance(input_value, (int, float)):
# Handle integers or floats as seconds
return timedelta(seconds=input_value)
elif isinstance(input_value, (tuple, list)):
# Handle tuple or list: (days, hours, minutes, seconds)
if len(input_value) == 4:
days, hours, minutes, seconds = input_value
return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
else:
raise ValueError(f"Expected a tuple or list of length 4, got {len(input_value)}")
elif isinstance(input_value, str):
# Handle strings like "2 days 5 hours 30 minutes"
total_seconds = 0
time_units = {
"day": 86400, # 24 * 60 * 60
"hour": 3600,
"minute": 60,
"second": 1,
}
# Regular expression to match time components like '2 days', '5 hours', etc.
matches = re.findall(r"(\d+)\s*(days?|hours?|minutes?|seconds?)", input_value)
if not matches:
raise ValueError(f"Invalid time string format: {input_value}")
for value, unit in matches:
unit = unit.lower().rstrip("s") # Normalize unit
if unit in time_units:
total_seconds += int(value) * time_units[unit]
else:
raise ValueError(f"Unsupported time unit: {unit}")
return timedelta(seconds=total_seconds)
else:
raise ValueError(f"Unsupported input type: {type(input_value)}")
def to_timezone(lat: float, lon: float, as_string: Optional[bool] = None):
"""Determines the timezone for a given geographic location specified by latitude and longitude.
By default, it returns a `ZoneInfo` object representing the timezone.
If `as_string` is set to `True`, the function returns the timezone name as a string instead.
Args:
lat (float): Latitude of the location in decimal degrees. Must be between -90 and 90.
lon (float): Longitude of the location in decimal degrees. Must be between -180 and 180.
as_string (Optional[bool]):
- If `True`, returns the timezone as a string (e.g., "America/New_York").
- If `False` or not provided, returns a `ZoneInfo` object for the timezone.
Returns:
str or ZoneInfo:
- A timezone name as a string (e.g., "America/New_York") if `as_string` is `True`.
- A `ZoneInfo` timezone object if `as_string` is `False` or not provided.
Raises:
ValueError: If the latitude or longitude is out of range, or if no timezone is found for
the specified coordinates.
Example:
>>> to_timezone(40.7128, -74.0060, as_string=True)
'America/New_York'
>>> to_timezone(40.7128, -74.0060)
ZoneInfo(key='America/New_York')
"""
# Initialize the static variable only once
if not hasattr(to_timezone, "timezone_finder"):
to_timezone.timezone_finder = TimezoneFinder() # static variable
# Check and convert coordinates to timezone
try:
tz_name = to_timezone.timezone_finder.timezone_at(lat=lat, lng=lon)
if not tz_name:
raise ValueError(f"No timezone found for coordinates: latitude {lat}, longitude {lon}")
except Exception as e:
raise ValueError(f"Invalid location: latitude {lat}, longitude {lon}") from e
if as_string:
return tz_name
return ZoneInfo(tz_name)

View File

@@ -0,0 +1,95 @@
"""Utility functions for handling logging tasks.
Functions:
----------
- get_logger: Creates and configures a logger with console and optional rotating file logging.
Example usage:
--------------
# Logger setup
>>> logger = get_logger(__name__, log_file="app.log", logging_level="DEBUG")
>>> logger.info("Logging initialized.")
Notes:
------
- The logger supports rotating log files to prevent excessive log file size.
"""
import logging
import os
from logging.handlers import RotatingFileHandler
from typing import Optional
def get_logger(
name: str,
log_file: Optional[str] = None,
logging_level: Optional[str] = "INFO",
max_bytes: int = 5000000,
backup_count: int = 5,
) -> logging.Logger:
"""Creates and configures a logger with a given name.
The logger supports logging to both the console and an optional log file. File logging is
handled by a rotating file handler to prevent excessive log file size.
Args:
name (str): The name of the logger, typically `__name__` from the calling module.
log_file (Optional[str]): Path to the log file for file logging. If None, no file logging is done.
logging_level (Optional[str]): Logging level (e.g., "INFO", "DEBUG"). Defaults to "INFO".
max_bytes (int): Maximum size in bytes for log file before rotation. Defaults to 5 MB.
backup_count (int): Number of backup log files to keep. Defaults to 5.
Returns:
logging.Logger: Configured logger instance.
Example:
logger = get_logger(__name__, log_file="app.log", logging_level="DEBUG")
logger.info("Application started")
"""
# Create a logger with the specified name
logger = logging.getLogger(name)
logger.propagate = True
if logging_level == "DEBUG":
level = logging.DEBUG
elif logging_level == "INFO":
level = logging.INFO
elif logging_level == "WARNING":
level = logging.WARNING
elif logging_level == "ERROR":
level = logging.ERROR
else:
level = logging.DEBUG
logger.setLevel(level)
# The log message format
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
# Prevent loggers from being added multiple times
# There may already be a logger from pytest
if not logger.handlers:
# Create a console handler with a standard output stream
console_handler = logging.StreamHandler()
console_handler.setLevel(level)
console_handler.setFormatter(formatter)
# Add the console handler to the logger
logger.addHandler(console_handler)
if log_file and len(logger.handlers) < 2: # We assume a console logger to be the first logger
# If a log file path is specified, create a rotating file handler
# Ensure the log directory exists
log_dir = os.path.dirname(log_file)
if log_dir and not os.path.exists(log_dir):
os.makedirs(log_dir)
# Create a rotating file handler
file_handler = RotatingFileHandler(log_file, maxBytes=max_bytes, backupCount=backup_count)
file_handler.setLevel(level)
file_handler.setFormatter(formatter)
# Add the file handler to the logger
logger.addHandler(file_handler)
return logger

View File

@@ -0,0 +1,48 @@
import datetime
import json
import zoneinfo
import numpy as np
# currently unused
def ist_dst_wechsel(tag: datetime.datetime, timezone="Europe/Berlin") -> bool:
"""Checks if Daylight Saving Time (DST) starts or ends on a given day."""
tz = zoneinfo.ZoneInfo(timezone)
# Get the current day and the next day
current_day = datetime.datetime(tag.year, tag.month, tag.day)
next_day = current_day + datetime.timedelta(days=1)
# Check if the UTC offsets are different (indicating a DST change)
dst_change = current_day.replace(tzinfo=tz).dst() != next_day.replace(tzinfo=tz).dst()
return dst_change
class NumpyEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, np.ndarray):
return obj.tolist() # Convert NumPy arrays to lists
if isinstance(obj, np.generic):
return obj.item() # Convert NumPy scalars to native Python types
return super(NumpyEncoder, self).default(obj)
@staticmethod
def dumps(data):
"""Static method to serialize a Python object into a JSON string using NumpyEncoder.
Args:
data: The Python object to serialize.
Returns:
str: A JSON string representation of the object.
"""
return json.dumps(data, cls=NumpyEncoder)
# # Example usage
# start_date = datetime.datetime(2024, 3, 31) # Date of the DST change
# if ist_dst_wechsel(start_date):
# prediction_hours = 23 # Adjust to 23 hours for DST change days
# else:
# prediction_hours = 24 # Default value for days without DST change