Coverage for harbor_cli/config.py: 82%

171 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-02-09 12:09 +0100

1from __future__ import annotations 

2 

3import json 

4from pathlib import Path 

5from typing import Any 

6from typing import Optional 

7from typing import TypedDict 

8 

9import tomli 

10import tomli_w 

11from harborapi.models.base import BaseModel as HarborBaseModel 

12from loguru import logger 

13from pydantic import Field 

14from pydantic import root_validator 

15from pydantic import SecretStr 

16from pydantic import validator 

17 

18from .dirs import CONFIG_DIR 

19from .exceptions import ConfigError 

20from .exceptions import CredentialsError 

21from .exceptions import HarborCLIError 

22from .exceptions import OverwriteError 

23from .format import OutputFormat 

24from .logs import LogLevel 

25from .utils import replace_none 

26 

27 

28DEFAULT_CONFIG_FILE = CONFIG_DIR / "config.toml" 

29 

30ENV_VAR_PREFIX = "HARBOR_CLI_" 

31 

32 

33def config_env_var(key: str) -> str: 

34 """Return the environment variable name for a config key.""" 

35 return ENV_VAR_PREFIX + key.upper().replace(".", "_") 

36 

37 

38def env_var(option: str) -> str: 

39 """Return the environment variable name for a CLI option.""" 

40 return ENV_VAR_PREFIX + option.upper().replace("-", "_") 

41 

42 

43def load_toml_file(config_file: Path) -> dict[str, Any]: 

44 """Load a TOML file and return the contents as a dict. 

45 

46 Parameters 

47 ---------- 

48 config_file : Path, 

49 Path to the TOML file to load. 

50 

51 Returns 

52 ------- 

53 Dict[str, Any] 

54 A TOML file as a dictionary 

55 """ 

56 conf = tomli.loads(config_file.read_text()) 

57 return conf 

58 

59 

60# We use the harborapi.models.BaseModel as our base class 

61# for the config models. This isn't ideal, and we should instead 

62# be able to import as_table from harborapi and add it to our own 

63# BaseModel class. But for now, this works. 

64# 

65# The reason we can't do the above is because as_table is a bound method 

66# on the harborapi.models.BaseModel class, and we can't add it to our own 

67# until the method is moved out of the class. 

68class BaseModel(HarborBaseModel): 

69 """Base model shared by all config models.""" 

70 

71 # https://pydantic-docs.helpmanual.io/usage/model_config/#change-behaviour-globally 

72 

73 @root_validator(pre=True) 

74 def _pre_root_validator(cls, values: dict[str, Any]) -> dict[str, Any]: 

75 """Checks for unknown fields and logs a warning if any are found. 

76 

77 Since we use `extra = "allow"`, it can be useful to check for unknown 

78 fields and log a warning if any are found, otherwise they will be 

79 silently ignored. 

80 

81 See: Config class below. 

82 """ 

83 for key in values: 

84 if key not in cls.__fields__: 84 ↛ 85line 84 didn't jump to line 85, because the condition on line 84 was never true

85 logger.warning( 

86 "{}: Got unknown config key {!r}.", 

87 getattr(cls, "__name__", str(cls)), 

88 key, 

89 ) 

90 return values 

91 

92 class Config: 

93 # Allow for future fields to be added to the config file without 

94 # breaking older versions of Harbor CLI 

95 extra = "allow" 

96 validate_assignment = True 

97 

98 

99class HarborCredentialsKwargs(TypedDict): 

100 url: str 

101 username: str 

102 secret: str 

103 credentials: str 

104 credentials_file: Optional[Path] 

105 

106 

107class HarborSettings(BaseModel): 

108 url: str = "" 

109 username: str = "" 

110 secret: SecretStr = SecretStr("") 

111 basicauth: SecretStr = SecretStr("") 

112 credentials_file: Optional[Path] = "" # type: ignore # validator below 

113 

114 class Config: 

115 # We want to make sure assignment to secret strings are validated 

116 # and converted to SecretStr objects, so they don't leak. 

117 validate_assignment = True 

118 

119 @validator("credentials_file", pre=True) 

120 def _empty_string_is_none(cls, v: Any) -> Any: 

121 """We can't serialize None to TOML, so we convert it to an empty string. 

122 However, passing an empty string to Path() will return the current working 

123 directory, so we need to convert it back to None.""" 

124 # I really wish TOML had a None type... 

125 if v == "": 

126 return None 

127 return v 

128 

129 @validator("credentials_file") 

130 def _validate_credentials_file(cls, v: Path | None) -> Path | None: 

131 if v is not None: 

132 if not v.exists(): 

133 raise ValueError(f"Credentials file {v} does not exist") 

134 elif not v.is_file(): 

135 raise ValueError(f"Credentials file {v} is not a file") 

136 return v 

137 

138 def ensure_authable(self) -> bool: 

