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 88f01a9..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,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 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/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..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" @@ -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" diff --git a/src/maison/config.py b/src/maison/config.py index 59da1dd..577ec17 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__. @@ -47,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__. @@ -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]: 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/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 c7fdc17..848b90f 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 @@ -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"" def test_repr_no_config_path(self, create_tmp_file: Callable[..., Path]) -> None: """ @@ -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) == "" def test_to_dict(self, create_pyproject_toml: Callable[..., Path]) -> None: """ @@ -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( @@ -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" @@ -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"}, @@ -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, @@ -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], @@ -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