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

1"""Configuration loader — reads secchi config files and env vars.""" 

2 

3from __future__ import annotations 

4 

5import os 

6import sys 

7from pathlib import Path 

8 

9from secchi.errors import ConfigError 

10from secchi.models import PackageRef, Project, Registry 

11 

12if sys.version_info >= (3, 11): 

13 import tomllib 

14else: 

15 import tomli as tomllib # type: ignore 

16 

17 

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 

28 

29 

30def find_config(explicit: str | None = None) -> Path | None: 

31 """Locate the config file. 

32 

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}") 

41 

42 for candidate in _config_locations(): 

43 if candidate.exists(): 

44 return candidate 

45 return None 

46 

47 

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", {}) 

55 

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}") 

60 

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 ) 

69 

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 ) 

92 

93 return project 

94 

95 

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()) 

103 

104 

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)] 

108 

109 

110def get_env_token(var_name: str) -> str | None: 

111 """Read an auth token from environment variable. 

112 

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