139 """Ensures that the credentials are sufficient to authenticate with the Habror API. 

140 Raises CredentialsError if not. 

141 """ 

142 if not self.url: 

143 raise CredentialsError("A Harbor API URL is required") 

144 

145 # require one of the auth methods to be set 

146 if not self.has_auth_method: 

147 raise CredentialsError( 

148 "A harbor authentication method must be specified. " 

149 "One of 'username'+'secret', 'basicauth', or 'credentials_file' must be specified. " 

150 "See the documentation for more information." 

151 ) 

152 return True 

153 

154 @property 

155 def has_auth_method(self) -> bool: 

156 """Returns True if any of the auth methods are set.""" 

157 return bool( 

158 (self.username and self.secret) or self.basicauth or self.credentials_file 

159 ) 

160 

161 @property 

162 def credentials(self) -> HarborCredentialsKwargs: 

163 """Fetches kwargs that can be passed to HarborAsyncClient for 

164 user authentication. 

165 

166 Returns 

167 ------- 

168 HarborCredentialsKwargs 

169 A dictionary with either base64 credentials, username and password 

170 or a path to a credentials file. 

171 """ 

172 return HarborCredentialsKwargs( 

173 url=self.url, 

174 username=self.username, 

175 secret=self.secret.get_secret_value(), 

176 credentials=self.basicauth.get_secret_value(), 

177 credentials_file=self.credentials_file, 

178 ) 

179 

180 

181class LoggingSettings(BaseModel): 

182 enabled: bool = True 

183 structlog: bool = False 

184 level: LogLevel = LogLevel.INFO 

185 

186 

187class TableSettings(BaseModel): 

188 """Settings for the table output format.""" 

189 

190 description: bool = False 

191 max_depth: int = 0 

192 compact: bool = True 

193 # TODO: table style 

194 # max_width: Optional[int] = None 

195 # max_lines: Optional[int] = None 

196 

197 @validator("max_depth", pre=True) 

198 def check_max_depth(cls, v: Any) -> Any: 

199 """TOML has no None type, so we interpret negative values as None. 

200 This validator converts negative values to None. 

201 

202 TODO: convert None to -1 when writing to TOML! 

203 """ 

204 if v is None: 

205 return 0 

206 try: 

207 v = int(v) 

208 except ValueError: 

209 raise ValueError("max_depth must be an integer") 

210 # We used to accept negative integers, and to avoid breaking 

211 # existing configs immediately, we just check if the value is negative, 

212 # and if so, return 0. 

213 # In the future, we will use Field(..., ge=0) to enforce it. 

214 if v < 0: 

215 logger.warning( 

216 "max_depth will stop accepting negative values in a future version. Use 0 instead." 

217 ) 

218 return 0 

219 return v 

220 

221 

222class JSONSettings(BaseModel): 

223 indent: int = 2 

224 sort_keys: bool = True 

225 

226 

227class OutputSettings(BaseModel): 

228 format: OutputFormat = OutputFormat.TABLE 

229 paging: bool = Field( 

230 False, 

231 description="Show output in pager (if supported). Does not support color output currently.", 

232 ) 

233 

234 # Custom pager support in Rich doesn't seem very mature out of the box, 

235 # and it would require us to write a custom pager class to support it. 

236 # pager: Optional[str] = Field(None, description="Pager to use if paging is enabled.") 

237 

238 # Naming: Don't shadow the built-in .json() method 

239 # The config file can still use the key "json" because of the alias 

240 table: TableSettings = Field(default_factory=TableSettings) 

241 JSON: JSONSettings = Field(default_factory=JSONSettings, alias="json") 

242 

243 class Config: 

244 allow_population_by_field_name = True 

245 

246 

247class HarborCLIConfig(BaseModel): 

248 harbor: HarborSettings = Field(default_factory=HarborSettings) 

249 logging: LoggingSettings = Field(default_factory=LoggingSettings) 

250 output: OutputSettings = Field(default_factory=OutputSettings) 

251 config_file: Optional[Path] = Field( 

252 None, exclude=True, description="Path to config file (if any)." 

253 ) # populated by CLI if loaded from file 

254 

255 class Config: 

256 json_encoders = { 

257 SecretStr: lambda v: v.get_secret_value() if v else None, 

258 } 

259 

260 @classmethod 

261 def from_file( 

262 cls, config_file: Path | None = DEFAULT_CONFIG_FILE, create: bool = False 

263 ) -> HarborCLIConfig: 

264 """Create a Config object from a TOML file. 

265 

266 Parameters 

267 ---------- 

268 config_file : Path 

269 Path to the TOML file. 

270 If `None`, the default configuration file is used. 

271 create : bool 

272 If `True`, the config file will be created if it does not exist. 

273 

274 Returns 

275 ------- 

276 Config 

277 A Config object. 

278 """ 

