Files

1 line
64 KiB
JSON

{"repo": "casper-mars/commit-hook", "files": {"tests/test_config.py": {"53e04db606144ad4026d0f978d7d94689c84a760": {"t": 1785746883.5218263, "n": 12394, "h": "c1c301ae048cf0fa", "c": "\"\"\"Tests for config module.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport warnings\nfrom collections.abc import Callable\nfrom pathlib import Path\nfrom typing import Any\n\nimport pytest\nimport yaml\n\nfrom commit_hook.config import (\n _CFG,\n _DEF_EXCLUDE,\n _DEF_FORBID_PATTERNS,\n _DEF_MAX_LINES,\n _DEF_MIN_LENGTH,\n _DEF_MODEL,\n _DEF_PROVIDER,\n Config,\n ConfigError,\n LLMConfig,\n load_config,\n)\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef temp_dir(tmp_path: Path) -> Path:\n \"\"\"Isolated temporary directory that serves as the repo root.\"\"\"\n return tmp_path\n\n\n@pytest.fixture\ndef use_config(temp_dir: Path) -> Callable[[dict[str, Any]], None]:\n \"\"\"Write a .commit-hook.yaml in *temp_dir* and chdir there.\"\"\"\n\n def _write(content: dict[str, Any]) -> None:\n (temp_dir / _CFG).write_text(yaml.dump(content), encoding=\"utf-8\")\n os.chdir(temp_dir)\n\n return _write\n\n\n# ---------------------------------------------------------------------------\n# Default values\n# ---------------------------------------------------------------------------\n\n\ndef test_defaults_when_no_config(temp_dir: Path) -> None:\n \"\"\"Return built-in defaults when no config file exists.\"\"\"\n os.chdir(temp_dir)\n cfg = load_config()\n assert cfg.llm.provider == _DEF_PROVIDER\n assert cfg.llm.model == _DEF_MODEL\n assert cfg.llm.api_key == \"\"\n assert cfg.llm.api_key_env == \"\"\n assert cfg.rules.min_length == _DEF_MIN_LENGTH\n assert cfg.rules.forbid_patterns == _DEF_FORBID_PATTERNS\n assert cfg.diff.exclude == _DEF_EXCLUDE\n assert cfg.diff.max_lines == _DEF_MAX_LINES\n\n\ndef test_default_dataclass_constructors() -> None:\n \"\"\"Dataclass constructors yield defaults.\"\"\"\n cfg = Config()\n assert cfg.llm.provider == _DEF_PROVIDER\n assert cfg.rules.min_length == _DEF_MIN_LENGTH\n assert cfg.rules.forbid_patterns == _DEF_FORBID_PATTERNS\n assert cfg.diff.exclude == _DEF_EXCLUDE\n assert cfg.diff.max_lines == _DEF_MAX_LINES\n\n\n# ---------------------------------------------------------------------------\n# Full / partial config\n# ---------------------------------------------------------------------------\n\n\ndef test_full_config(use_config: Callable[[dict[str, Any]], None]) -> None:\n \"\"\"All fields are loaded from a complete config file.\"\"\"\n use_config(\n {\n \"llm\": {\n \"provider\": \"anthropic\",\n \"model\": \"claude-3-opus\",\n \"api_key_env\": \"ANTHROPIC_API_KEY\",\n },\n \"rules\": {\n \"min_length\": 20,\n \"forbid_patterns\": [\"^tmp$\", \"^wip$\"],\n },\n \"diff\": {\"exclude\": [\"*.lock\", \"*.min.js\"], \"max_lines\": 300},\n }\n )\n with pytest.MonkeyPatch.context() as mp:\n mp.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-secret\")\n cfg = load_config()\n assert cfg.llm.provider == \"anthropic\"\n assert cfg.llm.model == \"claude-3-opus\"\n assert cfg.llm.api_key == \"sk-ant-secret\"\n assert cfg.llm.api_key_env == \"ANTHROPIC_API_KEY\"\n assert cfg.rules.min_length == 20\n assert cfg.rules.forbid_patterns == [\"^tmp$\", \"^wip$\"]\n assert cfg.diff.exclude == [\"*.lock\", \"*.min.js\"]\n assert cfg.diff.max_lines == 300\n\n\ndef test_partial_config_falls_back_to_defaults(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"Missing fields get defaults.\"\"\"\n use_config({\"llm\": {\"provider\": \"gemini\"}})\n cfg = load_config()\n assert cfg.llm.provider == \"gemini\"\n assert cfg.llm.model == _DEF_MODEL\n assert cfg.llm.api_key_env == \"\"\n assert cfg.rules.min_length == _DEF_MIN_LENGTH\n assert cfg.rules.forbid_patterns == _DEF_FORBID_PATTERNS\n\n\ndef test_empty_config_file(temp_dir: Path) -> None:\n \"\"\"An empty YAML file is treated as all defaults.\"\"\"\n (temp_dir / _CFG).write_text(\"\", encoding=\"utf-8\")\n os.chdir(temp_dir)\n cfg = load_config()\n assert cfg.llm.provider == _DEF_PROVIDER\n\n\n# ---------------------------------------------------------------------------\n# Upward search\n# ---------------------------------------------------------------------------\n\n\ndef test_upward_search(temp_dir: Path) -> None:\n \"\"\"Config is found by walking up from a subdirectory.\"\"\"\n config_path = temp_dir / _CFG\n config_path.write_text(yaml.dump({\"llm\": {\"provider\": \"deepseek\"}}), encoding=\"utf-8\")\n sub = temp_dir / \"a\" / \"b\"\n sub.mkdir(parents=True)\n os.chdir(sub)\n cfg = load_config()\n assert cfg.llm.provider == \"deepseek\"\n\n\ndef test_upward_search_stops_at_root(temp_dir: Path) -> None:\n \"\"\"Search stops at filesystem root; defaults are returned.\"\"\"\n sub = temp_dir / \"x\" / \"y\"\n sub.mkdir(parents=True)\n os.chdir(sub)\n cfg = load_config()\n assert cfg.llm.provider == _DEF_PROVIDER\n\n\ndef test_explicit_file_path(temp_dir: Path) -> None:\n \"\"\"load_config with an explicit file path loads that config.\"\"\"\n p = temp_dir / \"custom.yaml\"\n p.write_text(yaml.dump({\"diff\": {\"max_lines\": 100}}), encoding=\"utf-8\")\n cfg = load_config(str(p))\n assert cfg.diff.max_lines == 100\n\n\ndef test_explicit_dir_path(temp_dir: Path) -> None:\n \"\"\"load_config with a directory path searches upward from there.\"\"\"\n (temp_dir / _CFG).write_text(yaml.dump({\"diff\": {\"max_lines\": 200}}), encoding=\"utf-8\")\n cfg = load_config(str(temp_dir))\n assert cfg.diff.max_lines == 200\n\n\n# ---------------------------------------------------------------------------\n# API key resolution\n# ---------------------------------------------------------------------------\n\n\ndef test_api_key_env_success(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"api_key is resolved from the environment variable.\"\"\"\n use_config({\"llm\": {\"api_key_env\": \"TEST_API_KEY\"}})\n with pytest.MonkeyPatch.context() as mp:\n mp.setenv(\"TEST_API_KEY\", \"sk-test-123\")\n cfg = load_config()\n assert cfg.llm.api_key == \"sk-test-123\"\n\n\ndef test_api_key_env_missing_raises(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"ConfigError is raised when the referenced env var is absent.\"\"\"\n use_config({\"llm\": {\"api_key_env\": \"MISSING_KEY\"}})\n with pytest.raises(ConfigError, match=\"MISSING_KEY\"):\n load_config()\n\n\ndef test_api_key_env_empty_no_error(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"When api_key_env is empty, no env lookup occurs.\"\"\"\n use_config({\"llm\": {\"provider\": \"openai\"}})\n cfg = load_config()\n assert cfg.llm.api_key == \"\"\n\n\n# ---------------------------------------------------------------------------\n# Unknown key warnings\n# ---------------------------------------------------------------------------\n\n\ndef test_unknown_top_key_warns(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"Unknown top-level key emits UserWarning.\"\"\"\n use_config({\"llm\": {\"provider\": \"openai\"}, \"unknown_section\": {\"x\": 1}})\n with warnings.catch_warnings(record=True) as w:\n warnings.simplefilter(\"always\")\n cfg = load_config()\n assert cfg.llm.provider == \"openai\"\n warn_msgs = [str(x.message) for x in w]\n assert any(\"unknown_section\" in m for m in warn_msgs)\n\n\ndef test_unknown_sub_field_warns(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"Unknown field inside a known section emits UserWarning.\"\"\"\n use_config({\"llm\": {\"provider\": \"openai\", \"temperature\": 0.7}})\n with warnings.catch_warnings(record=True) as w:\n warnings.simplefilter(\"always\")\n cfg = load_config()\n assert cfg.llm.provider == \"openai\"\n warn_msgs = [str(x.message) for x in w]\n assert any(\"temperature\" in m for m in warn_msgs)\n\n\n# ---------------------------------------------------------------------------\n# Type validation errors\n# ---------------------------------------------------------------------------\n\n\ndef test_provider_not_string(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"llm\": {\"provider\": 123}})\n with pytest.raises(ConfigError, match=\"'provider' must be a string\"):\n load_config()\n\n\ndef test_model_not_string(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"llm\": {\"model\": True}})\n with pytest.raises(ConfigError, match=\"'model' must be a string\"):\n load_config()\n\n\ndef test_min_length_not_int(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"rules\": {\"min_length\": \"long\"}})\n with pytest.raises(ConfigError, match=\"'min_length' must be an integer\"):\n load_config()\n\n\ndef test_min_length_bool_rejected(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"bool is rejected even though it is an int subclass.\"\"\"\n use_config({\"rules\": {\"min_length\": True}})\n with pytest.raises(ConfigError, match=\"'min_length' must be an integer\"):\n load_config()\n\n\ndef test_forbid_patterns_not_list(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"rules\": {\"forbid_patterns\": \"^fix$\"}})\n with pytest.raises(ConfigError, match=\"'forbid_patterns' must be a list\"):\n load_config()\n\n\ndef test_forbid_patterns_item_not_str(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"rules\": {\"forbid_patterns\": [\"^fix$\", 1, \"^wip$\"]}})\n with pytest.raises(ConfigError, match=r\"forbid_patterns\\[1\\].*string\"):\n load_config()\n\n\ndef test_max_lines_not_int(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"diff\": {\"max_lines\": \"three\"}})\n with pytest.raises(ConfigError, match=\"'max_lines' must be an integer\"):\n load_config()\n\n\ndef test_exclude_not_list(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"diff\": {\"exclude\": \"*.lock\"}})\n with pytest.raises(ConfigError, match=\"'exclude' must be a list\"):\n load_config()\n\n\ndef test_exclude_item_not_str(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"diff\": {\"exclude\": [\"*.lock\", 123]}})\n with pytest.raises(ConfigError, match=r\"exclude\\[1\\].*string\"):\n load_config()\n\n\n# ---------------------------------------------------------------------------\n# YAML parse errors\n# ---------------------------------------------------------------------------\n\n\ndef test_invalid_yaml(temp_dir: Path) -> None:\n \"\"\"ConfigError is raised for unparseable YAML.\"\"\"\n p = temp_dir / _CFG\n p.write_text(\"llm: [unclosed\\n\", encoding=\"utf-8\")\n with pytest.raises(ConfigError, match=\"Failed to parse YAML\"):\n load_config(str(p))\n\n\ndef test_yaml_not_mapping(temp_dir: Path) -> None:\n \"\"\"ConfigError is raised when YAML root is not a mapping.\"\"\"\n p = temp_dir / _CFG\n p.write_text(\"- item1\\n- item2\\n\", encoding=\"utf-8\")\n with pytest.raises(ConfigError, match=\"YAML mapping\"):\n load_config(str(p))\n\n\n# ---------------------------------------------------------------------------\n# Config dataclass immutability of defaults\n# ---------------------------------------------------------------------------\n\n\ndef test_default_forbid_patterns_are_independent() -> None:\n \"\"\"Modifying one config's forbid_patterns does not affect another.\"\"\"\n cfg1 = Config()\n cfg2 = Config()\n cfg1.rules.forbid_patterns.append(\"^tmp$\")\n assert \"^tmp$\" not in cfg2.rules.forbid_patterns\n assert len(cfg2.rules.forbid_patterns) == len(_DEF_FORBID_PATTERNS)\n\n\ndef test_api_key_env_not_set_means_no_resolution() -> None:\n \"\"\"When api_key_env is empty string, no env resolution occurs.\"\"\"\n cfg = Config(llm=LLMConfig(provider=\"openai\", api_key_env=\"\"))\n assert cfg.llm.api_key == \"\"\n\n\ndef test_api_base_default_empty() -> None:\n \"\"\"api_base default value is empty string.\"\"\"\n cfg = LLMConfig()\n assert cfg.api_base == \"\"\n\n full_cfg = Config()\n assert full_cfg.llm.api_base == \"\"\n\n\ndef test_api_base_from_config(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"api_base is loaded correctly from YAML config.\"\"\"\n use_config(\n {\n \"llm\": {\n \"provider\": \"ollama\",\n \"model\": \"llama3\",\n \"api_base\": \"http://localhost:11434\",\n },\n }\n )\n cfg = load_config()\n assert cfg.llm.api_base == \"http://localhost:11434\"\n"}, "a8c3f9f09e483db9fb3ddcdb8dd225d958766a55": {"t": 1785768464.9910672, "n": 12394, "h": "c1c301ae048cf0fa", "c": "\"\"\"Tests for config module.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport warnings\nfrom collections.abc import Callable\nfrom pathlib import Path\nfrom typing import Any\n\nimport pytest\nimport yaml\n\nfrom commit_hook.config import (\n _CFG,\n _DEF_EXCLUDE,\n _DEF_FORBID_PATTERNS,\n _DEF_MAX_LINES,\n _DEF_MIN_LENGTH,\n _DEF_MODEL,\n _DEF_PROVIDER,\n Config,\n ConfigError,\n LLMConfig,\n load_config,\n)\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef temp_dir(tmp_path: Path) -> Path:\n \"\"\"Isolated temporary directory that serves as the repo root.\"\"\"\n return tmp_path\n\n\n@pytest.fixture\ndef use_config(temp_dir: Path) -> Callable[[dict[str, Any]], None]:\n \"\"\"Write a .commit-hook.yaml in *temp_dir* and chdir there.\"\"\"\n\n def _write(content: dict[str, Any]) -> None:\n (temp_dir / _CFG).write_text(yaml.dump(content), encoding=\"utf-8\")\n os.chdir(temp_dir)\n\n return _write\n\n\n# ---------------------------------------------------------------------------\n# Default values\n# ---------------------------------------------------------------------------\n\n\ndef test_defaults_when_no_config(temp_dir: Path) -> None:\n \"\"\"Return built-in defaults when no config file exists.\"\"\"\n os.chdir(temp_dir)\n cfg = load_config()\n assert cfg.llm.provider == _DEF_PROVIDER\n assert cfg.llm.model == _DEF_MODEL\n assert cfg.llm.api_key == \"\"\n assert cfg.llm.api_key_env == \"\"\n assert cfg.rules.min_length == _DEF_MIN_LENGTH\n assert cfg.rules.forbid_patterns == _DEF_FORBID_PATTERNS\n assert cfg.diff.exclude == _DEF_EXCLUDE\n assert cfg.diff.max_lines == _DEF_MAX_LINES\n\n\ndef test_default_dataclass_constructors() -> None:\n \"\"\"Dataclass constructors yield defaults.\"\"\"\n cfg = Config()\n assert cfg.llm.provider == _DEF_PROVIDER\n assert cfg.rules.min_length == _DEF_MIN_LENGTH\n assert cfg.rules.forbid_patterns == _DEF_FORBID_PATTERNS\n assert cfg.diff.exclude == _DEF_EXCLUDE\n assert cfg.diff.max_lines == _DEF_MAX_LINES\n\n\n# ---------------------------------------------------------------------------\n# Full / partial config\n# ---------------------------------------------------------------------------\n\n\ndef test_full_config(use_config: Callable[[dict[str, Any]], None]) -> None:\n \"\"\"All fields are loaded from a complete config file.\"\"\"\n use_config(\n {\n \"llm\": {\n \"provider\": \"anthropic\",\n \"model\": \"claude-3-opus\",\n \"api_key_env\": \"ANTHROPIC_API_KEY\",\n },\n \"rules\": {\n \"min_length\": 20,\n \"forbid_patterns\": [\"^tmp$\", \"^wip$\"],\n },\n \"diff\": {\"exclude\": [\"*.lock\", \"*.min.js\"], \"max_lines\": 300},\n }\n )\n with pytest.MonkeyPatch.context() as mp:\n mp.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-secret\")\n cfg = load_config()\n assert cfg.llm.provider == \"anthropic\"\n assert cfg.llm.model == \"claude-3-opus\"\n assert cfg.llm.api_key == \"sk-ant-secret\"\n assert cfg.llm.api_key_env == \"ANTHROPIC_API_KEY\"\n assert cfg.rules.min_length == 20\n assert cfg.rules.forbid_patterns == [\"^tmp$\", \"^wip$\"]\n assert cfg.diff.exclude == [\"*.lock\", \"*.min.js\"]\n assert cfg.diff.max_lines == 300\n\n\ndef test_partial_config_falls_back_to_defaults(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"Missing fields get defaults.\"\"\"\n use_config({\"llm\": {\"provider\": \"gemini\"}})\n cfg = load_config()\n assert cfg.llm.provider == \"gemini\"\n assert cfg.llm.model == _DEF_MODEL\n assert cfg.llm.api_key_env == \"\"\n assert cfg.rules.min_length == _DEF_MIN_LENGTH\n assert cfg.rules.forbid_patterns == _DEF_FORBID_PATTERNS\n\n\ndef test_empty_config_file(temp_dir: Path) -> None:\n \"\"\"An empty YAML file is treated as all defaults.\"\"\"\n (temp_dir / _CFG).write_text(\"\", encoding=\"utf-8\")\n os.chdir(temp_dir)\n cfg = load_config()\n assert cfg.llm.provider == _DEF_PROVIDER\n\n\n# ---------------------------------------------------------------------------\n# Upward search\n# ---------------------------------------------------------------------------\n\n\ndef test_upward_search(temp_dir: Path) -> None:\n \"\"\"Config is found by walking up from a subdirectory.\"\"\"\n config_path = temp_dir / _CFG\n config_path.write_text(yaml.dump({\"llm\": {\"provider\": \"deepseek\"}}), encoding=\"utf-8\")\n sub = temp_dir / \"a\" / \"b\"\n sub.mkdir(parents=True)\n os.chdir(sub)\n cfg = load_config()\n assert cfg.llm.provider == \"deepseek\"\n\n\ndef test_upward_search_stops_at_root(temp_dir: Path) -> None:\n \"\"\"Search stops at filesystem root; defaults are returned.\"\"\"\n sub = temp_dir / \"x\" / \"y\"\n sub.mkdir(parents=True)\n os.chdir(sub)\n cfg = load_config()\n assert cfg.llm.provider == _DEF_PROVIDER\n\n\ndef test_explicit_file_path(temp_dir: Path) -> None:\n \"\"\"load_config with an explicit file path loads that config.\"\"\"\n p = temp_dir / \"custom.yaml\"\n p.write_text(yaml.dump({\"diff\": {\"max_lines\": 100}}), encoding=\"utf-8\")\n cfg = load_config(str(p))\n assert cfg.diff.max_lines == 100\n\n\ndef test_explicit_dir_path(temp_dir: Path) -> None:\n \"\"\"load_config with a directory path searches upward from there.\"\"\"\n (temp_dir / _CFG).write_text(yaml.dump({\"diff\": {\"max_lines\": 200}}), encoding=\"utf-8\")\n cfg = load_config(str(temp_dir))\n assert cfg.diff.max_lines == 200\n\n\n# ---------------------------------------------------------------------------\n# API key resolution\n# ---------------------------------------------------------------------------\n\n\ndef test_api_key_env_success(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"api_key is resolved from the environment variable.\"\"\"\n use_config({\"llm\": {\"api_key_env\": \"TEST_API_KEY\"}})\n with pytest.MonkeyPatch.context() as mp:\n mp.setenv(\"TEST_API_KEY\", \"sk-test-123\")\n cfg = load_config()\n assert cfg.llm.api_key == \"sk-test-123\"\n\n\ndef test_api_key_env_missing_raises(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"ConfigError is raised when the referenced env var is absent.\"\"\"\n use_config({\"llm\": {\"api_key_env\": \"MISSING_KEY\"}})\n with pytest.raises(ConfigError, match=\"MISSING_KEY\"):\n load_config()\n\n\ndef test_api_key_env_empty_no_error(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"When api_key_env is empty, no env lookup occurs.\"\"\"\n use_config({\"llm\": {\"provider\": \"openai\"}})\n cfg = load_config()\n assert cfg.llm.api_key == \"\"\n\n\n# ---------------------------------------------------------------------------\n# Unknown key warnings\n# ---------------------------------------------------------------------------\n\n\ndef test_unknown_top_key_warns(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"Unknown top-level key emits UserWarning.\"\"\"\n use_config({\"llm\": {\"provider\": \"openai\"}, \"unknown_section\": {\"x\": 1}})\n with warnings.catch_warnings(record=True) as w:\n warnings.simplefilter(\"always\")\n cfg = load_config()\n assert cfg.llm.provider == \"openai\"\n warn_msgs = [str(x.message) for x in w]\n assert any(\"unknown_section\" in m for m in warn_msgs)\n\n\ndef test_unknown_sub_field_warns(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"Unknown field inside a known section emits UserWarning.\"\"\"\n use_config({\"llm\": {\"provider\": \"openai\", \"temperature\": 0.7}})\n with warnings.catch_warnings(record=True) as w:\n warnings.simplefilter(\"always\")\n cfg = load_config()\n assert cfg.llm.provider == \"openai\"\n warn_msgs = [str(x.message) for x in w]\n assert any(\"temperature\" in m for m in warn_msgs)\n\n\n# ---------------------------------------------------------------------------\n# Type validation errors\n# ---------------------------------------------------------------------------\n\n\ndef test_provider_not_string(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"llm\": {\"provider\": 123}})\n with pytest.raises(ConfigError, match=\"'provider' must be a string\"):\n load_config()\n\n\ndef test_model_not_string(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"llm\": {\"model\": True}})\n with pytest.raises(ConfigError, match=\"'model' must be a string\"):\n load_config()\n\n\ndef test_min_length_not_int(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"rules\": {\"min_length\": \"long\"}})\n with pytest.raises(ConfigError, match=\"'min_length' must be an integer\"):\n load_config()\n\n\ndef test_min_length_bool_rejected(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"bool is rejected even though it is an int subclass.\"\"\"\n use_config({\"rules\": {\"min_length\": True}})\n with pytest.raises(ConfigError, match=\"'min_length' must be an integer\"):\n load_config()\n\n\ndef test_forbid_patterns_not_list(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"rules\": {\"forbid_patterns\": \"^fix$\"}})\n with pytest.raises(ConfigError, match=\"'forbid_patterns' must be a list\"):\n load_config()\n\n\ndef test_forbid_patterns_item_not_str(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"rules\": {\"forbid_patterns\": [\"^fix$\", 1, \"^wip$\"]}})\n with pytest.raises(ConfigError, match=r\"forbid_patterns\\[1\\].*string\"):\n load_config()\n\n\ndef test_max_lines_not_int(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"diff\": {\"max_lines\": \"three\"}})\n with pytest.raises(ConfigError, match=\"'max_lines' must be an integer\"):\n load_config()\n\n\ndef test_exclude_not_list(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"diff\": {\"exclude\": \"*.lock\"}})\n with pytest.raises(ConfigError, match=\"'exclude' must be a list\"):\n load_config()\n\n\ndef test_exclude_item_not_str(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"diff\": {\"exclude\": [\"*.lock\", 123]}})\n with pytest.raises(ConfigError, match=r\"exclude\\[1\\].*string\"):\n load_config()\n\n\n# ---------------------------------------------------------------------------\n# YAML parse errors\n# ---------------------------------------------------------------------------\n\n\ndef test_invalid_yaml(temp_dir: Path) -> None:\n \"\"\"ConfigError is raised for unparseable YAML.\"\"\"\n p = temp_dir / _CFG\n p.write_text(\"llm: [unclosed\\n\", encoding=\"utf-8\")\n with pytest.raises(ConfigError, match=\"Failed to parse YAML\"):\n load_config(str(p))\n\n\ndef test_yaml_not_mapping(temp_dir: Path) -> None:\n \"\"\"ConfigError is raised when YAML root is not a mapping.\"\"\"\n p = temp_dir / _CFG\n p.write_text(\"- item1\\n- item2\\n\", encoding=\"utf-8\")\n with pytest.raises(ConfigError, match=\"YAML mapping\"):\n load_config(str(p))\n\n\n# ---------------------------------------------------------------------------\n# Config dataclass immutability of defaults\n# ---------------------------------------------------------------------------\n\n\ndef test_default_forbid_patterns_are_independent() -> None:\n \"\"\"Modifying one config's forbid_patterns does not affect another.\"\"\"\n cfg1 = Config()\n cfg2 = Config()\n cfg1.rules.forbid_patterns.append(\"^tmp$\")\n assert \"^tmp$\" not in cfg2.rules.forbid_patterns\n assert len(cfg2.rules.forbid_patterns) == len(_DEF_FORBID_PATTERNS)\n\n\ndef test_api_key_env_not_set_means_no_resolution() -> None:\n \"\"\"When api_key_env is empty string, no env resolution occurs.\"\"\"\n cfg = Config(llm=LLMConfig(provider=\"openai\", api_key_env=\"\"))\n assert cfg.llm.api_key == \"\"\n\n\ndef test_api_base_default_empty() -> None:\n \"\"\"api_base default value is empty string.\"\"\"\n cfg = LLMConfig()\n assert cfg.api_base == \"\"\n\n full_cfg = Config()\n assert full_cfg.llm.api_base == \"\"\n\n\ndef test_api_base_from_config(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"api_base is loaded correctly from YAML config.\"\"\"\n use_config(\n {\n \"llm\": {\n \"provider\": \"ollama\",\n \"model\": \"llama3\",\n \"api_base\": \"http://localhost:11434\",\n },\n }\n )\n cfg = load_config()\n assert cfg.llm.api_base == \"http://localhost:11434\"\n"}, "f99df6fcef6791fb73761db78529367d2c365133": {"t": 1785768466.4579473, "n": 11747, "h": "867e9bece616e453", "c": "\"\"\"Tests for config module.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport warnings\nfrom collections.abc import Callable\nfrom pathlib import Path\nfrom typing import Any\n\nimport pytest\nimport yaml\n\nfrom commit_hook.config import (\n _CFG,\n _DEF_EXCLUDE,\n _DEF_FORBID_PATTERNS,\n _DEF_MAX_LINES,\n _DEF_MIN_LENGTH,\n _DEF_MODEL,\n _DEF_PROVIDER,\n Config,\n ConfigError,\n LLMConfig,\n load_config,\n)\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef temp_dir(tmp_path: Path) -> Path:\n \"\"\"Isolated temporary directory that serves as the repo root.\"\"\"\n return tmp_path\n\n\n@pytest.fixture\ndef use_config(temp_dir: Path) -> Callable[[dict[str, Any]], None]:\n \"\"\"Write a .commit-hook.yaml in *temp_dir* and chdir there.\"\"\"\n\n def _write(content: dict[str, Any]) -> None:\n (temp_dir / _CFG).write_text(yaml.dump(content), encoding=\"utf-8\")\n os.chdir(temp_dir)\n\n return _write\n\n\n# ---------------------------------------------------------------------------\n# Default values\n# ---------------------------------------------------------------------------\n\n\ndef test_defaults_when_no_config(temp_dir: Path) -> None:\n \"\"\"Return built-in defaults when no config file exists.\"\"\"\n os.chdir(temp_dir)\n cfg = load_config()\n assert cfg.llm.provider == _DEF_PROVIDER\n assert cfg.llm.model == _DEF_MODEL\n assert cfg.llm.api_key == \"\"\n assert cfg.llm.api_key_env == \"\"\n assert cfg.rules.min_length == _DEF_MIN_LENGTH\n assert cfg.rules.forbid_patterns == _DEF_FORBID_PATTERNS\n assert cfg.diff.exclude == _DEF_EXCLUDE\n assert cfg.diff.max_lines == _DEF_MAX_LINES\n\n\ndef test_default_dataclass_constructors() -> None:\n \"\"\"Dataclass constructors yield defaults.\"\"\"\n cfg = Config()\n assert cfg.llm.provider == _DEF_PROVIDER\n assert cfg.rules.min_length == _DEF_MIN_LENGTH\n assert cfg.rules.forbid_patterns == _DEF_FORBID_PATTERNS\n assert cfg.diff.exclude == _DEF_EXCLUDE\n assert cfg.diff.max_lines == _DEF_MAX_LINES\n\n\n# ---------------------------------------------------------------------------\n# Full / partial config\n# ---------------------------------------------------------------------------\n\n\ndef test_full_config(use_config: Callable[[dict[str, Any]], None]) -> None:\n \"\"\"All fields are loaded from a complete config file.\"\"\"\n use_config(\n {\n \"llm\": {\n \"provider\": \"anthropic\",\n \"model\": \"claude-3-opus\",\n \"api_key_env\": \"ANTHROPIC_API_KEY\",\n },\n \"rules\": {\n \"min_length\": 20,\n \"forbid_patterns\": [\"^tmp$\", \"^wip$\"],\n },\n \"diff\": {\"exclude\": [\"*.lock\", \"*.min.js\"], \"max_lines\": 300},\n }\n )\n with pytest.MonkeyPatch.context() as mp:\n mp.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-secret\")\n cfg = load_config()\n assert cfg.llm.provider == \"anthropic\"\n assert cfg.llm.model == \"claude-3-opus\"\n assert cfg.llm.api_key == \"sk-ant-secret\"\n assert cfg.llm.api_key_env == \"ANTHROPIC_API_KEY\"\n assert cfg.rules.min_length == 20\n assert cfg.rules.forbid_patterns == [\"^tmp$\", \"^wip$\"]\n assert cfg.diff.exclude == [\"*.lock\", \"*.min.js\"]\n assert cfg.diff.max_lines == 300\n\n\ndef test_partial_config_falls_back_to_defaults(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"Missing fields get defaults.\"\"\"\n use_config({\"llm\": {\"provider\": \"gemini\"}})\n cfg = load_config()\n assert cfg.llm.provider == \"gemini\"\n assert cfg.llm.model == _DEF_MODEL\n assert cfg.llm.api_key_env == \"\"\n assert cfg.rules.min_length == _DEF_MIN_LENGTH\n assert cfg.rules.forbid_patterns == _DEF_FORBID_PATTERNS\n\n\ndef test_empty_config_file(temp_dir: Path) -> None:\n \"\"\"An empty YAML file is treated as all defaults.\"\"\"\n (temp_dir / _CFG).write_text(\"\", encoding=\"utf-8\")\n os.chdir(temp_dir)\n cfg = load_config()\n assert cfg.llm.provider == _DEF_PROVIDER\n\n\n# ---------------------------------------------------------------------------\n# Upward search\n# ---------------------------------------------------------------------------\n\n\ndef test_upward_search(temp_dir: Path) -> None:\n \"\"\"Config is found by walking up from a subdirectory.\"\"\"\n config_path = temp_dir / _CFG\n config_path.write_text(yaml.dump({\"llm\": {\"provider\": \"deepseek\"}}), encoding=\"utf-8\")\n sub = temp_dir / \"a\" / \"b\"\n sub.mkdir(parents=True)\n os.chdir(sub)\n cfg = load_config()\n assert cfg.llm.provider == \"deepseek\"\n\n\ndef test_upward_search_stops_at_root(temp_dir: Path) -> None:\n \"\"\"Search stops at filesystem root; defaults are returned.\"\"\"\n sub = temp_dir / \"x\" / \"y\"\n sub.mkdir(parents=True)\n os.chdir(sub)\n cfg = load_config()\n assert cfg.llm.provider == _DEF_PROVIDER\n\n\ndef test_explicit_file_path(temp_dir: Path) -> None:\n \"\"\"load_config with an explicit file path loads that config.\"\"\"\n p = temp_dir / \"custom.yaml\"\n p.write_text(yaml.dump({\"diff\": {\"max_lines\": 100}}), encoding=\"utf-8\")\n cfg = load_config(str(p))\n assert cfg.diff.max_lines == 100\n\n\ndef test_explicit_dir_path(temp_dir: Path) -> None:\n \"\"\"load_config with a directory path searches upward from there.\"\"\"\n (temp_dir / _CFG).write_text(yaml.dump({\"diff\": {\"max_lines\": 200}}), encoding=\"utf-8\")\n cfg = load_config(str(temp_dir))\n assert cfg.diff.max_lines == 200\n\n\n# ---------------------------------------------------------------------------\n# API key resolution\n# ---------------------------------------------------------------------------\n\n\ndef test_api_key_env_success(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"api_key is resolved from the environment variable.\"\"\"\n use_config({\"llm\": {\"api_key_env\": \"TEST_API_KEY\"}})\n with pytest.MonkeyPatch.context() as mp:\n mp.setenv(\"TEST_API_KEY\", \"sk-test-123\")\n cfg = load_config()\n assert cfg.llm.api_key == \"sk-test-123\"\n\n\ndef test_api_key_env_missing_raises(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"ConfigError is raised when the referenced env var is absent.\"\"\"\n use_config({\"llm\": {\"api_key_env\": \"MISSING_KEY\"}})\n with pytest.raises(ConfigError, match=\"MISSING_KEY\"):\n load_config()\n\n\ndef test_api_key_env_empty_no_error(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"When api_key_env is empty, no env lookup occurs.\"\"\"\n use_config({\"llm\": {\"provider\": \"openai\"}})\n cfg = load_config()\n assert cfg.llm.api_key == \"\"\n\n\n# ---------------------------------------------------------------------------\n# Unknown key warnings\n# ---------------------------------------------------------------------------\n\n\ndef test_unknown_top_key_warns(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"Unknown top-level key emits UserWarning.\"\"\"\n use_config({\"llm\": {\"provider\": \"openai\"}, \"unknown_section\": {\"x\": 1}})\n with warnings.catch_warnings(record=True) as w:\n warnings.simplefilter(\"always\")\n cfg = load_config()\n assert cfg.llm.provider == \"openai\"\n warn_msgs = [str(x.message) for x in w]\n assert any(\"unknown_section\" in m for m in warn_msgs)\n\n\ndef test_unknown_sub_field_warns(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"Unknown field inside a known section emits UserWarning.\"\"\"\n use_config({\"llm\": {\"provider\": \"openai\", \"temperature\": 0.7}})\n with warnings.catch_warnings(record=True) as w:\n warnings.simplefilter(\"always\")\n cfg = load_config()\n assert cfg.llm.provider == \"openai\"\n warn_msgs = [str(x.message) for x in w]\n assert any(\"temperature\" in m for m in warn_msgs)\n\n\n# ---------------------------------------------------------------------------\n# Type validation errors\n# ---------------------------------------------------------------------------\n\n\ndef test_provider_not_string(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"llm\": {\"provider\": 123}})\n with pytest.raises(ConfigError, match=\"'provider' must be a string\"):\n load_config()\n\n\ndef test_model_not_string(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"llm\": {\"model\": True}})\n with pytest.raises(ConfigError, match=\"'model' must be a string\"):\n load_config()\n\n\ndef test_min_length_not_int(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"rules\": {\"min_length\": \"long\"}})\n with pytest.raises(ConfigError, match=\"'min_length' must be an integer\"):\n load_config()\n\n\ndef test_min_length_bool_rejected(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"bool is rejected even though it is an int subclass.\"\"\"\n use_config({\"rules\": {\"min_length\": True}})\n with pytest.raises(ConfigError, match=\"'min_length' must be an integer\"):\n load_config()\n\n\ndef test_forbid_patterns_not_list(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"rules\": {\"forbid_patterns\": \"^fix$\"}})\n with pytest.raises(ConfigError, match=\"'forbid_patterns' must be a list\"):\n load_config()\n\n\ndef test_forbid_patterns_item_not_str(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"rules\": {\"forbid_patterns\": [\"^fix$\", 1, \"^wip$\"]}})\n with pytest.raises(ConfigError, match=r\"forbid_patterns\\[1\\].*string\"):\n load_config()\n\n\ndef test_max_lines_not_int(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"diff\": {\"max_lines\": \"three\"}})\n with pytest.raises(ConfigError, match=\"'max_lines' must be an integer\"):\n load_config()\n\n\ndef test_exclude_not_list(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"diff\": {\"exclude\": \"*.lock\"}})\n with pytest.raises(ConfigError, match=\"'exclude' must be a list\"):\n load_config()\n\n\ndef test_exclude_item_not_str(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"diff\": {\"exclude\": [\"*.lock\", 123]}})\n with pytest.raises(ConfigError, match=r\"exclude\\[1\\].*string\"):\n load_config()\n\n\n# ---------------------------------------------------------------------------\n# YAML parse errors\n# ---------------------------------------------------------------------------\n\n\ndef test_invalid_yaml(temp_dir: Path) -> None:\n \"\"\"ConfigError is raised for unparseable YAML.\"\"\"\n p = temp_dir / _CFG\n p.write_text(\"llm: [unclosed\\n\", encoding=\"utf-8\")\n with pytest.raises(ConfigError, match=\"Failed to parse YAML\"):\n load_config(str(p))\n\n\ndef test_yaml_not_mapping(temp_dir: Path) -> None:\n \"\"\"ConfigError is raised when YAML root is not a mapping.\"\"\"\n p = temp_dir / _CFG\n p.write_text(\"- item1\\n- item2\\n\", encoding=\"utf-8\")\n with pytest.raises(ConfigError, match=\"YAML mapping\"):\n load_config(str(p))\n\n\n# ---------------------------------------------------------------------------\n# Config dataclass immutability of defaults\n# ---------------------------------------------------------------------------\n\n\ndef test_default_forbid_patterns_are_independent() -> None:\n \"\"\"Modifying one config's forbid_patterns does not affect another.\"\"\"\n cfg1 = Config()\n cfg2 = Config()\n cfg1.rules.forbid_patterns.append(\"^tmp$\")\n assert \"^tmp$\" not in cfg2.rules.forbid_patterns\n assert len(cfg2.rules.forbid_patterns) == len(_DEF_FORBID_PATTERNS)\n\n\ndef test_api_key_env_not_set_means_no_resolution() -> None:\n \"\"\"When api_key_env is empty string, no env resolution occurs.\"\"\"\n cfg = Config(llm=LLMConfig(provider=\"openai\", api_key_env=\"\"))\n assert cfg.llm.api_key == \"\"\n"}, "fd11808eb14fe197c9997761ec1789d861850433": {"t": 1785768470.0054626, "n": 12285, "h": "26becfc74ad8b04b", "c": "\"\"\"Tests for config module.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport warnings\nfrom collections.abc import Callable\nfrom pathlib import Path\nfrom typing import Any\n\nimport pytest\nimport yaml\n\nfrom commit_hook.config import (\n _CFG,\n _DEF_ALLOWED,\n _DEF_BLANK_LINE,\n _DEF_EXCLUDE,\n _DEF_MAX_BODY,\n _DEF_MAX_LINES,\n _DEF_MAX_SUBJECT,\n _DEF_MODEL,\n _DEF_PROVIDER,\n Config,\n ConfigError,\n LLMConfig,\n load_config,\n)\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef temp_dir(tmp_path: Path) -> Path:\n \"\"\"Isolated temporary directory that serves as the repo root.\"\"\"\n return tmp_path\n\n\n@pytest.fixture\ndef use_config(temp_dir: Path) -> Callable[[dict[str, Any]], None]:\n \"\"\"Write a .commit-hook.yaml in *temp_dir* and chdir there.\"\"\"\n\n def _write(content: dict[str, Any]) -> None:\n (temp_dir / _CFG).write_text(yaml.dump(content), encoding=\"utf-8\")\n os.chdir(temp_dir)\n\n return _write\n\n\n# ---------------------------------------------------------------------------\n# Default values\n# ---------------------------------------------------------------------------\n\n\ndef test_defaults_when_no_config(temp_dir: Path) -> None:\n \"\"\"Return built-in defaults when no config file exists.\"\"\"\n os.chdir(temp_dir)\n cfg = load_config()\n assert cfg.llm.provider == _DEF_PROVIDER\n assert cfg.llm.model == _DEF_MODEL\n assert cfg.llm.api_key == \"\"\n assert cfg.llm.api_key_env == \"\"\n assert cfg.rules.max_subject_length == _DEF_MAX_SUBJECT\n assert cfg.rules.max_body_line_length == _DEF_MAX_BODY\n assert cfg.rules.require_blank_line is _DEF_BLANK_LINE\n assert cfg.rules.allowed_types == _DEF_ALLOWED\n assert cfg.diff.exclude == _DEF_EXCLUDE\n assert cfg.diff.max_lines == _DEF_MAX_LINES\n\n\ndef test_default_dataclass_constructors() -> None:\n \"\"\"Dataclass constructors yield defaults.\"\"\"\n cfg = Config()\n assert cfg.llm.provider == _DEF_PROVIDER\n assert cfg.rules.max_subject_length == _DEF_MAX_SUBJECT\n assert cfg.diff.exclude == _DEF_EXCLUDE\n assert cfg.diff.max_lines == _DEF_MAX_LINES\n\n\n# ---------------------------------------------------------------------------\n# Full / partial config\n# ---------------------------------------------------------------------------\n\n\ndef test_full_config(use_config: Callable[[dict[str, Any]], None]) -> None:\n \"\"\"All fields are loaded from a complete config file.\"\"\"\n use_config(\n {\n \"llm\": {\n \"provider\": \"anthropic\",\n \"model\": \"claude-3-opus\",\n \"api_key_env\": \"ANTHROPIC_API_KEY\",\n },\n \"rules\": {\n \"max_subject_length\": 50,\n \"max_body_line_length\": 72,\n \"require_blank_line\": False,\n \"allowed_types\": [\"feat\", \"fix\", \"chore\"],\n },\n \"diff\": {\"exclude\": [\"*.lock\", \"*.min.js\"], \"max_lines\": 300},\n }\n )\n with pytest.MonkeyPatch.context() as mp:\n mp.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-secret\")\n cfg = load_config()\n assert cfg.llm.provider == \"anthropic\"\n assert cfg.llm.model == \"claude-3-opus\"\n assert cfg.llm.api_key == \"sk-ant-secret\"\n assert cfg.llm.api_key_env == \"ANTHROPIC_API_KEY\"\n assert cfg.rules.max_subject_length == 50\n assert cfg.rules.max_body_line_length == 72\n assert cfg.rules.require_blank_line is False\n assert cfg.rules.allowed_types == [\"feat\", \"fix\", \"chore\"]\n assert cfg.diff.exclude == [\"*.lock\", \"*.min.js\"]\n assert cfg.diff.max_lines == 300\n\n\ndef test_partial_config_falls_back_to_defaults(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"Missing fields get defaults.\"\"\"\n use_config({\"llm\": {\"provider\": \"gemini\"}})\n cfg = load_config()\n assert cfg.llm.provider == \"gemini\"\n assert cfg.llm.model == _DEF_MODEL\n assert cfg.llm.api_key_env == \"\"\n assert cfg.rules.max_subject_length == _DEF_MAX_SUBJECT\n\n\ndef test_empty_config_file(temp_dir: Path) -> None:\n \"\"\"An empty YAML file is treated as all defaults.\"\"\"\n (temp_dir / _CFG).write_text(\"\", encoding=\"utf-8\")\n os.chdir(temp_dir)\n cfg = load_config()\n assert cfg.llm.provider == _DEF_PROVIDER\n\n\n# ---------------------------------------------------------------------------\n# Upward search\n# ---------------------------------------------------------------------------\n\n\ndef test_upward_search(temp_dir: Path) -> None:\n \"\"\"Config is found by walking up from a subdirectory.\"\"\"\n config_path = temp_dir / _CFG\n config_path.write_text(yaml.dump({\"llm\": {\"provider\": \"deepseek\"}}), encoding=\"utf-8\")\n sub = temp_dir / \"a\" / \"b\"\n sub.mkdir(parents=True)\n os.chdir(sub)\n cfg = load_config()\n assert cfg.llm.provider == \"deepseek\"\n\n\ndef test_upward_search_stops_at_root(temp_dir: Path) -> None:\n \"\"\"Search stops at filesystem root; defaults are returned.\"\"\"\n sub = temp_dir / \"x\" / \"y\"\n sub.mkdir(parents=True)\n os.chdir(sub)\n cfg = load_config()\n assert cfg.llm.provider == _DEF_PROVIDER\n\n\ndef test_explicit_file_path(temp_dir: Path) -> None:\n \"\"\"load_config with an explicit file path loads that config.\"\"\"\n p = temp_dir / \"custom.yaml\"\n p.write_text(yaml.dump({\"diff\": {\"max_lines\": 100}}), encoding=\"utf-8\")\n cfg = load_config(str(p))\n assert cfg.diff.max_lines == 100\n\n\ndef test_explicit_dir_path(temp_dir: Path) -> None:\n \"\"\"load_config with a directory path searches upward from there.\"\"\"\n (temp_dir / _CFG).write_text(yaml.dump({\"diff\": {\"max_lines\": 200}}), encoding=\"utf-8\")\n cfg = load_config(str(temp_dir))\n assert cfg.diff.max_lines == 200\n\n\n# ---------------------------------------------------------------------------\n# API key resolution\n# ---------------------------------------------------------------------------\n\n\ndef test_api_key_env_success(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"api_key is resolved from the environment variable.\"\"\"\n use_config({\"llm\": {\"api_key_env\": \"TEST_API_KEY\"}})\n with pytest.MonkeyPatch.context() as mp:\n mp.setenv(\"TEST_API_KEY\", \"sk-test-123\")\n cfg = load_config()\n assert cfg.llm.api_key == \"sk-test-123\"\n\n\ndef test_api_key_env_missing_raises(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"ConfigError is raised when the referenced env var is absent.\"\"\"\n use_config({\"llm\": {\"api_key_env\": \"MISSING_KEY\"}})\n with pytest.raises(ConfigError, match=\"MISSING_KEY\"):\n load_config()\n\n\ndef test_api_key_env_empty_no_error(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"When api_key_env is empty, no env lookup occurs.\"\"\"\n use_config({\"llm\": {\"provider\": \"openai\"}})\n cfg = load_config()\n assert cfg.llm.api_key == \"\"\n\n\n# ---------------------------------------------------------------------------\n# Unknown key warnings\n# ---------------------------------------------------------------------------\n\n\ndef test_unknown_top_key_warns(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"Unknown top-level key emits UserWarning.\"\"\"\n use_config({\"llm\": {\"provider\": \"openai\"}, \"unknown_section\": {\"x\": 1}})\n with warnings.catch_warnings(record=True) as w:\n warnings.simplefilter(\"always\")\n cfg = load_config()\n assert cfg.llm.provider == \"openai\"\n warn_msgs = [str(x.message) for x in w]\n assert any(\"unknown_section\" in m for m in warn_msgs)\n\n\ndef test_unknown_sub_field_warns(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"Unknown field inside a known section emits UserWarning.\"\"\"\n use_config({\"llm\": {\"provider\": \"openai\", \"temperature\": 0.7}})\n with warnings.catch_warnings(record=True) as w:\n warnings.simplefilter(\"always\")\n cfg = load_config()\n assert cfg.llm.provider == \"openai\"\n warn_msgs = [str(x.message) for x in w]\n assert any(\"temperature\" in m for m in warn_msgs)\n\n\n# ---------------------------------------------------------------------------\n# Type validation errors\n# ---------------------------------------------------------------------------\n\n\ndef test_provider_not_string(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"llm\": {\"provider\": 123}})\n with pytest.raises(ConfigError, match=\"'provider' must be a string\"):\n load_config()\n\n\ndef test_model_not_string(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"llm\": {\"model\": True}})\n with pytest.raises(ConfigError, match=\"'model' must be a string\"):\n load_config()\n\n\ndef test_max_subject_length_not_int(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"rules\": {\"max_subject_length\": \"long\"}})\n with pytest.raises(ConfigError, match=\"'max_subject_length' must be an integer\"):\n load_config()\n\n\ndef test_max_body_line_length_bool(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"bool is rejected even though it is an int subclass.\"\"\"\n use_config({\"rules\": {\"max_body_line_length\": True}})\n with pytest.raises(ConfigError, match=\"'max_body_line_length' must be an integer\"):\n load_config()\n\n\ndef test_require_blank_line_not_bool(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"rules\": {\"require_blank_line\": \"yes\"}})\n with pytest.raises(ConfigError, match=\"'require_blank_line' must be a boolean\"):\n load_config()\n\n\ndef test_allowed_types_not_list(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"rules\": {\"allowed_types\": \"feat,fix\"}})\n with pytest.raises(ConfigError, match=\"'allowed_types' must be a list\"):\n load_config()\n\n\ndef test_allowed_types_item_not_str(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"rules\": {\"allowed_types\": [\"feat\", 1, \"fix\"]}})\n with pytest.raises(ConfigError, match=r\"allowed_types\\[1\\].*string\"):\n load_config()\n\n\ndef test_max_lines_not_int(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"diff\": {\"max_lines\": \"three\"}})\n with pytest.raises(ConfigError, match=\"'max_lines' must be an integer\"):\n load_config()\n\n\ndef test_exclude_not_list(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"diff\": {\"exclude\": \"*.lock\"}})\n with pytest.raises(ConfigError, match=\"'exclude' must be a list\"):\n load_config()\n\n\ndef test_exclude_item_not_str(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"diff\": {\"exclude\": [\"*.lock\", 123]}})\n with pytest.raises(ConfigError, match=r\"exclude\\[1\\].*string\"):\n load_config()\n\n\n# ---------------------------------------------------------------------------\n# YAML parse errors\n# ---------------------------------------------------------------------------\n\n\ndef test_invalid_yaml(temp_dir: Path) -> None:\n \"\"\"ConfigError is raised for unparseable YAML.\"\"\"\n p = temp_dir / _CFG\n p.write_text(\"llm: [unclosed\\n\", encoding=\"utf-8\")\n with pytest.raises(ConfigError, match=\"Failed to parse YAML\"):\n load_config(str(p))\n\n\ndef test_yaml_not_mapping(temp_dir: Path) -> None:\n \"\"\"ConfigError is raised when YAML root is not a mapping.\"\"\"\n p = temp_dir / _CFG\n p.write_text(\"- item1\\n- item2\\n\", encoding=\"utf-8\")\n with pytest.raises(ConfigError, match=\"YAML mapping\"):\n load_config(str(p))\n\n\n# ---------------------------------------------------------------------------\n# Config dataclass immutability of defaults\n# ---------------------------------------------------------------------------\n\n\ndef test_default_allowed_types_are_independent() -> None:\n \"\"\"Modifying one config's allowed_types does not affect another.\"\"\"\n cfg1 = Config()\n cfg2 = Config()\n cfg1.rules.allowed_types.append(\"custom\")\n assert \"custom\" not in cfg2.rules.allowed_types\n assert len(cfg2.rules.allowed_types) == len(_DEF_ALLOWED)\n\n\ndef test_api_key_env_not_set_means_no_resolution() -> None:\n \"\"\"When api_key_env is empty string, no env resolution occurs.\"\"\"\n cfg = Config(llm=LLMConfig(provider=\"openai\", api_key_env=\"\"))\n assert cfg.llm.api_key == \"\"\n"}, "f0ea71f7b410529490b472ff6120ec6a92a34e78": {"t": 1785768471.2707293, "n": 11612, "h": "4de5cc07ce5e39b2", "c": "\"\"\"Tests for config module.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport warnings\nfrom collections.abc import Callable\nfrom pathlib import Path\nfrom typing import Any\n\nimport pytest\nimport yaml\n\nfrom commit_hook.config import (\n _CFG,\n _DEF_ALLOWED,\n _DEF_BLANK_LINE,\n _DEF_CONTEXT,\n _DEF_MAX_BODY,\n _DEF_MAX_SUBJECT,\n _DEF_MODEL,\n _DEF_PROVIDER,\n Config,\n ConfigError,\n LLMConfig,\n load_config,\n)\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef temp_dir(tmp_path: Path) -> Path:\n \"\"\"Isolated temporary directory that serves as the repo root.\"\"\"\n return tmp_path\n\n\n@pytest.fixture\ndef use_config(temp_dir: Path) -> Callable[[dict[str, Any]], None]:\n \"\"\"Write a .commit-hook.yaml in *temp_dir* and chdir there.\"\"\"\n\n def _write(content: dict[str, Any]) -> None:\n (temp_dir / _CFG).write_text(yaml.dump(content), encoding=\"utf-8\")\n os.chdir(temp_dir)\n\n return _write\n\n\n# ---------------------------------------------------------------------------\n# Default values\n# ---------------------------------------------------------------------------\n\n\ndef test_defaults_when_no_config(temp_dir: Path) -> None:\n \"\"\"Return built-in defaults when no config file exists.\"\"\"\n os.chdir(temp_dir)\n cfg = load_config()\n assert cfg.llm.provider == _DEF_PROVIDER\n assert cfg.llm.model == _DEF_MODEL\n assert cfg.llm.api_key == \"\"\n assert cfg.llm.api_key_env == \"\"\n assert cfg.rules.max_subject_length == _DEF_MAX_SUBJECT\n assert cfg.rules.max_body_line_length == _DEF_MAX_BODY\n assert cfg.rules.require_blank_line is _DEF_BLANK_LINE\n assert cfg.rules.allowed_types == _DEF_ALLOWED\n assert cfg.diff.context_lines == _DEF_CONTEXT\n\n\ndef test_default_dataclass_constructors() -> None:\n \"\"\"Dataclass constructors yield defaults.\"\"\"\n cfg = Config()\n assert cfg.llm.provider == _DEF_PROVIDER\n assert cfg.rules.max_subject_length == _DEF_MAX_SUBJECT\n assert cfg.diff.context_lines == _DEF_CONTEXT\n\n\n# ---------------------------------------------------------------------------\n# Full / partial config\n# ---------------------------------------------------------------------------\n\n\ndef test_full_config(use_config: Callable[[dict[str, Any]], None]) -> None:\n \"\"\"All fields are loaded from a complete config file.\"\"\"\n use_config({\n \"llm\": {\n \"provider\": \"anthropic\",\n \"model\": \"claude-3-opus\",\n \"api_key_env\": \"ANTHROPIC_API_KEY\",\n },\n \"rules\": {\n \"max_subject_length\": 50,\n \"max_body_line_length\": 72,\n \"require_blank_line\": False,\n \"allowed_types\": [\"feat\", \"fix\", \"chore\"],\n },\n \"diff\": {\"context_lines\": 5},\n })\n with pytest.MonkeyPatch.context() as mp:\n mp.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant-secret\")\n cfg = load_config()\n assert cfg.llm.provider == \"anthropic\"\n assert cfg.llm.model == \"claude-3-opus\"\n assert cfg.llm.api_key == \"sk-ant-secret\"\n assert cfg.llm.api_key_env == \"ANTHROPIC_API_KEY\"\n assert cfg.rules.max_subject_length == 50\n assert cfg.rules.max_body_line_length == 72\n assert cfg.rules.require_blank_line is False\n assert cfg.rules.allowed_types == [\"feat\", \"fix\", \"chore\"]\n assert cfg.diff.context_lines == 5\n\n\ndef test_partial_config_falls_back_to_defaults(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"Missing fields get defaults.\"\"\"\n use_config({\"llm\": {\"provider\": \"gemini\"}})\n cfg = load_config()\n assert cfg.llm.provider == \"gemini\"\n assert cfg.llm.model == _DEF_MODEL\n assert cfg.llm.api_key_env == \"\"\n assert cfg.rules.max_subject_length == _DEF_MAX_SUBJECT\n\n\ndef test_empty_config_file(temp_dir: Path) -> None:\n \"\"\"An empty YAML file is treated as all defaults.\"\"\"\n (temp_dir / _CFG).write_text(\"\", encoding=\"utf-8\")\n os.chdir(temp_dir)\n cfg = load_config()\n assert cfg.llm.provider == _DEF_PROVIDER\n\n\n# ---------------------------------------------------------------------------\n# Upward search\n# ---------------------------------------------------------------------------\n\n\ndef test_upward_search(temp_dir: Path) -> None:\n \"\"\"Config is found by walking up from a subdirectory.\"\"\"\n config_path = temp_dir / _CFG\n config_path.write_text(\n yaml.dump({\"llm\": {\"provider\": \"deepseek\"}}), encoding=\"utf-8\"\n )\n sub = temp_dir / \"a\" / \"b\"\n sub.mkdir(parents=True)\n os.chdir(sub)\n cfg = load_config()\n assert cfg.llm.provider == \"deepseek\"\n\n\ndef test_upward_search_stops_at_root(temp_dir: Path) -> None:\n \"\"\"Search stops at filesystem root; defaults are returned.\"\"\"\n sub = temp_dir / \"x\" / \"y\"\n sub.mkdir(parents=True)\n os.chdir(sub)\n cfg = load_config()\n assert cfg.llm.provider == _DEF_PROVIDER\n\n\ndef test_explicit_file_path(temp_dir: Path) -> None:\n \"\"\"load_config with an explicit file path loads that config.\"\"\"\n p = temp_dir / \"custom.yaml\"\n p.write_text(yaml.dump({\"diff\": {\"context_lines\": 10}}), encoding=\"utf-8\")\n cfg = load_config(str(p))\n assert cfg.diff.context_lines == 10\n\n\ndef test_explicit_dir_path(temp_dir: Path) -> None:\n \"\"\"load_config with a directory path searches upward from there.\"\"\"\n (temp_dir / _CFG).write_text(\n yaml.dump({\"diff\": {\"context_lines\": 12}}), encoding=\"utf-8\"\n )\n cfg = load_config(str(temp_dir))\n assert cfg.diff.context_lines == 12\n\n\n# ---------------------------------------------------------------------------\n# API key resolution\n# ---------------------------------------------------------------------------\n\n\ndef test_api_key_env_success(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"api_key is resolved from the environment variable.\"\"\"\n use_config({\"llm\": {\"api_key_env\": \"TEST_API_KEY\"}})\n with pytest.MonkeyPatch.context() as mp:\n mp.setenv(\"TEST_API_KEY\", \"sk-test-123\")\n cfg = load_config()\n assert cfg.llm.api_key == \"sk-test-123\"\n\n\ndef test_api_key_env_missing_raises(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"ConfigError is raised when the referenced env var is absent.\"\"\"\n use_config({\"llm\": {\"api_key_env\": \"MISSING_KEY\"}})\n with pytest.raises(ConfigError, match=\"MISSING_KEY\"):\n load_config()\n\n\ndef test_api_key_env_empty_no_error(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"When api_key_env is empty, no env lookup occurs.\"\"\"\n use_config({\"llm\": {\"provider\": \"openai\"}})\n cfg = load_config()\n assert cfg.llm.api_key == \"\"\n\n\n# ---------------------------------------------------------------------------\n# Unknown key warnings\n# ---------------------------------------------------------------------------\n\n\ndef test_unknown_top_key_warns(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"Unknown top-level key emits UserWarning.\"\"\"\n use_config({\"llm\": {\"provider\": \"openai\"}, \"unknown_section\": {\"x\": 1}})\n with warnings.catch_warnings(record=True) as w:\n warnings.simplefilter(\"always\")\n cfg = load_config()\n assert cfg.llm.provider == \"openai\"\n warn_msgs = [str(x.message) for x in w]\n assert any(\"unknown_section\" in m for m in warn_msgs)\n\n\ndef test_unknown_sub_field_warns(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"Unknown field inside a known section emits UserWarning.\"\"\"\n use_config({\"llm\": {\"provider\": \"openai\", \"temperature\": 0.7}})\n with warnings.catch_warnings(record=True) as w:\n warnings.simplefilter(\"always\")\n cfg = load_config()\n assert cfg.llm.provider == \"openai\"\n warn_msgs = [str(x.message) for x in w]\n assert any(\"temperature\" in m for m in warn_msgs)\n\n\n# ---------------------------------------------------------------------------\n# Type validation errors\n# ---------------------------------------------------------------------------\n\n\ndef test_provider_not_string(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"llm\": {\"provider\": 123}})\n with pytest.raises(ConfigError, match=\"'provider' must be a string\"):\n load_config()\n\n\ndef test_model_not_string(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"llm\": {\"model\": True}})\n with pytest.raises(ConfigError, match=\"'model' must be a string\"):\n load_config()\n\n\ndef test_max_subject_length_not_int(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"rules\": {\"max_subject_length\": \"long\"}})\n with pytest.raises(ConfigError, match=\"'max_subject_length' must be an integer\"):\n load_config()\n\n\ndef test_max_body_line_length_bool(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n \"\"\"bool is rejected even though it is an int subclass.\"\"\"\n use_config({\"rules\": {\"max_body_line_length\": True}})\n with pytest.raises(ConfigError, match=\"'max_body_line_length' must be an integer\"):\n load_config()\n\n\ndef test_require_blank_line_not_bool(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"rules\": {\"require_blank_line\": \"yes\"}})\n with pytest.raises(ConfigError, match=\"'require_blank_line' must be a boolean\"):\n load_config()\n\n\ndef test_allowed_types_not_list(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"rules\": {\"allowed_types\": \"feat,fix\"}})\n with pytest.raises(ConfigError, match=\"'allowed_types' must be a list\"):\n load_config()\n\n\ndef test_allowed_types_item_not_str(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"rules\": {\"allowed_types\": [\"feat\", 1, \"fix\"]}})\n with pytest.raises(ConfigError, match=r\"allowed_types\\[1\\].*string\"):\n load_config()\n\n\ndef test_context_lines_not_int(\n use_config: Callable[[dict[str, Any]], None],\n) -> None:\n use_config({\"diff\": {\"context_lines\": \"three\"}})\n with pytest.raises(ConfigError, match=\"'context_lines' must be an integer\"):\n load_config()\n\n\n# ---------------------------------------------------------------------------\n# YAML parse errors\n# ---------------------------------------------------------------------------\n\n\ndef test_invalid_yaml(temp_dir: Path) -> None:\n \"\"\"ConfigError is raised for unparseable YAML.\"\"\"\n p = temp_dir / _CFG\n p.write_text(\"llm: [unclosed\\n\", encoding=\"utf-8\")\n with pytest.raises(ConfigError, match=\"Failed to parse YAML\"):\n load_config(str(p))\n\n\ndef test_yaml_not_mapping(temp_dir: Path) -> None:\n \"\"\"ConfigError is raised when YAML root is not a mapping.\"\"\"\n p = temp_dir / _CFG\n p.write_text(\"- item1\\n- item2\\n\", encoding=\"utf-8\")\n with pytest.raises(ConfigError, match=\"YAML mapping\"):\n load_config(str(p))\n\n\n# ---------------------------------------------------------------------------\n# Config dataclass immutability of defaults\n# ---------------------------------------------------------------------------\n\n\ndef test_default_allowed_types_are_independent() -> None:\n \"\"\"Modifying one config's allowed_types does not affect another.\"\"\"\n cfg1 = Config()\n cfg2 = Config()\n cfg1.rules.allowed_types.append(\"custom\")\n assert \"custom\" not in cfg2.rules.allowed_types\n assert len(cfg2.rules.allowed_types) == len(_DEF_ALLOWED)\n\n\ndef test_api_key_env_not_set_means_no_resolution() -> None:\n \"\"\"When api_key_env is empty string, no env resolution occurs.\"\"\"\n cfg = Config(llm=LLMConfig(provider=\"openai\", api_key_env=\"\"))\n assert cfg.llm.api_key == \"\"\n"}, "5e7ce335875cfb97447cfdb89ba86f30e0fb173f": {"t": 1785768473.508531, "n": 340, "h": "e4d5034f55c002e2", "c": "\"\"\"Tests for config module.\"\"\"\n\n\ndef test_load_config_empty() -> None:\n \"\"\"Test loading an empty config.\"\"\"\n import tempfile\n\n from commit_hook.config import load_config\n\n with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".yaml\", delete=False) as f:\n f.write(\"\")\n result = load_config(f.name)\n assert result == {}\n"}}}}