Coverage for repo_ctx / config.py: 82%

121 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-25 17:42 +0100

1"""Configuration management.""" 

2import os 

3import re 

4from pathlib import Path 

5from typing import Optional 

6import yaml 

7from pydantic import BaseModel 

8 

9 

10class Config(BaseModel): 

11 # GitLab (optional now - for multi-provider support) 

12 gitlab_url: Optional[str] = None 

13 gitlab_token: Optional[str] = None 

14 

15 # GitHub (optional) 

16 github_url: Optional[str] = None 

17 github_token: Optional[str] = None 

18 

19 # Storage 

20 storage_path: str = "./data/context.db" 

21 

22 @classmethod 

23 def from_env(cls) -> "Config": 

24 """Load config from environment variables. 

25 

26 Supported environment variables: 

27 GitLab: 

28 - GITLAB_URL or GIT_CONTEXT_GITLAB_URL 

29 - GITLAB_TOKEN or GIT_CONTEXT_GITLAB_TOKEN 

30 

31 GitHub: 

32 - GITHUB_URL or GIT_CONTEXT_GITHUB_URL (default: https://api.github.com) 

33 - GITHUB_TOKEN or GIT_CONTEXT_GITHUB_TOKEN (optional for public repos) 

34 

35 Storage: 

36 - STORAGE_PATH or GIT_CONTEXT_STORAGE_PATH (default: ~/.repo-ctx/context.db) 

37 

38 At least one provider (GitLab or GitHub) must be configured. 

39 """ 

40 # GitLab 

41 gitlab_url = os.getenv("GIT_CONTEXT_GITLAB_URL") or os.getenv("GITLAB_URL") 

42 gitlab_token = os.getenv("GIT_CONTEXT_GITLAB_TOKEN") or os.getenv("GITLAB_TOKEN") 

43 

44 # GitHub 

45 github_url = os.getenv("GIT_CONTEXT_GITHUB_URL") or os.getenv("GITHUB_URL") 

46 github_token = os.getenv("GIT_CONTEXT_GITHUB_TOKEN") or os.getenv("GITHUB_TOKEN") 

47 

48 # Default GitHub URL if token is provided but URL is not 

49 if github_token and not github_url: 

50 github_url = "https://api.github.com" 

51 

52 # Storage 

53 storage_path = ( 

54 os.getenv("GIT_CONTEXT_STORAGE_PATH") 

55 or os.getenv("STORAGE_PATH") 

56 or os.path.expanduser("~/.repo-ctx/context.db") 

57 ) 

58 

59 # Validate at least one provider is configured 

60 if not (gitlab_url and gitlab_token) and not github_url: 

61 raise ValueError( 

62 "At least one provider must be configured.\n" 

63 "GitLab: Set GITLAB_URL and GITLAB_TOKEN\n" 

64 "GitHub: Set GITHUB_URL (optional) and/or GITHUB_TOKEN\n" 

65 "Or set GIT_CONTEXT_* prefixed versions" 

66 ) 

67 

68 return cls( 

69 gitlab_url=gitlab_url, 

70 gitlab_token=gitlab_token, 

71 github_url=github_url, 

72 github_token=github_token, 

73 storage_path=storage_path 

74 ) 

75 

76 @classmethod 

77 def from_yaml(cls, path: str = "config.yaml") -> "Config": 

78 """Load config from YAML file with environment variable substitution. 

79 

80 Example config.yaml: 

81 ```yaml 

82 gitlab: 

83 url: "https://gitlab.company.com" 

84 token: "${GITLAB_TOKEN}" 

85 

86 github: 

87 url: "https://api.github.com" # Optional, defaults to public GitHub 

88 token: "${GITHUB_TOKEN}" # Optional for public repos 

89 

90 storage: 

91 path: "~/.repo-ctx/context.db" 

92 ``` 

93 """ 

94 config_path = Path(path) 

95 if not config_path.exists(): 

96 raise FileNotFoundError(f"Config file not found: {path}") 

97 

98 with open(config_path) as f: 

99 content = f.read() 

100 

101 # Substitute ${VAR} or $VAR with environment variables 

102 def replace_env_var(match): 

103 var_name = match.group(1) or match.group(2) 

104 value = os.getenv(var_name) 

105 if value is None: 

106 raise ValueError(f"Environment variable {var_name} not set") 

107 return value 

108 

109 # Replace ${VAR} and $VAR patterns 

110 content = re.sub(r'\$\{([^}]+)\}|\$([A-Z_][A-Z0-9_]*)', replace_env_var, content) 

111 

112 data = yaml.safe_load(content) 

113 

114 # Extract GitLab config 

115 gitlab_url = None 

116 gitlab_token = None 

117 if "gitlab" in data: 

118 gitlab_url = data["gitlab"].get("url") 

119 gitlab_token = data["gitlab"].get("token") 

120 

121 # Extract GitHub config 

122 github_url = None 

123 github_token = None 

124 if "github" in data: 

125 github_url = data["github"].get("url") 

126 github_token = data["github"].get("token") 

127 # Default GitHub URL 

128 if github_token and not github_url: 

129 github_url = "https://api.github.com" 

130 

131 # Extract storage path 

132 storage_path = data.get("storage", {}).get("path", "./data/context.db") 

133 

134 # Validate at least one provider 

135 if not (gitlab_url and gitlab_token) and not github_url: 

