Source code for foundry_dev_tools.utils.config
"""Utils functions for the configuration.
The config directories/files.
The environment config.
Function to merge dictionaries.
And the function that checks if the kwargs for a __init__ are correct,
currently used for the :py:class:`foundry_dev_tools.config.config.Config`
and the token providers :py:mod:`foundry_dev_tools.config.token_provider`.
"""
from __future__ import annotations
import inspect
import os
import warnings
from functools import cache
from importlib.metadata import entry_points
from os import PathLike
from pathlib import Path
from typing import TYPE_CHECKING, Any
import platformdirs
from foundry_dev_tools.errors.config import FoundryConfigError
from foundry_dev_tools.utils.repo import git_toplevel_dir
if TYPE_CHECKING:
from foundry_dev_tools.clients.api_client import APIClient
from foundry_dev_tools.config.token_provider import TokenProvider
ENVIRONMENT_VARIABLE_PREFIX = "FDT_"
CFG_FILE_NAME = "config.toml"
PROJECT_CFG_FILE_NAME = ".foundry_dev_tools.toml"
@cache
def _platformdirs() -> platformdirs.PlatformDirsABC:
return platformdirs.PlatformDirs("foundry-dev-tools")
[docs]
@cache
def cfg_files(use_project_config: bool = True) -> dict[Path, None]:
"""Returns all the possible configuration file paths (cached).
Returns a dict with the paths as keys.
Sets are not ordered in python but dicts are since 3.8.
The first one is the system-wide config.
"""
ret = {site_cfg_file(): None, **user_cfg_files()}
if use_project_config and (project_cfg := find_project_config_file()):
ret[project_cfg] = None
return ret
[docs]
@cache
def user_cfg_files() -> dict[Path, None]:
"""Returns all possible user configuration files.
Returns a dict with the paths as keys.
Sets are not ordered in python but dicts are since 3.8.
"""
return {
Path.home().joinpath(".foundry-dev-tools", CFG_FILE_NAME): None,
Path.home().joinpath(".config", "foundry-dev-tools", CFG_FILE_NAME): None,
_platformdirs().user_config_path.joinpath(CFG_FILE_NAME): None,
}
[docs]
@cache
def site_cfg_file() -> Path:
"""Returns the site_config_path from :py:mod:`platformdirs`."""
return _platformdirs().site_config_path.joinpath(CFG_FILE_NAME)
[docs]
@cache
def user_cache() -> Path:
"""Returns the cached directory for the user (cached)."""
return _platformdirs().user_cache_path
[docs]
def merge_dicts(a: dict, b: dict) -> dict:
"""Merges two nested dicts."""
if isinstance(a, dict) and isinstance(b, dict):
for k in b:
if k in a:
a[k] = merge_dicts(a[k], b[k])
else:
a[k] = b[k]
return a
return b if b is not None else a
[docs]
def path_from_path_or_str(path: Path | PathLike[str] | str) -> Path:
"""Returns the same variable if instance of Path otherwise create a Path."""
if isinstance(path, Path):
return path
return Path(path)
[docs]
def find_project_config_file(
project_directory: Path | None = None, use_git: bool = False, check_caller_file: bool = True
) -> Path | None:
"""Get the project config file in a git repo.
Args:
project_directory: the path to a (sub)directory of a git repo,
otherwise checks caller filename directory and working directory
use_git: passed to :py:meth:git_toplevel_dir
check_caller_file: if the directory of the current executed python script should be checked
Returns:
Path | None: Path to the project config or None if no file was found
"""
if project_directory is None:
git_directory = None
if check_caller_file:
git_directory = git_toplevel_dir(Path(inspect.stack()[-1].filename).parent, use_git=use_git)
if git_directory is None:
git_directory = git_toplevel_dir(Path.cwd(), use_git=use_git)
else:
git_directory = git_toplevel_dir(project_directory, use_git=use_git)
if not git_directory:
cwd_config = Path.cwd().joinpath(PROJECT_CFG_FILE_NAME)
# return config in current directory only if it exists
if cwd_config.exists():
return cwd_config
return None
return git_directory.joinpath(PROJECT_CFG_FILE_NAME)
[docs]
def get_environment_variable_config() -> dict:
"""Returns the `FDT_*` environment variables as a config dict."""
env_dict = {}
for name, value in os.environ.items():
if name.startswith(ENVIRONMENT_VARIABLE_PREFIX):
parts = name[len(ENVIRONMENT_VARIABLE_PREFIX) :].lower().split("__")
if len(parts) <= 1:
if parts[0] == "profile": # profile is a special case
if len(value) == 0:
value = None # allow erasing via env variable # noqa: PLW2901
else:
warnings.warn(f"{name} is not a valid Foundry DevTools configuration environment variable.")
continue
cfg_parts = parts[:-1]
o = env_dict
for cfg_part in cfg_parts:
if cfg_part not in o or not isinstance(o[cfg_part], dict):
o[cfg_part] = {}
o = o[cfg_part]
o[parts[-1]] = _try_convert_to_bool(value)
from foundry_dev_tools.utils.compat import get_v1_environment_variables # otherwise circular import
v1_env_dict = get_v1_environment_variables()
return merge_dicts(v1_env_dict, env_dict)
def _try_convert_to_bool(value: Any) -> Any: # noqa: ANN401
if isinstance(value, str) and value.lower() == "false":
return False
elif isinstance(value, str) and value.lower() == "true": # noqa: RET505
return True
return value
[docs]
@cache
def entry_point_fdt_token_provider() -> dict[str, type[TokenProvider]]:
"""Returns the token provider implementations registered via entry points."""
return {ep.name: ep.load() for ep in entry_points(group="fdt_token_provider")}
[docs]
@cache
def entry_point_fdt_api_client() -> dict[str, type[APIClient]]:
"""Returns the API clients registered via entry points."""
return {ep.name: ep.load() for ep in entry_points(group="fdt_api_client")}
[docs]
def check_init(
init_class: type,
config_path: str,
kwargs: dict[str, Any],
) -> dict:
"""Checks if the supplied kwargs from the config dict are valid for the class to be instantiated.
If a kwargs is of the wrong type it will try to be cast to the correct type.
If this fails a :py:class:`FoundryConfigError` will be raised.
If it succeeds it will still show a warning, that the value has been cast.
Args:
init_class: The class that will be instantiated with :py:attr:`kwargs`
config_path: For the warnings/errors to show which config setting is invalid
config.path + "." + invalid_kwarg_name
kwargs: the kwargs to check
Returns:
valid_kwargs: dict
the kwargs that (were cast to) work for the instantiated class
"""
valid_kwargs = {}
sig = inspect.signature(init_class)
parms = sig.parameters
for name, parameter in parms.items():
conf_name = f"{config_path}.{name}"
if name in kwargs and parameter.annotation is not parameter.empty and not isinstance(parameter.annotation, str):
if not isinstance(kwargs[name], parameter.annotation):
try:
valid_kwargs[name] = parameter.annotation(kwargs[name])
warnings.warn(
f"{conf_name} was type {type(kwargs[name])!s} but has been cast to"
f" {parameter.annotation!s}, this was needed to instantiate {init_class!s}.",
)
del kwargs[name]
continue
except Exception as e:
msg = (
f"To initialize {init_class!s}, the config option {conf_name} needs to be of type"
f" {parameter.annotation!s}, but it is type {type(kwargs[name])!s}"
)
raise FoundryConfigError(
msg,
) from e
valid_kwargs[name] = kwargs[name]
del kwargs[name]
elif name in kwargs:
valid_kwargs[name] = kwargs[name]
del kwargs[name]
elif (
parameter.kind is not parameter.VAR_KEYWORD
and parameter.kind is not parameter.VAR_POSITIONAL
and parameter.default is parameter.empty
and parameter.default is parameter.empty
):
msg = f"{conf_name} is missing to create {init_class!s}."
raise FoundryConfigError(msg)
for name in kwargs:
conf_name = f"{config_path}.{name}"
warnings.warn(f"{conf_name} is not a valid config option for {init_class!s}")
return valid_kwargs