Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Separate config classes #1840

Merged
merged 15 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/build_documentation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ on: # yamllint disable-line rule:truthy
push:
branches: [develop, latest]
pull_request:
branches: [develop, latest]

jobs:
build:
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/lint_code.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ on: # yamllint disable-line rule:truthy
push:
branches: [develop, latest]
pull_request:
branches: [develop, latest]

jobs:
lint_json:
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/test_code.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ on: # yamllint disable-line rule:truthy
push:
branches: [develop, latest]
pull_request:
branches: [develop, latest]

jobs:
test_python:
Expand Down
2 changes: 0 additions & 2 deletions data_safe_haven/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,13 @@
ConfigSubsectionRemoteDesktopOpts,
)
from .pulumi import DSHPulumiConfig, DSHPulumiProject
from .serialisable_config import SerialisableConfig

__all__ = [
"Config",
"ConfigSectionAzure",
"ConfigSectionSHM",
"ConfigSectionSRE",
"ConfigSubsectionRemoteDesktopOpts",
"SerialisableConfig",
"DSHPulumiConfig",
"DSHPulumiProject",
]
Original file line number Diff line number Diff line change
@@ -1,64 +1,23 @@
from __future__ import annotations
"""A YAMLSerialisableModel that can be serialised to and from Azure"""

from typing import Any, ClassVar, TypeVar

import yaml
from pydantic import BaseModel, ValidationError
from yaml import YAMLError

from data_safe_haven.context import Context
from data_safe_haven.exceptions import (
DataSafeHavenConfigError,
DataSafeHavenParameterError,
)
from data_safe_haven.external import AzureApi
from data_safe_haven.utility import YAMLSerialisableModel

T = TypeVar("T", bound="SerialisableConfig")
T = TypeVar("T", bound="AzureSerialisableModel")


class SerialisableConfig(BaseModel, validate_assignment=True):
class AzureSerialisableModel(YAMLSerialisableModel):
"""Base class for configuration that can be written to Azure storage"""

config_type: ClassVar[str] = "SerialisableConfig"
config_type: ClassVar[str] = "AzureSerialisableModel"
filename: ClassVar[str] = "config.yaml"

def to_yaml(self) -> str:
"""Write configuration to a YAML formatted string"""
return yaml.dump(self.model_dump(mode="json"), indent=2)

def upload(self, context: Context) -> None:
"""Upload configuration data to Azure storage"""
azure_api = AzureApi(subscription_name=context.subscription_name)
azure_api.upload_blob(
self.to_yaml(),
self.filename,
context.resource_group_name,
context.storage_account_name,
context.storage_container_name,
)

@classmethod
def from_yaml(cls: type[T], settings_yaml: str) -> T:
"""Construct configuration from a YAML string"""
try:
settings_dict = yaml.safe_load(settings_yaml)
except YAMLError as exc:
msg = f"Could not parse {cls.config_type} configuration as YAML.\n{exc}"
raise DataSafeHavenConfigError(msg) from exc

if not isinstance(settings_dict, dict):
msg = f"Unable to parse {cls.config_type} configuration as a dict."
raise DataSafeHavenConfigError(msg)

try:
return cls.model_validate(settings_dict)
except ValidationError as exc:
msg = f"Could not load {cls.config_type} configuration.\n{exc}"
raise DataSafeHavenParameterError(msg) from exc

