Coverage for src/secchi/config.py: 72%
78 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 23:28 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 23:28 +0000
1"""Configuration loader — reads secchi config files and env vars."""
3from __future__ import annotations
5import os
6import tomllib
7from pathlib import Path
8from typing import Any
10from secchi.errors import ConfigError
11from secchi.models import PackageRef, Project, Registry
14def _toml_type(value: Any) -> str:
15 if isinstance(value, list):
16 return "an array"
17 if isinstance(value, bool):
18 return "a boolean"
19 if isinstance(value, str):
20 return "a string"
21 if isinstance(value, int | float):
22 return "a number"
23 return type(value).__name__
26def _require_table(value: Any, description: str) -> dict[str, Any]:
27 """Raise a readable ConfigError instead of a raw KeyError/TypeError/
28 AttributeError when a config section isn't the TOML table shape the
29 loader expects (e.g. `packages = ["requests"]` instead of a table
30 per package, or `projects` written as an array)."""
31 if not isinstance(value, dict):
32 raise ConfigError(
33 f"{description} must be a TOML table, got {_toml_type(value)}."
34 )
35 return value
38def _config_locations() -> list[Path]:
39 """Return candidate config file paths in priority order."""
40 candidates: list[Path] = []
41 candidates.append(Path.cwd() / "secchi.toml")
42 candidates.append(Path.cwd() / ".secchi.toml")
43 if platform := os.environ.get("XDG_CONFIG_HOME", ""):
44 candidates.append(Path(platform) / "secchi" / "config.toml")
45 else:
46 candidates.append(Path.home() / ".config" / "secchi" / "config.toml")
47 return candidates
50def find_config(explicit: str | None = None) -> Path | None:
51 """Locate the config file.
53 Priority: explicit path > ./secchi.toml > ./.secchi.toml >
54 ~/.config/secchi/config.toml
55 """
56 if explicit:
57 path = Path(explicit).expanduser()
58 if path.exists():
59 return path
60 raise ConfigError(f"Config file not found: {explicit}")
62 for candidate in _config_locations():
63 if candidate.exists():
64 return candidate
65 return None
68def load_project(config_path: Path, project_name: str) -> Project:
69 """Load a single project from the config file."""
70 try:
71 data = tomllib.loads(config_path.read_text())
72 except (OSError, ValueError) as exc:
73 raise ConfigError(f"Could not read config file: {config_path}") from exc
74 projects = _require_table(data.get("projects", {}), "'projects'")
76 if project_name not in projects:
77 available = list(projects.keys())
78 hint = f" Available projects: {', '.join(available)}" if available else ""
79 raise ConfigError(f"Project '{project_name}' not found in {config_path}.{hint}")
81 raw = _require_table(projects[project_name], f"Project '{project_name}'")
82 project = Project(
83 name=project_name,
84 title=raw.get("title", project_name),
85 description=raw.get("description", ""),
86 favorite=bool(raw.get("favorite", False)),
87 repository_url=raw.get("repository", raw.get("repository_url", "")),
88 )
90 packages = raw.get("packages", [])
91 if not isinstance(packages, list):
92 raise ConfigError(
93 f"'packages' in project '{project_name}' must be an array, "
94 f"got {_toml_type(packages)}."
95 )
97 for index, raw_pkg in enumerate(packages):
98 pkg = _require_table(raw_pkg, f"packages[{index}] in project '{project_name}'")
99 if not isinstance(pkg.get("name"), str) or not pkg["name"]:
100 raise ConfigError(
101 f"packages[{index}] in project '{project_name}' is missing a "
102 "non-empty 'name'."
103 )
104 name = pkg["name"]
105 registry_raw = pkg.get("registry", "pypi")
106 # Package-level favorites are retained for compatibility with existing
107 # configs. New configs should put this navigation preference on the
108 # project instead.
109 favorite = bool(pkg.get("favorite", raw.get("favorite", False)))
110 try:
111 registry = Registry(registry_raw)
112 except ValueError:
113 raise ConfigError(
114 f"Unknown registry '{registry_raw}' for package '{name}'. "
115 f"Must be one of: {', '.join(r.value for r in Registry)}"
116 ) from None
117 project.packages.append(
118 PackageRef(
119 name=name,
120 registry=registry,
121 favorite=favorite,
122 project_name=project_name,
123 )
124 )
126 return project
129def list_projects(config_path: Path) -> list[str]:
130 """List all project names in the config file."""
131 try:
132 data = tomllib.loads(config_path.read_text())
133 except (OSError, ValueError) as exc:
134 raise ConfigError(f"Could not read config file: {config_path}") from exc
135 return list(_require_table(data.get("projects", {}), "'projects'").keys())
138def load_projects(config_path: Path) -> list[Project]:
139 """Load every project in configuration order for workspace dashboards."""
140 return [load_project(config_path, name) for name in list_projects(config_path)]
143def get_env_token(var_name: str) -> str | None:
144 """Read an auth token from environment variable.
146 Supported vars:
147 - SECCHI_GITHUB_TOKEN — GitHub API token for release notes
148 """
149 token = os.environ.get(var_name)
150 return token or None