From d72b7ca42d24a9aa3bba12c7761fdc32ef67a6ab Mon Sep 17 00:00:00 2001 From: Axel H Date: Fri, 22 Dec 2023 12:45:25 +0100 Subject: [PATCH] feat(config): allow overriding config from environment (#10) --- README.md | 11 ++++++ src/sync_pre_commit_lock/config.py | 62 ++++++++++++++++++++++++++---- tests/test_config.py | 38 +++++++++++++++++- 3 files changed, 103 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f3e0c63..d6cef26 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,17 @@ dependency-mapping = {"package-name"= {"repo"= "https://github.com/example/packa > Note: the `dependency-mapping` is merged with the default mapping, so you don't need to specify the default mapping if you want to add a new mapping. > Repos urls will be normalized to http(s), with the trailing slash removed. +### From environment + +Some settings are overridable by environment variables with the following `SYNC_PRE_COMMIT_LOCK_*` prefixed environment variables: + +| `toml` setting | environment | format | +| ------------------------------|----------------------------------------|-----------------------------------| +| `automatically-install-hooks` | `SYNC_PRE_COMMIT_LOCK_INSTALL` | `bool` as string (`true`, `1`...) | +| `disable-sync-from-lock` | `SYNC_PRE_COMMIT_LOCK_DISABLED` | `bool` as string (`true`, `1`...) | +| `ignore` | `SYNC_PRE_COMMIT_LOCK_IGNORE` | comma-separated list | +| `pre-commit-config-file` | `SYNC_PRE_COMMIT_LOCK_PRE_COMMIT_FILE` | `str` | + ## Usage Once installed, and optionally configured, the plugin usage should be transparent, and trigger when you run applicable PDM or Poetry commands, like `pdm lock`, or `poetry lock`. diff --git a/src/sync_pre_commit_lock/config.py b/src/sync_pre_commit_lock/config.py index bd8f819..9b0365b 100644 --- a/src/sync_pre_commit_lock/config.py +++ b/src/sync_pre_commit_lock/config.py @@ -1,8 +1,9 @@ from __future__ import annotations +import os from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Callable, TypedDict try: # 3.11+ @@ -16,6 +17,16 @@ pass +ENV_PREFIX = "SYNC_PRE_COMMIT_LOCK" + + +def env_as_bool(value: str) -> bool: + return (value or "False").lower() in ("true", "1") + + +def env_as_list(value: str) -> list[str]: + return [v.strip() for v in (value or "").split(",")] + def from_toml(data: dict[str, Any]) -> SyncPreCommitLockConfig: fields = {f.metadata.get("toml", f.name): f for f in SyncPreCommitLockConfig.__dataclass_fields__.values()} @@ -33,13 +44,50 @@ def from_toml(data: dict[str, Any]) -> SyncPreCommitLockConfig: ) +def update_from_env(config: SyncPreCommitLockConfig) -> SyncPreCommitLockConfig: + vars = { + f.metadata["env"]: f for f in SyncPreCommitLockConfig.__dataclass_fields__.values() if f.metadata.get("env") + } + for var, specs in vars.items(): + if value := os.getenv(f"{ENV_PREFIX}_{var}"): + caster = specs.metadata.get("cast", lambda v: v) + setattr(config, specs.name, caster(value)) + return config + + +class Metadata(TypedDict, total=False): + """Configuration metadata known fields""" + + toml: str + """Map the `toml` field""" + env: str + """Optionnaly map the environment variable suffix""" + cast: Callable[[str], Any] + """Optionnaly provide a cast function for environment variable""" + + @dataclass class SyncPreCommitLockConfig: - automatically_install_hooks: bool = field(default=True, metadata={"toml": "automatically-install-hooks"}) - disable_sync_from_lock: bool = field(default=False, metadata={"toml": "disable-sync-from-lock"}) - ignore: list[str] = field(default_factory=list, metadata={"toml": "ignore"}) - pre_commit_config_file: str = field(metadata={"toml": "pre-commit-config-file"}, default=".pre-commit-config.yaml") - dependency_mapping: PackageRepoMapping = field(default_factory=dict, metadata={"toml": "dependency-mapping"}) + automatically_install_hooks: bool = field( + default=True, + metadata=Metadata(toml="automatically-install-hooks", env="INSTALL", cast=env_as_bool), + ) + disable_sync_from_lock: bool = field( + default=False, + metadata=Metadata(toml="disable-sync-from-lock", env="DISABLED", cast=env_as_bool), + ) + ignore: list[str] = field( + default_factory=list, + metadata=Metadata(toml="ignore", env="IGNORE", cast=env_as_list), + ) + pre_commit_config_file: str = field( + default=".pre-commit-config.yaml", + metadata=Metadata(toml="pre-commit-config-file", env="PRE_COMMIT_FILE"), + ) + dependency_mapping: PackageRepoMapping = field( + default_factory=dict, + metadata=Metadata(toml="dependency-mapping"), + ) def load_config(path: Path | None = None) -> SyncPreCommitLockConfig: @@ -52,4 +100,4 @@ def load_config(path: Path | None = None) -> SyncPreCommitLockConfig: if not tool_dict or len(tool_dict) == 0: return SyncPreCommitLockConfig() - return from_toml(tool_dict) + return update_from_env(from_toml(tool_dict)) diff --git a/tests/test_config.py b/tests/test_config.py index 8c7cbc1..b0dbe3e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, patch -from sync_pre_commit_lock.config import SyncPreCommitLockConfig, from_toml, load_config +import pytest +from sync_pre_commit_lock.config import SyncPreCommitLockConfig, from_toml, load_config, update_from_env from sync_pre_commit_lock.db import RepoInfo @@ -23,6 +24,24 @@ def test_from_toml() -> None: assert actual_config == expected_config +def test_update_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SYNC_PRE_COMMIT_LOCK_DISABLED", "1") + monkeypatch.setenv("SYNC_PRE_COMMIT_LOCK_INSTALL", "false") + monkeypatch.setenv("SYNC_PRE_COMMIT_LOCK_IGNORE", "a, b") + monkeypatch.setenv("SYNC_PRE_COMMIT_LOCK_PRE_COMMIT_FILE", ".test-config.yaml") + expected_config = SyncPreCommitLockConfig( + automatically_install_hooks=False, + disable_sync_from_lock=True, + ignore=["a", "b"], + pre_commit_config_file=".test-config.yaml", + dependency_mapping={}, + ) + + actual_config = update_from_env(SyncPreCommitLockConfig()) + + assert actual_config == expected_config + + def test_sync_pre_commit_lock_config() -> None: config = SyncPreCommitLockConfig( disable_sync_from_lock=True, @@ -63,3 +82,20 @@ def test_load_config_with_data(mock_from_toml: MagicMock, mock_open: MagicMock, mock_path.open.assert_called_once_with("rb") mock_load.assert_called_once() mock_from_toml.assert_called_once_with({"disable": True}) + + +@patch("sync_pre_commit_lock.config.toml.load", return_value={"tool": {"sync-pre-commit-lock": {"ignore": ["fake"]}}}) +@patch("builtins.open", new_callable=MagicMock) +def test_env_override_config(mock_open: MagicMock, mock_load: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SYNC_PRE_COMMIT_LOCK_DISABLED", "true") + monkeypatch.setenv("SYNC_PRE_COMMIT_LOCK_IGNORE", "a, b") + expected_config = SyncPreCommitLockConfig( + disable_sync_from_lock=True, + ignore=["a", "b"], + ) + mock_path = MagicMock() + mock_path.open = mock_open(read_data="dummy_stream") + actual_config = load_config(mock_path) + + assert actual_config == expected_config + mock_path.open.assert_called_once_with("rb")