@classmethod
def from_remote(cls: type[T], context: Context) -> T:
"""Construct configuration from a YAML file in Azure storage"""
"""Construct an AzureSerialisableModel from a YAML file in Azure storage."""
azure_api = AzureApi(subscription_name=context.subscription_name)
config_yaml = azure_api.download_blob(
cls.filename,
Expand All @@ -72,6 +31,10 @@ def from_remote(cls: type[T], context: Context) -> T:
def from_remote_or_create(
cls: type[T], context: Context, **default_args: dict[Any, Any]
) -> T:
"""
Construct an AzureSerialisableModel from a YAML file in Azure storage, or from
default arguments if no such file exists.
"""
azure_api = AzureApi(subscription_name=context.subscription_name)
if azure_api.blob_exists(
cls.filename,
Expand All @@ -82,3 +45,14 @@ def from_remote_or_create(
return cls.from_remote(context)
else:
return cls(**default_args)

def upload(self, context: Context) -> None:
"""Serialise an AzureSerialisableModel to a YAML file in Azure storage."""
azure_api = AzureApi(subscription_name=context.subscription_name)
azure_api.upload_blob(
self.to_yaml(),
self.filename,
context.resource_group_name,
context.storage_account_name,
context.storage_container_name,
)
4 changes: 2 additions & 2 deletions data_safe_haven/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
UniqueList,
)

from .serialisable_config import SerialisableConfig
from .azure_serialisable_model import AzureSerialisableModel


class ConfigSectionAzure(BaseModel, validate_assignment=True):
Expand Down Expand Up @@ -186,7 +186,7 @@ def update(
)


class Config(SerialisableConfig):
class Config(AzureSerialisableModel):
config_type: ClassVar[str] = "Config"
filename: ClassVar[str] = "config.yaml"
azure: ConfigSectionAzure
Expand Down
4 changes: 2 additions & 2 deletions data_safe_haven/config/pulumi.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from pydantic import BaseModel

from .serialisable_config import SerialisableConfig
from .azure_serialisable_model import AzureSerialisableModel


class DSHPulumiProject(BaseModel, validate_assignment=True):
Expand All @@ -21,7 +21,7 @@ def __hash__(self) -> int:
return hash(self.stack_config)


class DSHPulumiConfig(SerialisableConfig):
class DSHPulumiConfig(AzureSerialisableModel):
config_type: ClassVar[str] = "Pulumi"
filename: ClassVar[str] = "pulumi.yaml"
projects: dict[str, DSHPulumiProject]
Expand Down
50 changes: 12 additions & 38 deletions data_safe_haven/context/context_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@

import yaml
from azure.keyvault.keys import KeyVaultKey
from pydantic import BaseModel, Field, ValidationError, model_validator
from yaml import YAMLError
from pydantic import BaseModel, Field, model_validator

from data_safe_haven import __version__
from data_safe_haven.exceptions import (
Expand All @@ -20,7 +19,7 @@
)
from data_safe_haven.external import AzureApi
from data_safe_haven.functions import alphanumeric
from data_safe_haven.utility import LoggingSingleton, config_dir
from data_safe_haven.utility import LoggingSingleton, YAMLSerialisableModel, config_dir
from data_safe_haven.utility.annotated_types import (
AzureLocation,
AzureLongName,
Expand Down Expand Up @@ -101,8 +100,8 @@ def to_yaml(self) -> str:
return yaml.dump(self.model_dump(), indent=2)


class ContextSettings(BaseModel, validate_assignment=True):
"""Load global and local settings from dotfiles with structure like the following
class ContextSettings(YAMLSerialisableModel):
"""Load available and current contexts from YAML files structured as follows:

selected: acme_deployment
contexts:
Expand All @@ -111,9 +110,15 @@ class ContextSettings(BaseModel, validate_assignment=True):
admin_group_id: d5c5c439-1115-4cb6-ab50-b8e547b6c8dd
location: uksouth
subscription_name: Data Safe Haven (Acme)
acme_testing:
name: Acme Testing
admin_group_id: 32ebe412-e198-41f3-88f6-bc6687eb471b
location: ukwest
subscription_name: Data Safe Haven (Acme Testing)
...
"""

config_type: ClassVar[str] = "ContextSettings"
selected_: str | None = Field(..., alias="selected")
contexts: dict[str, Context]
logger: ClassVar[LoggingSingleton] = LoggingSingleton()
Expand Down Expand Up @@ -220,49 +225,18 @@ def remove(self, key: str) -> None:
if key == self.selected:
self.selected = None

@classmethod
def from_yaml(cls, settings_yaml: str) -> ContextSettings:
try:
settings_dict = yaml.safe_load(settings_yaml)
except YAMLError as exc:
msg = f"Could not parse context settings as YAML.\n{exc}"
raise DataSafeHavenConfigError(msg) from exc

if not isinstance(settings_dict, dict):
msg = "Unable to parse context settings as a dict."
raise DataSafeHavenConfigError(msg)

try:
return ContextSettings.model_validate(settings_dict)
except ValidationError as exc:
msg = f"Could not load context settings.\n{exc}"
raise DataSafeHavenParameterError(msg) from exc

@classmethod
def from_file(cls, config_file_path: Path | None = None) -> ContextSettings:
if config_file_path is None:
config_file_path = cls.default_config_file_path()
cls.logger.info(
f"Reading project settings from '[green]{config_file_path}[/]'."
)
try:
with open(config_file_path, encoding="utf-8") as f_yaml:
settings_yaml = f_yaml.read()
return cls.from_yaml(settings_yaml)
except FileNotFoundError as exc:
msg = f"Could not find file {config_file_path}.\n{exc}"
raise DataSafeHavenConfigError(msg) from exc

def to_yaml(self) -> str:
return yaml.dump(self.model_dump(by_alias=True), indent=2)
return cls.from_filepath(config_file_path)

def write(self, config_file_path: Path | None = None) -> None:
"""Write settings to YAML file"""
if config_file_path is None:
config_file_path = self.default_config_file_path()
# Create the parent directory if it does not exist then write YAML
config_file_path.parent.mkdir(parents=True, exist_ok=True)

with open(config_file_path, "w", encoding="utf-8") as f_yaml:
f_yaml.write(self.to_yaml())
self.to_filepath(config_file_path)
self.logger.info(f"Saved context settings to '[green]{config_file_path}[/]'.")
6 changes: 6 additions & 0 deletions data_safe_haven/utility/__init__.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
from .annotated_types import AzureLocation, AzureLongName, Guid
from .directories import config_dir
from .enums import DatabaseSystem, SoftwarePackageCategory
from .file_reader import FileReader
from .logger import LoggingSingleton, NonLoggingSingleton
from .singleton import Singleton
from .types import PathType
from .yaml_serialisable_model import YAMLSerialisableModel

__all__ = [
"AzureLocation",
"AzureLongName",
"config_dir",
"DatabaseSystem",
"FileReader",
"Guid",
JimMadge marked this conversation as resolved.
Show resolved Hide resolved
"LoggingSingleton",
"NonLoggingSingleton",
"PathType",
"Singleton",
"SoftwarePackageCategory",
"YAMLSerialisableModel",
]
67 changes: 67 additions & 0 deletions data_safe_haven/utility/yaml_serialisable_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""A pydantic BaseModel that can be serialised to and from YAML"""

from pathlib import Path
from typing import ClassVar, TypeVar

import yaml
from pydantic import BaseModel, ValidationError

from data_safe_haven.exceptions import (
DataSafeHavenConfigError,
DataSafeHavenParameterError,
)

from .types import PathType

T = TypeVar("T", bound="YAMLSerialisableModel")


class YAMLSerialisableModel(BaseModel, validate_assignment=True):
"""
A pydantic BaseModel that can be serialised to and from YAML
"""

config_type: ClassVar[str] = "YAMLSerialisableModel"

@classmethod
def from_filepath(cls: type[T], config_file_path: PathType) -> T:
"""Construct a YAMLSerialisableModel from a YAML file"""
try:
with open(Path(config_file_path), encoding="utf-8") as f_yaml:
settings_yaml = f_yaml.read()
return cls.from_yaml(settings_yaml)
except FileNotFoundError as exc:
msg = f"Could not find file {config_file_path}.\n{exc}"
raise DataSafeHavenConfigError(msg) from exc

@classmethod
def from_yaml(cls: type[T], settings_yaml: str) -> T:
"""Construct a YAMLSerialisableModel from a YAML string"""
try:
settings_dict = yaml.safe_load(settings_yaml)
except yaml.YAMLError as exc:
msg = f"Could not parse {cls.config_type} configuration as YAML.\n{exc}"
raise DataSafeHavenConfigError(msg) from exc

if not isinstance(settings_dict, dict):
msg = f"Unable to parse {cls.config_type} configuration as a dict."
raise DataSafeHavenConfigError(msg)

try:
return cls.model_validate(settings_dict)
except ValidationError as exc:
msg = f"Could not load {cls.config_type} configuration.\n{exc}"
raise DataSafeHavenParameterError(msg) from exc

def to_filepath(self, config_file_path: PathType) -> None:
"""Serialise a YAMLSerialisableModel to a YAML file"""
# Create the parent directory if it does not exist then write YAML
_config_file_path = Path(config_file_path)
_config_file_path.parent.mkdir(parents=True, exist_ok=True)

with open(_config_file_path, "w", encoding="utf-8") as f_yaml:
f_yaml.write(self.to_yaml())

def to_yaml(self) -> str:
"""Serialise a YAMLSerialisableModel to a YAML string"""
return yaml.dump(self.model_dump(by_alias=True, mode="json"), indent=2)
Loading