"""Configuration module."""
import os
from enum import Enum
from pathlib import Path
from typing import Any
from typing import Dict
from typing import List
from typing import Literal
from typing import Optional
from typing import Tuple
from typing import Type
from typing import TypedDict
from typing import Union
import tomli
from pydantic import Field
from pydantic import model_validator
from pydantic_settings import BaseSettings
[docs]class QtDevHelperConfigError(Exception):
"""Error thrown when accessing functionality with insufficient config."""
[docs]class ConfigNotFoundError(Exception):
"""Error thrown when the config file could not be found."""
[docs]def _str_list_factory(*args: Any) -> List[str]:
"""Ensure that a list of arbitrary argument is List[str].
Parameters
----------
args: Any
Arbitrary arguments.
Returns
-------
List[str]
List of ``args`` cast to string.
"""
return [str(arg) for arg in args]
[docs]def _check_symmetric_io_definition(
config: "Config", input_var_name: str, output_var_name: str
) -> "Config":
"""Check that ``input_var_name`` and ``output_var_name`` are both None or both not None.
Parameters
----------
config: "Config"
Instance of the Config.
input_var_name: str
Name of the input path variable.
output_var_name: str
Name of the output path variable.
Returns
-------
"Config"
Value of ``values``
Raises
------
AssertionError
If only one value of ``input_var_name`` and ``output_var_name`` is None.
"""
input_path = config.model_dump().get(input_var_name)
output_path = config.model_dump().get(output_var_name)
if (output_path is None and input_path is not None) or (
output_path is not None and input_path is None
):
raise AssertionError(
f"The values of {input_var_name!r} and {output_var_name!r} need either be both "
"defined or both be undefined.\nGot:\n"
f"\t{input_var_name}={input_path!r}\n\t{output_var_name}={output_path!r}"
)
return config
[docs]def expand_io_paths(
config: "Config", input_var_name: str, output_var_name: str
) -> Tuple[Path, Path]:
"""Expand relative io paths with ``base_path`` from config.
Parameters
----------
config: Config
Config instance, needed to determine the base path.
input_var_name: str
Name of the variable holding the input path string.
output_var_name: str
Name of the variable holding the input path string.
Returns
-------
Tuple[Path, Path]
Expanded input path and expanded output path.
Raises
------
QtDevHelperConfigError
If any of the io paths is None.
"""
input_var: str = getattr(config, input_var_name)
output_var: str = getattr(config, output_var_name)
base_path: Path = getattr(config, "base_path")
if input_var is None or output_var is None:
raise QtDevHelperConfigError(
f"Both {input_var_name!r} and {output_var_name!r} need to be defined.\n"
f"Got:\n\t{input_var_name}={output_var!r}\n\n{input_var_name}={output_var!r}"
)
return base_path / input_var, base_path / output_var
[docs]class CodeGenerators(str, Enum):
"""Valid code generator values."""
python = "python"
cpp = "cpp"
[docs]class UicKwargs(TypedDict, total=False):
"""Keyword arguments to be used with ``compile_ui_file``."""
generator: Literal["python", "cpp"]
uic_args: List[str]
form_import: bool
[docs]class RccKwargs(TypedDict, total=False):
"""Keyword arguments to be used with ``compile_resource_file``."""
generator: Literal["python", "cpp"]
rcc_args: List[str]
[docs]class Config(BaseSettings, extra="forbid"): # type:ignore[call-arg]
"""Project configuration."""
base_path: Path = Field(
description="Directory the config was loaded from, used to resolve relative paths."
)
# Style generator options
root_sass_file: Optional[str] = Field(
default=None, description="Scss stylesheet with the style for the whole application."
)
root_qss_file: Optional[str] = Field(
default=None,
description=(
"Qss stylesheet with the style for the whole application, "
"generated from 'root_sass_file'."
),
)
# General Qt code generator options
generator: CodeGenerators = Field(
default=CodeGenerators.python,
description="Code generator used to compile ui and resource files.",
)
flatten_folder_structure: bool = Field(
default=True, description="Whether to keep the original folder structure or flatten it."
)
# Qt ui code generator options
ui_files_folder: Optional[str] = Field(
default=None, description="Root folder containing *.ui files."
)
generated_ui_code_folder: Optional[str] = Field(
default=None, description="Root folder to save code generated from *.ui files to."
)
uic_args: List[str] = Field(
default_factory=_str_list_factory,
description="Additional arguments for the uic executable.",
)
form_import: bool = Field(default=True, description="Python: generate imports relative to '.'")
# Qt rc code generator options
resource_folder: Optional[str] = Field(
default=None, description="Root folder containing *.qrc files."
)
generated_rc_code_folder: Optional[str] = Field(
default=None, description="Root folder to save code generated from *.qrc files to."
)
rcc_args: List[str] = Field(
default_factory=_str_list_factory,
description="Additional arguments for the rcc executable.",
)
@model_validator(mode="before")
def _validate_style_input_path(cls: Type["Config"], data: Dict[str, Any]) -> Dict[str, Any]:
"""Validate that ``root_sass_file`` is a valid path if defined."""
return _check_input_exists(data, "root_sass_file", is_file=True)
@model_validator(mode="before")
def _validate_ui_input_path(cls: Type["Config"], data: Dict[str, Any]) -> Dict[str, Any]:
"""Validate that ``ui_files_folder`` is a valid path if defined."""
return _check_input_exists(data, "ui_files_folder")
@model_validator(mode="before")
def _validate_rc_input_path(cls: Type["Config"], data: Dict[str, Any]) -> Dict[str, Any]:
"""Validate that ``resource_folder`` is a valid path if defined."""
return _check_input_exists(data, "resource_folder")
@model_validator(mode="after")
def _validate_styles_io(self) -> "Config":
"""``root_sass_file`` and ``root_qss_file`` are both defined or undefined."""
return _check_symmetric_io_definition(self, "root_sass_file", "root_qss_file")
@model_validator(mode="after")
def _validate_ui_io(self) -> "Config":
"""``ui_files_folder`` and ``generated_ui_code_folder`` are both defined or undefined."""
return _check_symmetric_io_definition(self, "ui_files_folder", "generated_ui_code_folder")
@model_validator(mode="after")
def _validate_rc_io(self) -> "Config":
"""``resource_folder`` and ``generated_rc_code_folder`` are both defined or undefined."""
return _check_symmetric_io_definition(self, "resource_folder", "generated_rc_code_folder")
[docs] def root_style_paths(self) -> Tuple[Path, Path]:
"""Resolve paths to root style files.
Returns
-------
Tuple[Path, Path]
Paths to ``root_sass_file`` and ``root_qss_file``.
"""
return expand_io_paths(self, "root_sass_file", "root_qss_file")
[docs] def ui_folder_paths(self) -> Tuple[Path, Path]:
"""Resolve paths to root style files.
Returns
-------
Tuple[Path, Path]
Paths to ``ui_files_folder`` and ``generated_ui_code_folder``.
"""
return expand_io_paths(self, "ui_files_folder", "generated_ui_code_folder")
[docs] def rc_folder_paths(self) -> Tuple[Path, Path]:
"""Resolve paths to root style files.
Returns
-------
Tuple[Path, Path]
Paths to ``resource_folder`` and ``generated_rc_code_folder``.
"""
return expand_io_paths(self, "resource_folder", "generated_rc_code_folder")
[docs] def uic_kwargs(self) -> UicKwargs:
"""Extract keyword arguments to be used with ``compile_ui_file``.
Returns
-------
UicKwargs
Keyword arguments for ``compile_ui_file``.
"""
return {
"generator": self.generator.value,
"form_import": self.form_import,
"uic_args": self.uic_args,
}
[docs] def rcc_kwargs(self) -> RccKwargs:
"""Extract keyword arguments to be used with ``compile_resource_file``.
Returns
-------
RccKwargs
Keyword arguments for ``compile_resource_file``.
"""
return {
"generator": self.generator.value,
"rcc_args": self.rcc_args,
}
[docs] def deactivate_style_build(self) -> None:
"""Deactivate style building with :func:`build_all_assets`."""
self.root_sass_file = None
self.root_qss_file = None
[docs] def deactivate_ui_build(self) -> None:
"""Deactivate ui building with :func:`build_all_assets`."""
self.ui_files_folder = None
self.generated_ui_code_folder = None
[docs] def deactivate_resource_build(self) -> None:
"""Deactivate resource building with :func:`build_all_assets`."""
self.resource_folder = None
self.generated_rc_code_folder = None
[docs] def update(self, update_dict: Dict[str, Any], filter_none: bool = True) -> None:
"""Update config values.
Parameters
----------
update_dict: Dict[str, Any]
Dict containing updated values.
filter_none: bool
Whether or not to filter None values before updating. Defaults to True
"""
if filter_none is True:
update_dict = {key: value for key, value in update_dict.items() if value is not None}
# This ensures validation of the updated values
updated_config = self.__class__(**{**self.model_dump(), **update_dict})
for key, val in updated_config.model_dump().items():
setattr(self, key, val)
[docs]def load_toml_config(path: Path) -> Config:
"""Load config from toml config file.
Parameters
----------
path: Path
Path to the toml config file.
Returns
-------
Config
Configuration instance generate from toml definition.
Raises
------
ConfigNotFoundError
If no config file does not contain 'qt-dev-helper' config.
"""
toml_config = tomli.loads(path.read_text())
qt_dev_helper_config = toml_config.get("tool", {}).get("qt-dev-helper", {})
if len(qt_dev_helper_config) > 0:
return Config(**{**qt_dev_helper_config, "base_path": path.parent})
raise ConfigNotFoundError(f"Could not find 'qt-dev-helper' config in {path.as_posix()}")
[docs]def find_config(
start_path: Optional[Union[Path, str]] = None, config_file_name: str = "pyproject.toml"
) -> Path:
"""Find config file based on its name and start path, by traversing parent paths.
Parameters
----------
start_path: Optional[Union[Path,str]]
Path to start looking for the config file.
Defaults to None which means the current dir will be used
config_file_name: str
Name of the config file. Defaults to "pyproject.toml"
Returns
-------
Path
Path of the found config file
Raises
------
ConfigNotFoundError
If no config file could be found.
"""
if start_path is None:
start_path = Path(os.curdir)
start_path = Path(start_path).resolve()
if start_path.is_file():
start_path = start_path.parent
for path in (start_path, *start_path.parents):
file_path = path / config_file_name
if file_path in set(path.iterdir()):
return file_path
raise ConfigNotFoundError(f"Could not find config file {config_file_name!r}.")
[docs]def load_config(start_path: Optional[Union[Path, str]] = None) -> Config:
"""Load config from file.
Parameters
----------
start_path: Optional[Union[Path,str]]
Path to start looking for the config file.
Defaults to None which means the current dir will be used
Returns
-------
Config
Configuration instance generated from file.
Raises
------
ConfigNotFoundError
If no config file containing 'qt-dev-helper' config could be found.
"""
supported_config_formats = (("pyproject.toml", load_toml_config),)
for config_file_name, load_func in supported_config_formats:
try:
return load_func(find_config(start_path, config_file_name))
except ConfigNotFoundError:
continue
raise ConfigNotFoundError("No config file containing 'qt-dev-helper' config could be found.")