From 8123a21cd6513596bfde67b7bc2e35b8002d9f84 Mon Sep 17 00:00:00 2001 From: Dom Batten Date: Thu, 28 Oct 2021 18:06:32 +0200 Subject: [PATCH 1/6] raise `NoSchemaError` --- docs/usage.md | 7 +++++++ src/maison/config.py | 23 ++++++++++++++++++++++- src/maison/errors.py | 5 +++++ tests/unit/test_config.py | 26 ++++++++++++++++++++++---- 4 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 src/maison/errors.py diff --git a/docs/usage.md b/docs/usage.md index 88f01a9..7d973a6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -109,6 +109,13 @@ config.validate() If the configuration is invalid, a `pydantic` `ValidationError` will be raised. If the configuration is valid, nothing will happen. +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 By default, `maison` will replace the values in the config with whatever comes back from diff --git a/src/maison/config.py b/src/maison/config.py index 59da1dd..0962605 100644 --- a/src/maison/config.py +++ b/src/maison/config.py @@ -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 @@ -39,7 +40,7 @@ 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__. @@ -65,6 +66,20 @@ 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, @@ -93,7 +108,13 @@ class Schema(ConfigSchema): 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. + + Raises: + NoSchemaError: when validation is attempted but no schema has been provided. """ + if not config_schema and not self.config_schema: + raise NoSchemaError + validated_schema: Optional[ConfigSchema] = None if config_schema: diff --git a/src/maison/errors.py b/src/maison/errors.py new file mode 100644 index 0000000..0cab904 --- /dev/null +++ b/src/maison/errors.py @@ -0,0 +1,5 @@ +"""Module to define custom errors.""" + + +class NoSchemaError(Exception): + """Raised when validation is attempted but no schema has been provided.""" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index c7fdc17..075b0ec 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -6,6 +6,7 @@ from pydantic import ValidationError from maison.config import ProjectConfig +from maison.errors import NoSchemaError from maison.schema import ConfigSchema @@ -202,15 +203,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, @@ -350,3 +350,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 From 598632529a54fc833f197ba9eae7e69b593cca0e Mon Sep 17 00:00:00 2001 From: Dom Batten Date: Fri, 29 Oct 2021 18:15:58 +0200 Subject: [PATCH 2/6] improve coverage --- src/maison/config.py | 15 ++++++------- src/maison/utils.py | 2 +- tests/unit/test_config.py | 44 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/maison/config.py b/src/maison/config.py index 0962605..cbaac82 100644 --- a/src/maison/config.py +++ b/src/maison/config.py @@ -102,7 +102,7 @@ class Schema(ConfigSchema): {"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 @@ -110,19 +110,16 @@ class Schema(ConfigSchema): described above, and default values defined in the schema are used. Raises: - NoSchemaError: when validation is attempted but no schema has been provided. + NoSchemaError: when validation is attempted but no schema has been provided """ - if not config_schema and not self.config_schema: + if not (config_schema or self.config_schema): raise NoSchemaError - validated_schema: Optional[ConfigSchema] = None + schema: Type[ConfigSchema] = config_schema or self.config_schema - if config_schema: - validated_schema = config_schema(**self._config_dict) - elif self.config_schema: - validated_schema = self.config_schema(**self._config_dict) + validated_schema: ConfigSchema = schema(**self._config_dict) - if validated_schema and use_schema_values: + if use_schema_values: self._config_dict = validated_schema.dict() def get_option( diff --git a/src/maison/utils.py b/src/maison/utils.py index 6f470d8..f88e3ed 100644 --- a/src/maison/utils.py +++ b/src/maison/utils.py @@ -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: diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 075b0ec..7d45c07 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -120,6 +120,25 @@ def test_not_found(self) -> None: assert str(config) == "ProjectConfig (config_path=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 str(config) == "ProjectConfig (config_path=None)" + assert config.to_dict() == {} + def test_single_valid_toml_source( self, create_pyproject_toml: Callable[..., Path] ) -> None: @@ -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], From 5f6db8a59f2b2aa9b598d1ac0b01e13b80b0a47d Mon Sep 17 00:00:00 2001 From: Dom Batten Date: Sun, 31 Oct 2021 20:22:51 +0100 Subject: [PATCH 3/6] update docs and small updates --- README.md | 5 +++-- docs/usage.md | 5 +++-- mkdocs.yml | 2 +- src/maison/config.py | 28 +++++++++++++++++----------- tests/unit/test_config.py | 2 +- 5 files changed, 25 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 82700d3..945ecf8 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/usage.md b/docs/usage.md index 7d973a6..4855ec8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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 @@ -107,7 +108,7 @@ 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: diff --git a/mkdocs.yml b/mkdocs.yml index 9850c9c..b6809e1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,7 +24,7 @@ plugins: python: rendering: show_root_heading: true - heading_level: 1 + watch: - src - autolinks diff --git a/src/maison/config.py b/src/maison/config.py index cbaac82..577ec17 100644 --- a/src/maison/config.py +++ b/src/maison/config.py @@ -48,7 +48,7 @@ def __repr__(self) -> str: 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__. @@ -84,22 +84,23 @@ 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 `ConfigSchema` to define the schema. This @@ -109,19 +110,24 @@ class Schema(ConfigSchema): 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 """ if not (config_schema or self.config_schema): raise NoSchemaError - schema: Type[ConfigSchema] = config_schema or self.config_schema + schema: Type[ConfigSchema] = config_schema or self.config_schema # type: ignore 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]: diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 7d45c07..a86311c 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -23,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"" def test_repr_no_config_path(self, create_tmp_file: Callable[..., Path]) -> None: """ From 6cad38b4154c22dca9f25bc55ce68f05dd7aa7e8 Mon Sep 17 00:00:00 2001 From: Dom Batten Date: Sun, 31 Oct 2021 20:42:23 +0100 Subject: [PATCH 4/6] fix tests and improve assertions --- tests/unit/test_config.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index a86311c..848b90f 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -35,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) == "" def test_to_dict(self, create_pyproject_toml: Callable[..., Path]) -> None: """ @@ -117,7 +117,7 @@ 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( @@ -136,7 +136,7 @@ def test_unrecognised_file_extension( starting_path=source_path, ) - assert str(config) == "ProjectConfig (config_path=None)" + assert config.config_path is None assert config.to_dict() == {} def test_single_valid_toml_source( @@ -181,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" @@ -208,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"}, From 117a954f50fc147b5f8be2137fa330a555bc778d Mon Sep 17 00:00:00 2001 From: Dom Batten Date: Sun, 31 Oct 2021 20:48:24 +0100 Subject: [PATCH 5/6] add `toml` to main dependencies --- poetry.lock | 4 ++-- pyproject.toml | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index 58c1e33..1adc2d4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -748,7 +748,7 @@ pbr = ">=2.0.0,<2.1.0 || >2.1.0" name = "toml" version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" +category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" @@ -857,7 +857,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.6.1" -content-hash = "6985588fe2f184df65444747222242013b2fd5fcf98057b67220fd2d5fbd2e19" +content-hash = "4d5c838638166d9ff1fa94e8c2a6bdbdbe3d24f608f1928a78957ed1c01089fb" [metadata.files] appdirs = [ diff --git a/pyproject.toml b/pyproject.toml index 8b560cf..84d634c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" From 8a13009065ee85f5a52dcdd81978c2abf6ff4168 Mon Sep 17 00:00:00 2001 From: Dom Batten Date: Mon, 1 Nov 2021 08:57:33 +0100 Subject: [PATCH 6/6] bump poetry version to 1.2.3 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 84d634c..985f246 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "maison" -version = "1.2.2" +version = "1.2.3" description = "Maison" authors = ["Dom Batten "] license = "MIT"