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

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

2 

3from __future__ import annotations 

4 

5import os 

6import tomllib 

7from pathlib import Path 

8from typing import Any 

9 

10from secchi.errors import ConfigError 

11from secchi.models import PackageRef, Project, Registry 

12 

13 

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__ 

24 

25 

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 

36 

37 

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 

48 

49 

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

51 """Locate the config file. 

52 

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

61 

62 for candidate in _config_locations(): 

63 if candidate.exists(): 

64 return candidate 

65 return None 

66 

67 

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

75 

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

80 

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 ) 

89 

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 ) 

96 

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 ) 

125 

126 return project 

127 

128 

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

136 

137 

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

141 

142 

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

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

145 

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