Coverage for repo_ctx/config.py: 82%

1"""Configuration management."""

2 import os

3 import re

4 from pathlib import Path

5 from typing import Optional

6 import yaml

7 from pydantic import BaseModel

8

9

10 class 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 )