279 if config_file is None: 279 ↛ 280line 279 didn't jump to line 280, because the condition on line 279 was never true

280 config_file = DEFAULT_CONFIG_FILE 

281 

282 if not config_file.exists(): 

283 if create: 

284 create_config(config_file) 

285 else: 

286 raise FileNotFoundError(f"Config file {config_file} does not exist.") 

287 config = load_toml_file(config_file) 

288 return cls(**config, config_file=config_file) 

289 

290 def save(self, path: Path | None = None) -> None: 

291 if not path and not self.config_file: 291 ↛ 292line 291 didn't jump to line 292, because the condition on line 291 was never true

292 raise ValueError("Cannot save config: no config file specified") 

293 p = path or self.config_file 

294 assert p is not None # p shouldn't be None here! am i dumb??? 

295 save_config(self, p) 

296 

297 def toml( 

298 self, 

299 expose_secrets: bool = True, 

300 tomli_kwargs: dict[str, Any] | None = {}, 

301 **kwargs: Any, 

302 ) -> str: 

303 """Return a TOML representation of the config object. 

304 In order to serialize all types properly, the serialization takes 

305 a round-trip through the Pydantic JSON converter. 

306 

307 Parameters 

308 ---------- 

309 expose_secrets : bool 

310 If `True`, secrets will be included in the TOML output. 

311 If `False`, secrets will be replaced with strings of asterisks. 

312 By default, secrets are included. 

313 tomli_kwargs : dict 

314 Dict of keyword arguments passed to `tomli_w.dumps()`. 

315 **kwargs 

316 Keyword arguments passed to `BaseModel.json()`. 

317 

318 Returns 

319 ------- 

320 str 

321 TOML representation of the config as a string. 

322 

323 See Also 

324 -------- 

325 `BaseModel.json()` <https://pydantic-docs.helpmanual.io/usage/exporting_models/#modeljson> 

326 """ 

327 tomli_kwargs = tomli_kwargs or {} 

328 

329 # Roundtrip through JSON to get dict of builtin types 

330 # 

331 # Also replace None values with empty strings, because: 

332 # 1. TOML doesn't have a None type 

333 # 2. Show users that these values _can_ be configured 

334 dict_basic_types = replace_none(json.loads(self.json(**kwargs))) 

335 

336 if not expose_secrets: 

337 for key in ["secret", "basicauth", "credentials_file"]: 

338 if ( 338 ↛ 337line 338 didn't jump to line 337

339 key in dict_basic_types["harbor"] 

340 and dict_basic_types["harbor"][key] # ignore empty values 

341 ): 

342 dict_basic_types["harbor"][key] = "********" 

343 

344 return tomli_w.dumps(dict_basic_types) 

345 

346 

347def create_config(config_path: Path | None, overwrite: bool = False) -> Path: 

348 if config_path is None: 348 ↛ 349line 348 didn't jump to line 349, because the condition on line 348 was never true

349 config_path = DEFAULT_CONFIG_FILE 

350 

351 try: 

352 config_path.parent.mkdir(parents=True, exist_ok=True) 

353 config_path.touch(exist_ok=overwrite) 

354 except FileExistsError as e: 

355 raise OverwriteError(f"Config file {config_path} already exists.") from e 

356 except Exception as e: 

357 logger.bind(exc=e).error("Failed to create config file") 

358 raise ConfigError(f"Could not create config file {config_path}: {e}") from e 

359 

360 # Write sample config to the created file 

361 config_path.write_text(sample_config()) 

362 

363 return config_path 

364 

365 

366def load_config(config_path: Path | None = None) -> HarborCLIConfig: 

367 """Load the config file.""" 

368 try: 

369 return HarborCLIConfig.from_file(config_path) 

370 except HarborCLIError: 

371 raise 

372 except Exception as e: 

373 logger.bind(exc=e).error("Failed to load config file") 

374 raise ConfigError(f"Could not load config file {config_path}: {e}") from e 

375 

376 

377def save_config(config: HarborCLIConfig, config_path: Path) -> None: 

378 """Save the config file.""" 

379 try: 

380 config_path.write_text(config.toml(exclude_none=True)) 

381 except Exception as e: 

382 logger.bind(exc=e).error("Failed to save config file") 

383 raise ConfigError(f"Could not save config file {config_path}: {e}") from e 

384 

385 

386def sample_config(exclude_none: bool = False) -> str: 

387 """Returns the contents of a sample config file as a TOML string. 

388 

389 Parameters 

390 ---------- 

391 exclude_none : bool 

392 If `True`, fields with `None` values will be excluded from the sample 

393 config, otherwise they will be included with empty strings as field values. 

394 Defaults to `False` - all fields will be included. 

395 

396 Returns 

397 ------- 

398 str 

399 Sample config file contents in TOML format. 

400 """ 

401 config = HarborCLIConfig() 

402 return config.toml(exclude_none=exclude_none)