Source code for herethere.everywhere.config

"""herethere.everywhere.config"""

import os
from dataclasses import MISSING, asdict, dataclass, field, fields
from os import environ
from typing import Any

from dotenv import dotenv_values, find_dotenv, set_key, unset_key


class ConnectionConfigError(Exception):
    """Connection config error."""


def prefixed_key(*, key: str, prefix: str) -> str:
    """Returns key with a prefix and in upper case."""
    if prefix:
        prefix = f"{prefix}_"
    return f"{prefix}{key}".upper()


def load_prefixed_env(*, prefix: str, path: str = None) -> dict[str, str]:
    """Load prefixed dotenv config and overlay process environment variables."""
    if not path:
        path = find_dotenv(f"{prefix}.env", usecwd=True)
    env = dotenv_values(dotenv_path=path)
    env.update(environ)
    return env


[docs] @dataclass class ConnectionConfig: """Remote connection configuration.""" host: str port: int username: str password: str host_key_sha256: str | None = field(default=None, kw_only=True) def __post_init__(self): self.port = int(self.port) if self.host_key_sha256 is not None: self.host_key_sha256 = self.host_key_sha256.strip() or None @property def asdict(self) -> dict[str, Any]: """Dict represntation of the instance.""" return asdict(self) @property def ssh_options(self) -> dict[str, Any]: """Connection options accepted directly by AsyncSSH.""" options = self.asdict options.pop("host_key_sha256") return options
[docs] @classmethod def load(cls, prefix: str = "", path: str = None) -> "ConnectionConfig": """Load configuration from the environment, and file with configurations variables. If `path` is not specified, variables are loaded from a file named {prefix}.env in the current directory or any of its parents. :param prefix: prefix for variables ({PREFIX}_HOST), and a configuration file name to search for: {prefix}.env :param path: explicit configuration file location """ env = load_prefixed_env(prefix=prefix, path=path) return cls.load_from_dict(env=env, prefix=prefix)
[docs] @classmethod def load_from_dict(cls, *, env: dict[str, str], prefix: str) -> "ConnectionConfig": """Load config from dictionary.""" values = {} for config_field in fields(cls): key = prefixed_key(prefix=prefix, key=config_field.name) if config_field.name == "host": values[config_field.name] = env.get(key, "127.0.0.1") elif config_field.name == "port": values[config_field.name] = env.get(key, 8022) elif key in env: values[config_field.name] = env[key] elif ( config_field.default is MISSING and config_field.default_factory is MISSING ): raise ConnectionConfigError( f"Connection is not configured: {key!r} is not set." ) return cls(**values)
[docs] def save(self, path: str, prefix: str = ""): """Save config to the given location.""" if not os.path.exists(path): with open(path, "w", encoding="utf-8"): pass saved_keys = dotenv_values(dotenv_path=path) for key, value in self.asdict.items(): env_key = prefixed_key(prefix=prefix, key=key) if value is None: result = env_key not in saved_keys or unset_key(path, env_key)[0] else: result = set_key(path, env_key, str(value))[0] if not result: raise ConnectionConfigError("Error while saving connection config.")