Skip to content

Commit

Permalink
Merge pull request #31 from dbatten5/no-schema-error
Browse files Browse the repository at this point in the history
 Raise no schema error
  • Loading branch information
dbatten5 committed Nov 1, 2021
2 parents f21259b + 8a13009 commit 70a732a
Show file tree
Hide file tree
Showing 9 changed files with 137 additions and 36 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Maison

[![Actions Status](https://github.com/dbatten5/maison/workflows/Tests/badge.svg)](https://github.com/dbatten5/maison/actions)
[![Actions Status](https://github.com/dbatten5/maison/workflows/Release/badge.svg)](https://github.com/dbatten5/maison/actions)
[![codecov](https://codecov.io/gh/dbatten5/maison/branch/main/graph/badge.svg?token=948J8ECAQT)](https://codecov.io/gh/dbatten5/maison)

# Maison
[![PyPI version](https://badge.fury.io/py/maison.svg)](https://badge.fury.io/py/maison)

Read configuration settings from `python` configuration files.

Expand Down
12 changes: 10 additions & 2 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ options:

By default, `maison` will look for a `pyproject.toml` file. If you prefer to look
elsewhere, provide a `source_files` list to `ProjectConfig` and `maison` will select the
first source file it finds from the list. Note that there is no merging of configs.
first source file it finds from the list. Note that there is no merging of configs if
multiple files are discovered.


```python
Expand Down Expand Up @@ -107,7 +108,14 @@ config.validate()
```

If the configuration is invalid, a `pydantic` `ValidationError` will be raised. If the
configuration is valid, nothing will happen.
configuration is valid, the validated values are returned.

If `validate` is invoked but no schema has been provided, a `NoSchemaError` will
be raised. A schema can be added after instantiation through a setter:

```python
config.config_schema = MySchema
```

## Casting and default values

Expand Down
2 changes: 1 addition & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ plugins:
python:
rendering:
show_root_heading: true
heading_level: 1

watch:
- src
- autolinks
Expand Down
4 changes: 2 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "maison"
version = "1.2.2"
version = "1.2.3"
description = "Maison"
authors = ["Dom Batten <[email protected]>"]
license = "MIT"
Expand All @@ -23,6 +23,7 @@ Changelog = "https://github.com/dbatten5/maison/releases"
python = "^3.6.1"
click = "^8.0.1"
pydantic = "^1.8.2"
toml = "^0.10.2"

[tool.poetry.dev-dependencies]
pytest = "^6.2.4"
Expand Down
60 changes: 42 additions & 18 deletions src/maison/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import Optional
from typing import Type

from maison.errors import NoSchemaError
from maison.schema import ConfigSchema
from maison.utils import _find_config

Expand Down Expand Up @@ -39,15 +40,15 @@ def __init__(
)
self._config_dict: Dict[str, Any] = config_dict or {}
self.config_path: Optional[Path] = config_path
self.config_schema = config_schema
self._config_schema: Optional[Type[ConfigSchema]] = config_schema

def __repr__(self) -> str:
"""Return the __repr__.
Returns:
the representation
"""
return f"{self.__class__.__name__} (config_path={self.config_path})"
return f"<{self.__class__.__name__} config_path:{self.config_path}>"

def __str__(self) -> str:
"""Return the __str__.
Expand All @@ -65,45 +66,68 @@ def to_dict(self) -> Dict[str, Any]:
"""
return self._config_dict

@property
def config_schema(self) -> Optional[Type[ConfigSchema]]:
"""Return the `config_schema`.
Returns:
the `config_schema`
"""
return self._config_schema

@config_schema.setter
def config_schema(self, config_schema: Type[ConfigSchema]) -> None:
"""Set the `config_schema`."""
self._config_schema = config_schema

def validate(
self,
config_schema: Optional[Type[ConfigSchema]] = None,
use_schema_values: bool = True,
) -> None:
) -> Dict[str, Any]:
"""Validate the configuration.
Note that this will cast values to whatever is defined in the schema. For
example, for the following schema:
Warning:
Using this method with `use_schema_values` set to `True` will cast values to
whatever is defined in the schema. For example, for the following schema:
class Schema(ConfigSchema):
foo: str
class Schema(ConfigSchema):
foo: str
Validating a config with:
Validating a config with:
{"foo": 1}
{"foo": 1}
Will result in:
Will result in:
{"foo": "1"}
{"foo": "1"}
Args:
config_schema: an optional `pydantic` base model to define the schema. This
config_schema: an optional `ConfigSchema` to define the schema. This
takes precedence over a schema provided at object instantiation.
use_schema_values: an optional boolean to indicate whether the result
of passing the config through the schema should overwrite the existing
config values, meaning values are cast to types defined in the schema as
described above, and default values defined in the schema are used.
Returns:
the config values
Raises:
NoSchemaError: when validation is attempted but no schema has been provided
"""
validated_schema: Optional[ConfigSchema] = None
if not (config_schema or self.config_schema):
raise NoSchemaError

if config_schema:
validated_schema = config_schema(**self._config_dict)
elif self.config_schema:
validated_schema = self.config_schema(**self._config_dict)
schema: Type[ConfigSchema] = config_schema or self.config_schema # type: ignore

if validated_schema and use_schema_values:
validated_schema: ConfigSchema = schema(**self._config_dict)

if use_schema_values:
self._config_dict = validated_schema.dict()

return self._config_dict

def get_option(
self, option_name: str, default_value: Optional[Any] = None
) -> Optional[Any]:
Expand Down
5 changes: 5 additions & 0 deletions src/maison/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Module to define custom errors."""


class NoSchemaError(Exception):
"""Raised when validation is attempted but no schema has been provided."""
2 changes: 1 addition & 1 deletion src/maison/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def _find_config(
project_name: str,
source_files: List[str],
starting_path: Optional[Path] = None,
) -> Tuple[Optional[Path], Dict[str, Any]]: # pragma: no cover
) -> Tuple[Optional[Path], Dict[str, Any]]:
"""Find the desired config file.
Args:
Expand Down
80 changes: 71 additions & 9 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pydantic import ValidationError

from maison.config import ProjectConfig
from maison.errors import NoSchemaError
from maison.schema import ConfigSchema


Expand All @@ -22,7 +23,7 @@ def test_repr(self, create_tmp_file: Callable[..., Path]) -> None:

config = ProjectConfig(project_name="foo", starting_path=pyproject_path)

assert str(config) == f"ProjectConfig (config_path={pyproject_path})"
assert str(config) == f"<ProjectConfig config_path:{pyproject_path}>"

def test_repr_no_config_path(self, create_tmp_file: Callable[..., Path]) -> None:
"""
Expand All @@ -34,7 +35,7 @@ def test_repr_no_config_path(self, create_tmp_file: Callable[..., Path]) -> None

config = ProjectConfig(project_name="foo", starting_path=pyproject_path)

assert str(config) == "ProjectConfig (config_path=None)"
assert str(config) == "<ProjectConfig config_path:None>"

def test_to_dict(self, create_pyproject_toml: Callable[..., Path]) -> None:
"""
Expand Down Expand Up @@ -116,7 +117,26 @@ def test_not_found(self) -> None:
"""
config = ProjectConfig(project_name="foo", source_files=["foo"])

assert str(config) == "ProjectConfig (config_path=None)"
assert config.config_path is None
assert config.to_dict() == {}

def test_unrecognised_file_extension(
self,
create_tmp_file: Callable[..., Path],
) -> None:
"""
Given a source with a file extension that isn't recognised,
When the `ProjectConfig` is instantiated with the source,
Then the config dict is empty
"""
source_path = create_tmp_file(filename="foo.txt")
config = ProjectConfig(
project_name="foo",
source_files=["foo.txt"],
starting_path=source_path,
)

assert config.config_path is None
assert config.to_dict() == {}

def test_single_valid_toml_source(
Expand Down Expand Up @@ -161,7 +181,7 @@ def test_multiple_valid_toml_sources(

result = config.get_option("bar")

assert str(config) == f"ProjectConfig (config_path={source_path_1})"
assert config.config_path == source_path_1
assert result == "baz"


Expand All @@ -188,7 +208,7 @@ def test_valid_ini_file(self, create_tmp_file: Callable[..., Path]) -> None:
source_files=["foo.ini"],
)

assert str(config) == f"ProjectConfig (config_path={source_path})"
assert config.config_path == source_path
assert config.to_dict() == {
"section 1": {"option_1": "value_1"},
"section 2": {"option_2": "value_2"},
Expand All @@ -202,15 +222,14 @@ def test_no_schema(self) -> None:
"""
Given an instance of `ProjectConfig` with no schema,
When the `validate` method is called,
Then nothing happens
Then a `NoSchemaError` is raised
"""
config = ProjectConfig(project_name="acme", starting_path=Path("/"))

assert config.to_dict() == {}

config.validate()

assert config.to_dict() == {}
with pytest.raises(NoSchemaError):
config.validate()

def test_one_schema_with_valid_config(
self,
Expand Down Expand Up @@ -238,6 +257,31 @@ class Schema(ConfigSchema):

assert config.get_option("bar") == "baz"

def test_one_schema_injected_at_validation(
self,
create_pyproject_toml: Callable[..., Path],
) -> None:
"""
Given an instance of `ProjectConfig` with a given schema,
When the `validate` method is called,
Then the configuration is validated
"""

class Schema(ConfigSchema):
"""Defines schema."""

bar: str

pyproject_path = create_pyproject_toml()
config = ProjectConfig(
project_name="foo",
starting_path=pyproject_path,
)

config.validate(config_schema=Schema)

assert config.get_option("bar") == "baz"

def test_use_schema_values(
self,
create_pyproject_toml: Callable[..., Path],
Expand Down Expand Up @@ -350,3 +394,21 @@ class Schema(ConfigSchema):

with pytest.raises(ValidationError):
config.validate()

def test_setter(self) -> None:
"""
Given an instance of `ProjectConfig`,
When the `config_schema` is set,
Then the `config_schema` can be retrieved
"""

class Schema(ConfigSchema):
"""Defines schema."""

config = ProjectConfig(project_name="foo")

assert config.config_schema is None

config.config_schema = Schema

assert config.config_schema is Schema

0 comments on commit 70a732a

Please sign in to comment.