Coverage for src/secchi/config.py: 65%
60 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-04 22:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-04 22:15 +0000
1"""Configuration loader — reads secchi config files and env vars."""
3from __future__ import annotations
5import os
6import sys
7from pathlib import Path
9from secchi.errors import ConfigError
10from secchi.models import PackageRef, Project, Registry
12if sys.version_info >= (3, 11):
13 import tomllib
14else:
15 import tomli as tomllib # type: ignore
18def _config_locations() -> list[Path]:
19 """Return candidate config file paths in priority order."""
20 candidates: list[Path] = []
21 candidates.append(Path.cwd() / "secchi.toml")
22 candidates.append(Path.cwd() / ".secchi.toml")
23 if platform := os.environ.get("XDG_CONFIG_HOME", ""):
24 candidates.append(Path(platform) / "secchi" / "config.toml")
25 else:
26 candidates.append(Path.home() / ".config" / "secchi" / "config.toml")
27 return candidates
30def find_config(explicit: str | None = None) -> Path | None:
31 """Locate the config file.
33 Priority: explicit path > ./secchi.toml > ./.secchi.toml >
34 ~/.config/secchi/config.toml
35 """
36 if explicit:
37 path = Path(explicit).expanduser()
38 if path.exists():
39 return path
40 raise ConfigError(f"Config file not found: {explicit}")
42 for candidate in _config_locations():
43 if candidate.exists():
44 return candidate
45 return None
48def load_project(config_path: Path, project_name: str) -> Project:
49 """Load a single project from the config file."""
50 try:
51 data = tomllib.loads(config_path.read_text())
52 except (OSError, ValueError) as exc:
53 raise ConfigError(f"Could not read config file: {config_path}") from exc
54 projects = data.get("projects", {})
56 if project_name not in projects:
57 available = list(projects.keys())
58 hint = f" Available projects: {', '.join(available)}" if available else ""
59 raise ConfigError(f"Project '{project_name}' not found in {config_path}.{hint}")
61 raw = projects[project_name]
62 project = Project(
63 name=project_name,
64 title=raw.get("title", project_name),
65 description=raw.get("description", ""),
66 favorite=bool(raw.get("favorite", False)),
67 repository_url=raw.get("repository", raw.get("repository_url", "")),
68 )
70 for pkg in raw.get("packages", []):
71 name = pkg["name"]
72 registry_raw = pkg.get("registry", "pypi")
73 # Package-level favorites are retained for compatibility with existing
74 # configs. New configs should put this navigation preference on the
75 # project instead.
76 favorite = bool(pkg.get("favorite", raw.get("favorite", False)))
77 try:
78 registry = Registry(registry_raw)
79 except ValueError:
80 raise ConfigError(
81 f"Unknown registry '{registry_raw}' for package '{name}'. "
82 f"Must be one of: {', '.join(r.value for r in Registry)}"
83 ) from None
84 project.packages.append(
85 PackageRef(
86 name=name,
87 registry=registry,
88 favorite=favorite,
89 project_name=project_name,
90 )
91 )
93 return project
96def list_projects(config_path: Path) -> list[str]:
97 """List all project names in the config file."""
98 try:
99 data = tomllib.loads(config_path.read_text())
100 except (OSError, ValueError) as exc:
101 raise ConfigError(f"Could not read config file: {config_path}") from exc
102 return list(data.get("projects", {}).keys())
105def load_projects(config_path: Path) -> list[Project]:
106 """Load every project in configuration order for workspace dashboards."""
107 return [load_project(config_path, name) for name in list_projects(config_path)]
110def get_env_token(var_name: str) -> str | None:
111 """Read an auth token from environment variable.
113 Supported vars:
114 - SECCHI_GITHUB_TOKEN — GitHub API token for release notes
115 """
116 token = os.environ.get(var_name)
117 return token or None