136 raise ValueError( 

137 "Config file must specify at least one provider (gitlab or github)" 

138 ) 

139 

140 return cls( 

141 gitlab_url=gitlab_url, 

142 gitlab_token=gitlab_token, 

143 github_url=github_url, 

144 github_token=github_token, 

145 storage_path=storage_path 

146 ) 

147 

148 @classmethod 

149 def find_config_file(cls) -> Optional[Path]: 

150 """Find config file in standard locations. 

151 

152 Checks in order: 

153 1. ./config.yaml (current directory) 

154 2. ~/.config/repo-ctx/config.yaml 

155 3. ~/.repo-ctx/config.yaml 

156 

157 Returns: 

158 Path to config file if found, None otherwise 

159 """ 

160 locations = [ 

161 Path("config.yaml"), 

162 Path.home() / ".config" / "repo-ctx" / "config.yaml", 

163 Path.home() / ".repo-ctx" / "config.yaml", 

164 ] 

165 

166 for location in locations: 

167 if location.exists(): 

168 return location 

169 

170 return None 

171 

172 @classmethod 

173 def load( 

174 cls, 

175 config_path: Optional[str] = None, 

176 gitlab_url: Optional[str] = None, 

177 gitlab_token: Optional[str] = None, 

178 github_url: Optional[str] = None, 

179 github_token: Optional[str] = None, 

180 storage_path: Optional[str] = None, 

181 ) -> "Config": 

182 """Load configuration from multiple sources with priority. 

183 

184 Priority (highest to lowest): 

185 1. Explicit arguments 

186 2. Specified config file (config_path) 

187 3. Environment variables 

188 4. Standard config file locations 

189 

190 Args: 

191 config_path: Explicit path to config file 

192 gitlab_url: GitLab URL (overrides all other sources) 

193 gitlab_token: GitLab token (overrides all other sources) 

194 github_url: GitHub URL (overrides all other sources) 

195 github_token: GitHub token (overrides all other sources) 

196 storage_path: Storage path (overrides all other sources) 

197 

198 Returns: 

199 Config instance 

200 

201 Raises: 

202 ValueError: If no valid configuration source is found 

203 """ 

204 # If any provider is explicitly configured, use explicit params 

205 has_gitlab = gitlab_url and gitlab_token 

206 has_github = github_url or github_token 

207 

208 if has_gitlab or has_github: 

209 # Default GitHub URL if not provided 

210 if github_token and not github_url: 

211 github_url = "https://api.github.com" 

212 

213 return cls( 

214 gitlab_url=gitlab_url, 

215 gitlab_token=gitlab_token, 

216 github_url=github_url, 

217 github_token=github_token, 

218 storage_path=storage_path or os.path.expanduser("~/.repo-ctx/context.db") 

219 ) 

220 

221 # Try explicit config path 

222 if config_path: 

223 try: 

224 config = cls.from_yaml(config_path) 

225 # Override with explicit params if provided 

226 if gitlab_url: 

227 config.gitlab_url = gitlab_url 

228 if gitlab_token: 

229 config.gitlab_token = gitlab_token 

230 if github_url: 

231 config.github_url = github_url 

232 if github_token: 

233 config.github_token = github_token 

234 if storage_path: 

235 config.storage_path = storage_path 

236 return config 

237 except FileNotFoundError: 

238 raise 

239 except Exception as e: 

240 raise ValueError(f"Error loading config from {config_path}: {e}") 

241 

242 # Try environment variables 

243 try: 

244 config = cls.from_env() 

245 # Override with explicit params if provided 

246 if gitlab_url: 

247 config.gitlab_url = gitlab_url 

248 if gitlab_token: 

249 config.gitlab_token = gitlab_token 

250 if github_url: 

251 config.github_url = github_url 

252 if github_token: 

253 config.github_token = github_token 

254 if storage_path: 

255 config.storage_path = storage_path 

256 return config 

257 except ValueError: 

258 pass # Environment variables not set, try config file 

259 

260 # Try standard config file locations 

261 config_file = cls.find_config_file() 

262 if config_file: 

263 try: 

264 config = cls.from_yaml(str(config_file)) 

265 # Override with explicit params if provided 

266 if gitlab_url: 

267 config.gitlab_url = gitlab_url 

268 if gitlab_token: 

269 config.gitlab_token = gitlab_token 

270 if github_url: 

271 config.github_url = github_url 

272 if github_token: 

273 config.github_token = github_token 

274 if storage_path: 

275 config.storage_path = storage_path 

276 return config 

277 except Exception as e: 

278 raise ValueError(f"Error loading config from {config_file}: {e}") 

279 

280 # No valid config found 

281 raise ValueError( 

282 "No configuration found. Please configure at least one provider:\n\n" 

283 "Option 1: Environment variables\n" 

284 " GitLab: GITLAB_URL and GITLAB_TOKEN\n" 

285 " GitHub: GITHUB_URL (optional) and GITHUB_TOKEN\n\n" 

286 "Option 2: Config file in:\n" 

287 " - Current directory (config.yaml)\n" 

288 " - ~/.config/repo-ctx/config.yaml\n" 

289 " - ~/.repo-ctx/config.yaml\n\n" 

290 "Option 3: Command-line arguments\n" 

291 " --gitlab-url --gitlab-token\n" 

292 " --github-url --github-token\n\n" 

293 "Option 4: Use --config to specify config file path" 

294 )