import json
import os
import inspect
import sys
from asyncio import Lock
from pathlib import Path
from typing import Any, Optional
from dotenv import load_dotenv
from vyra_base.helper._aiopath import AsyncPath
from vyra_base.helper.file_lock import (
get_lock_for_file,
release_lock_for_file,
get_lock_for_file_sync,
release_lock_for_file_sync
)
[docs]
class FileReader:
"""File reader.
Reads file content from locally stored files into the module.
"""
@staticmethod
async def _resolve_async_open(open_result):
"""Normalize AsyncPath.open return type across runtimes and mocks."""
if inspect.isawaitable(open_result):
return await open_result
return open_result
[docs]
@classmethod
async def open_json_file(
cls, config_file: Path, config_default: Path=Path('')) -> Any:
"""Reads a JSON file.
To ensure cross platform compatibility, JSON files are opened with
utf8-sig encoding.
:param config_file: JSON formatted file.
:type config_file: Path
:param config_default: JSON file as a default configuration for the module.
:type config_default: Path
:returns: Parsed JSON content.
:rtype: Any
:raises IOError: If an unexpected IO error occurs.
:raises UnicodeDecodeError: If a decoding error occurs.
:raises json.decoder.JSONDecodeError: If a JSON decoding error occurs.
:raises TypeError: If an unexpected type error occurs.
:raises FileNotFoundError: If the file is not found.
"""
try:
lock = await get_lock_for_file(config_file)
async with lock:
reader_cm = await cls._resolve_async_open(
AsyncPath(str(config_file)).open(mode='r', encoding='utf-8')
)
async with reader_cm as file:
json_content = json.loads(await file.read())
if json_content:
return json_content
else:
default_reader_cm = await cls._resolve_async_open(
AsyncPath(str(config_default)).open(mode='r', encoding='utf-8-sig')
)
async with default_reader_cm as default_file:
default_content = await default_file.read()
await AsyncPath(str(config_file)).write_text(default_content)
return json.loads(default_content)
except FileNotFoundError:
raise FileNotFoundError(f"File not found: {config_file}")
except json.decoder.JSONDecodeError as error:
raise json.decoder.JSONDecodeError('JSON decoding error:', error.doc, error.pos)
except (IOError, UnicodeDecodeError, TypeError) as error:
if isinstance(error, IOError):
raise IOError('An unexpected io error occured!')
if isinstance(error, UnicodeDecodeError):
raise UnicodeDecodeError(
'utf-8', b'', 0, 0, 'A decoding error occured!') # type: ignore
raise
finally:
await release_lock_for_file(config_file)
[docs]
@classmethod
async def open_markdown_file(cls, config_file: Path, config_default='') -> Any:
"""Reads a markdown file.
:param config_file: Markdown formatted file.
:type config_file: Path
:param config_default: Markdown file as a default configuration for the module.
:type config_default: str
:returns: File content.
:rtype: Any
:raises IOError: If an unexpected IO error occurs.
:raises UnicodeDecodeError: If a decoding error occurs.
:raises json.decoder.JSONDecodeError: If a JSON decoding error occurs.
:raises TypeError: If an unexpected type error occurs.
:raises FileNotFoundError: If the file is not found.
"""
try:
lock = await get_lock_for_file(config_file)
async with lock:
reader_cm = await cls._resolve_async_open(
AsyncPath(str(config_file)).open(mode='r', encoding='utf-8')
)
async with reader_cm as file:
content = await file.read()
if content:
return content
else:
default_reader_cm = await cls._resolve_async_open(
AsyncPath(str(config_default)).open(mode='r', encoding='utf-8-sig')
)
async with default_reader_cm as default_file:
default_content = await default_file.read()
await AsyncPath(str(config_file)).write_text(default_content)
return default_content
release_lock_for_file(config_file)
except (IOError, UnicodeDecodeError, json.decoder.JSONDecodeError,
TypeError, FileNotFoundError) as error:
if error == UnicodeDecodeError: # type: ignore
raise UnicodeDecodeError(
'utf-8', b'', 0, 0, 'A decoding error occured!') # type: ignore
if error == json.decoder.JSONDecodeError: # type: ignore
raise json.decoder.JSONDecodeError('JSON decoding error.', '', 0)
finally:
await release_lock_for_file(config_file)
[docs]
@classmethod
async def open_env_file(cls, env_path: Path) -> dict:
"""Reads an environment (.env) file.
:param env_path: Path to the directory containing the .env file.
:type env_path: Path
:returns: Dictionary of environment variables.
:rtype: dict
:raises IOError: If an unexpected IO error occurs.
:raises TypeError: If an unexpected type error occurs.
:raises FileNotFoundError: If the file is not found.
"""
try:
lock: Lock = await get_lock_for_file(env_path / '.env')
async with lock:
load_dotenv(dotenv_path=env_path)
env_content: list = []
env_vars: dict = dict(os.environ)
return env_vars
finally:
await release_lock_for_file(env_path / '.env')
[docs]
@classmethod
async def open_toml_file(cls, config_file: Path) -> Any:
"""Reads a TOML file.
:param config_file: TOML formatted file.
:type config_file: Path
:returns: Parsed TOML content as a dictionary.
:rtype: dict[str, Any]
:raises ImportError: If tomli is not installed for Python < 3.11.
:raises IOError: If an unexpected IO error occurs.
:raises TypeError: If an unexpected type error occurs.
:raises FileNotFoundError: If the file is not found.
"""
try:
if sys.version_info >= (3, 11):
import tomllib
else:
try:
import tomli as tomllib
except ImportError:
raise ImportError(
"Python < 3.11 benötigt das Paket 'tomli' zum Parsen "
"von TOML-Dateien. Installiere es mit: pip install tomli"
)
lock: Lock = await get_lock_for_file(config_file)
async with lock:
toml_content: dict = {}
reader_cm = await cls._resolve_async_open(
AsyncPath(config_file).open(mode='r', encoding='utf-8-sig')
)
async with reader_cm as file:
raw_content = await file.read()
if not isinstance(raw_content, str):
raise TypeError(
f"{config_file} must be a string. Red: {raw_content}"
)
toml_content = tomllib.loads(raw_content)
return toml_content
finally:
await release_lock_for_file(config_file)
[docs]
@classmethod
async def open_ini_file(cls, config_file: Path) -> Any:
"""Reads an INI file.
:param config_file: INI formatted file.
:type config_file: Path
:returns: Parsed INI content as a dictionary.
:rtype: dict[str, Any]
:raises ImportError: If configparser is not available.
:raises IOError: If an unexpected IO error occurs.
:raises TypeError: If an unexpected type error occurs.
:raises FileNotFoundError: If the file is not found.
"""
try:
import configparser
lock: Lock = await get_lock_for_file(config_file)
async with lock:
config = configparser.ConfigParser()
config.read(str(config_file))
return {s: dict(config.items(s)) for s in config.sections()}
finally:
await release_lock_for_file(config_file)
[docs]
@classmethod
async def open_yaml_file(cls, config_file: Path) -> Any:
"""Reads a YAML file.
:param config_file: YAML formatted file.
:type config_file: Path
:returns: Parsed YAML content as a dictionary.
:rtype: dict[str, Any]
:raises ImportError: If PyYAML is not installed.
:raises IOError: If an unexpected IO error occurs.
:raises TypeError: If an unexpected type error occurs.
:raises FileNotFoundError: If the file is not found.
"""
try:
import yaml
lock: Lock = await get_lock_for_file(config_file)
async with lock:
reader_cm = await cls._resolve_async_open(
AsyncPath(config_file).open(mode='r', encoding='utf-8-sig')
)
async with reader_cm as file:
yaml_content = yaml.safe_load(await file.read())
return yaml_content
finally:
await release_lock_for_file(config_file)
# ========================================================================
# SYNC METHODS
# ========================================================================
[docs]
@classmethod
def open_json_file_sync(
cls, config_file: Path, config_default: Path = Path('')) -> Any:
"""Reads a JSON file (synchronous).
:param config_file: JSON formatted file.
:type config_file: Path
:param config_default: JSON file as a default configuration for the module.
:type config_default: Path
:returns: Parsed JSON content.
:rtype: Any
:raises IOError: If an unexpected IO error occurs.
:raises UnicodeDecodeError: If a decoding error occurs.
:raises json.decoder.JSONDecodeError: If a JSON decoding error occurs.
:raises TypeError: If an unexpected type error occurs.
:raises FileNotFoundError: If the file is not found.
"""
try:
lock = get_lock_for_file_sync(config_file)
with lock:
with open(config_file, mode='r', encoding='utf-8') as file:
json_content = json.loads(file.read())
if json_content:
return json_content
else:
with open(config_default, mode='r', encoding='utf-8-sig') as default_file:
Path(config_file).write_text(default_file.read())
with open(config_file, mode='r', encoding='utf-8') as file:
return json.loads(file.read())
except FileNotFoundError:
raise FileNotFoundError(f"File not found: {config_file}")
except json.decoder.JSONDecodeError as error:
raise json.decoder.JSONDecodeError('JSON decoding error:', error.doc, error.pos)
except (IOError, UnicodeDecodeError, TypeError) as error:
if isinstance(error, IOError):
raise IOError('An unexpected io error occured!')
if isinstance(error, UnicodeDecodeError):
raise UnicodeDecodeError('utf-8', b'', 0, 0, 'A decoding error occured!')
raise
finally:
release_lock_for_file_sync(config_file)
[docs]
@classmethod
def open_markdown_file_sync(cls, config_file: Path, config_default: str = '') -> Any:
"""Reads a markdown file (synchronous).
:param config_file: Markdown formatted file.
:type config_file: Path
:param config_default: Markdown file as a default configuration for the module.
:type config_default: str
:returns: File content.
:rtype: Any
:raises IOError: If an unexpected IO error occurs.
:raises UnicodeDecodeError: If a decoding error occurs.
:raises TypeError: If an unexpected type error occurs.
:raises FileNotFoundError: If the file is not found.
"""
try:
lock = get_lock_for_file_sync(config_file)
with lock:
with open(config_file, mode='r', encoding='utf-8') as file:
content = file.read()
if content:
return content
else:
with open(config_default, mode='r', encoding='utf-8-sig') as default_file:
Path(config_file).write_text(default_file.read())
with open(config_file, mode='r', encoding='utf-8') as file:
return file.read()
except (IOError, UnicodeDecodeError, TypeError, FileNotFoundError) as error:
if isinstance(error, UnicodeDecodeError):
raise UnicodeDecodeError('utf-8', b'', 0, 0, 'A decoding error occured!')
raise
finally:
release_lock_for_file_sync(config_file)
[docs]
@classmethod
def open_env_file_sync(cls, env_path: Path) -> Optional[dict]:
"""Reads an environment (.env) file (synchronous).
:param env_path: Path to the directory containing the .env file.
:type env_path: Path
:returns: Dictionary of environment variables.
:rtype: dict
:raises IOError: If an unexpected IO error occurs.
:raises TypeError: If an unexpected type error occurs.
:raises FileNotFoundError: If the file is not found.
"""
try:
lock = get_lock_for_file_sync(env_path / '.env')
with lock:
load_dotenv(dotenv_path=env_path)
env_vars: dict = dict(os.environ)
return env_vars
finally:
release_lock_for_file_sync(env_path / '.env')
[docs]
@classmethod
def open_toml_file_sync(cls, config_file: Path) -> Optional[Any]:
"""Reads a TOML file (synchronous).
:param config_file: TOML formatted file.
:type config_file: Path
:returns: Parsed TOML content as a dictionary.
:rtype: dict[str, Any]
:raises ImportError: If tomli is not installed for Python < 3.11.
:raises IOError: If an unexpected IO error occurs.
:raises TypeError: If an unexpected type error occurs.
:raises FileNotFoundError: If the file is not found.
"""
try:
if sys.version_info >= (3, 11):
import tomllib
else:
try:
import tomli as tomllib
except ImportError:
raise ImportError(
"Python < 3.11 benötigt das Paket 'tomli' zum Parsen "
"von TOML-Dateien. Installiere es mit: pip install tomli"
)
lock = get_lock_for_file_sync(config_file)
with lock:
with open(config_file, mode='rb') as file:
toml_content = tomllib.load(file)
return toml_content
finally:
release_lock_for_file_sync(config_file)
[docs]
@classmethod
def open_ini_file_sync(cls, config_file: Path) -> Optional[Any]:
"""Reads an INI file (synchronous).
:param config_file: INI formatted file.
:type config_file: Path
:returns: Parsed INI content as a dictionary.
:rtype: dict[str, Any]
:raises ImportError: If configparser is not available.
:raises IOError: If an unexpected IO error occurs.
:raises TypeError: If an unexpected type error occurs.
:raises FileNotFoundError: If the file is not found.
"""
try:
import configparser
lock = get_lock_for_file_sync(config_file)
with lock:
config = configparser.ConfigParser()
config.read(str(config_file))
return {s: dict(config.items(s)) for s in config.sections()}
finally:
release_lock_for_file_sync(config_file)
[docs]
@classmethod
def open_yaml_file_sync(cls, config_file: Path) -> Optional[Any]:
"""Reads a YAML file (synchronous).
:param config_file: YAML formatted file.
:type config_file: Path
:returns: Parsed YAML content as a dictionary.
:rtype: dict[str, Any]
:raises ImportError: If PyYAML is not installed.
:raises IOError: If an unexpected IO error occurs.
:raises TypeError: If an unexpected type error occurs.
:raises FileNotFoundError: If the file is not found.
"""
try:
import yaml
lock = get_lock_for_file_sync(config_file)
with lock:
with open(config_file, mode='r', encoding='utf-8-sig') as file:
yaml_content = yaml.safe_load(file.read())
return yaml_content
finally:
release_lock_for_file_sync(config_file)