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
« prev ^ index » next coverage.py v6.5.0, created at 2023-02-09 12:09 +0100
1from __future__ import annotations
3import json
4from pathlib import Path
5from typing import Any
6from typing import Optional
7from typing import TypedDict
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
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
28DEFAULT_CONFIG_FILE = CONFIG_DIR / "config.toml"
30ENV_VAR_PREFIX = "HARBOR_CLI_"
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(".", "_")
38def env_var(option: str) -> str:
39 """Return the environment variable name for a CLI option."""
40 return ENV_VAR_PREFIX + option.upper().replace("-", "_")
43def load_toml_file(config_file: Path) -> dict[str, Any]:
44 """Load a TOML file and return the contents as a dict.
46 Parameters
47 ----------
48 config_file : Path,
49 Path to the TOML file to load.
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
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."""
71 # https://pydantic-docs.helpmanual.io/usage/model_config/#change-behaviour-globally
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.
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.
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
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
99class HarborCredentialsKwargs(TypedDict):
100 url: str
101 username: str
102 secret: str
103 credentials: str
104 credentials_file: Optional[Path]
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
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
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
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
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")
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
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 )
161 @property
162 def credentials(self) -> HarborCredentialsKwargs:
163 """Fetches kwargs that can be passed to HarborAsyncClient for
164 user authentication.
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 )
181class LoggingSettings(BaseModel):
182 enabled: bool = True
183 structlog: bool = False
184 level: LogLevel = LogLevel.INFO
187class TableSettings(BaseModel):
188 """Settings for the table output format."""
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
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.
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
222class JSONSettings(BaseModel):
223 indent: int = 2
224 sort_keys: bool = True
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 )
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.")
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")
243 class Config:
244 allow_population_by_field_name = True
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
255 class Config:
256 json_encoders = {
257 SecretStr: lambda v: v.get_secret_value() if v else None,
258 }
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.
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.
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
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)
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)
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.
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()`.
318 Returns
319 -------
320 str
321 TOML representation of the config as a string.
323 See Also
324 --------
325 `BaseModel.json()` <https://pydantic-docs.helpmanual.io/usage/exporting_models/#modeljson>
326 """
327 tomli_kwargs = tomli_kwargs or {}
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)))
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] = "********"
344 return tomli_w.dumps(dict_basic_types)
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
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
360 # Write sample config to the created file
361 config_path.write_text(sample_config())
363 return config_path
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
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
386def sample_config(exclude_none: bool = False) -> str:
387 """Returns the contents of a sample config file as a TOML string.
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.
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)