Skip to content

Commit

Permalink
test: Add tests for the ref package
Browse files Browse the repository at this point in the history
  • Loading branch information
lewisjared committed Nov 7, 2024
1 parent 0720e01 commit 360e45d
Show file tree
Hide file tree
Showing 13 changed files with 516 additions and 6 deletions.
4 changes: 4 additions & 0 deletions packages/ref/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ classifiers = [
"Topic :: Scientific/Engineering",
]
dependencies = [
"attrs>=24.2.0",
"cattrs>=24.1.2",
"environs>=11.0.0",
"tomlkit>=0.13.2",
"typer>=0.12.5",
]

Expand Down
2 changes: 1 addition & 1 deletion packages/ref/src/ref/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from ref.cli import config, sync

app = typer.Typer()
app = typer.Typer(name="ref")

app.command(name="sync")(sync.sync)
app.add_typer(config.app, name="config")
24 changes: 21 additions & 3 deletions packages/ref/src/ref/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,39 @@
View and update the REF configuration
"""

from pathlib import Path

import typer

from ref.config import Config
from ref.constants import config_filename

app = typer.Typer(help=__doc__)


@app.command()
def list():
def list(configuration_directory: Path | None = typer.Option(None, help="Configuration directory")) -> None:
"""
Print the current ref configuration
If a configuration directory is provided,
the configuration will attempted to be loaded from the specified directory.
If the configuration file is missing then a
"""
print("config")
try:
if configuration_directory:
config = Config.load(configuration_directory / config_filename, allow_missing=False)
else:
config = Config.default()
except FileNotFoundError:
typer.secho("Configuration file not found", fg=typer.colors.RED)
raise typer.Exit(1)

print(config.dumps(defaults=True))


@app.command()
def update():
def update() -> None:
"""
Print the current ref configuration
"""
Expand Down
2 changes: 1 addition & 1 deletion packages/ref/src/ref/cli/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@


@app.command()
def sync():
def sync() -> None:
"""
Placeholder command for syncing data
""" # noqa: D401
Expand Down
218 changes: 218 additions & 0 deletions packages/ref/src/ref/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
"""Configuration management"""

# The basics of the configuration management takes a lot of inspiration from the
# `esgpull` configuration management system with some of the extra complexity removed.
# https://github.com/ESGF/esgf-download/blob/main/esgpull/config.py

from pathlib import Path
from typing import Any

import tomlkit
from attrs import Factory, define, field
from cattrs import Converter
from cattrs.gen import make_dict_unstructure_fn, override
from tomlkit import TOMLDocument

from ref.constants import config_filename
from ref.env import env


def _pop_empty(d: dict[str, Any]) -> None:
keys = list(d.keys())
for key in keys:
value = d[key]
if isinstance(value, dict):
_pop_empty(value)
if not value:
d.pop(key)


@define
class Paths:
"""
Common paths used by the REF application
"""

data: Path = field(converter=Path)
db: Path = field(converter=Path)
log: Path = field(converter=Path)
tmp: Path = field(converter=Path)

@data.default
def _data_factory(self) -> Path:
return env.path("REF_CONFIGURATION") / "data"

@db.default
def _db_factory(self) -> Path:
return env.path("REF_CONFIGURATION") / "db"

@log.default
def _log_factory(self) -> Path:
return env.path("REF_CONFIGURATION") / "log"

@tmp.default
def _tmp_factory(self) -> Path:
return env.path("REF_CONFIGURATION") / "tmp"


@define
class Db:
"""
Database configuration
"""

filename: str = "sqlite://ref.db"


@define
class Config:
"""
REF configuration
This class is used to store the configuration of the REF application.
"""

paths: Paths = Factory(Paths)
db: Db = Factory(Db)
_raw: TOMLDocument | None = field(init=False, default=None)
_config_file: Path | None = field(init=False, default=None)

@classmethod
def load(cls, config_file: Path, allow_missing: bool = True) -> "Config":
"""
Load the configuration from a file
Parameters
----------
config_file
Path to the configuration file.
This should be a TOML file.
Returns
-------
:
The configuration loaded from the file
"""
if config_file.is_file():
with config_file.open() as fh:
doc = tomlkit.load(fh)
raw = doc
else:
if not allow_missing:
raise FileNotFoundError(f"Configuration file not found: {config_file}")

doc = TOMLDocument()
raw = None
config = _converter_defaults.structure(doc, cls)
config._raw = raw
config._config_file = config_file
return config

def save(self, config_file: Path | None = None) -> None:
"""
Save the configuration as a TOML file
The configuration will be saved to the specified file.
If no file is specified, the configuration will be saved to the file
that was used to load the configuration.
Parameters
----------
config_file
The file to save the configuration to
Raises
------
ValueError
If no configuration file is specified and the configuration was not loaded from a file
"""
if config_file is None:
if self._config_file is None: # pragma: no cover
# I'm not sure if this is possible
raise ValueError("No configuration file specified")
config_file = self._config_file

config_file.parent.mkdir(parents=True, exist_ok=True)

with open(config_file, "w") as fh:
fh.write(self.dumps())

@classmethod
def default(cls) -> "Config":
"""
Load the default configuration
This will load the configuration from the default configuration location,
which is typically the user's configuration directory.
This location can be overridden by setting the `REF_CONFIGURATION` environment variable.
Returns
-------
:
The default configuration
"""
root = env.path("REF_CONFIGURATION")
return cls.load(root / config_filename)

def dumps(self, defaults: bool = True) -> str:
"""
Dump the configuration to a TOML string
Parameters
----------
defaults
If True, include default values in the output
Returns
-------
:
The configuration as a TOML string
"""
return self.dump(defaults).as_string()

def dump(
self,
defaults: bool = True,
) -> TOMLDocument:
"""
Dump the configuration to a TOML document
Parameters
----------
defaults
If True, include default values in the output
Returns
-------
:
The configuration as a TOML document
"""
if defaults:
converter = _converter_defaults
else:
converter = _converter_no_defaults
dump = converter.unstructure(self)
if not defaults:
_pop_empty(dump)
doc = TOMLDocument()
doc.update(dump)
return doc


def _make_converter(omit_default: bool) -> Converter:
conv = Converter(omit_if_default=omit_default, forbid_extra_keys=True)
conv.register_unstructure_hook(Path, str)
conv.register_unstructure_hook(
Config,
make_dict_unstructure_fn(
Config,
conv,
_raw=override(omit=True),
_config_file=override(omit=True),
),
)
return conv


_converter_defaults = _make_converter(omit_default=False)
_converter_no_defaults = _make_converter(omit_default=True)
8 changes: 8 additions & 0 deletions packages/ref/src/ref/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""
Constants used by the REF
"""

config_filename = "ref.toml"
"""
Default name of the configuration file
"""
35 changes: 35 additions & 0 deletions packages/ref/src/ref/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Environment variable management"""

import os

import platformdirs
from environs import Env


def _set_defaults() -> None:
os.environ.setdefault("REF_CONFIGURATION", str(platformdirs.user_config_path("cmip-ref")))


def get_env() -> Env:
"""
Get the current environment
Returns
-------
:
The current environment including any environment variables loaded from the .env file
and any defaults set by this application.
"""
# Set the default values for the environment variables
_set_defaults()

env = Env(expand_vars=True)

# Load the environment variables from the .env file
# This will override any defaults set above
env.read_env(verbose=True)

return env


env = get_env()
13 changes: 13 additions & 0 deletions packages/ref/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import pytest
from ref.config import Config


@pytest.fixture
def config(tmp_path, monkeypatch) -> Config:
monkeypatch.setenv("REF_CONFIGURATION", str(tmp_path / "ref"))

# Uses the default configuration
cfg = Config.load(tmp_path / "ref" / "ref.toml")
cfg.save()

return cfg
Loading

0 comments on commit 360e45d

Please sign in to comment.