Skip to content

Commit

Permalink
Merge pull request #310 from dbatten5/v2.2
Browse files Browse the repository at this point in the history
V2
  • Loading branch information
dbatten5 committed Aug 19, 2024
2 parents 6792792 + 267750d commit da3e0a0
Show file tree
Hide file tree
Showing 18 changed files with 314 additions and 543 deletions.
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ Read configuration settings from configuration files.

## Motivation

When developing a `python` package, e.g a command-line tool, it can be
helpful to allow the user to set their own configuration options to allow them
to tailor the tool to their needs. These options are typically set in files in
the root of a project directory that uses the tool, for example in a
`pyproject.toml` or an `{project_name}.ini` file.
When developing a `python` package, e.g a command-line tool, it can be helpful
to allow the user to set their own configuration options to allow them to tailor
the tool to their needs. These options are typically set in files in the root of
a user's directory that uses the tool, for example in a `pyproject.toml` or an
`{project_name}.ini` file.

`maison` aims to provide a simple and flexible way to read and validate those
configuration options so that they may be used in the package.
Expand All @@ -34,24 +34,24 @@ pip install maison

## Usage

Suppose the following `pyproject.toml` lives somewhere in a project directory:
Suppose the following `pyproject.toml` lives somewhere in a user's directory:

```toml
[tool.acme]
enable_useful_option = true
```

`maison` exposes a `ProjectConfig` class to retrieve values from config files
`maison` exposes a `UserConfig` class to retrieve values from config files
like so:

```python
from maison import ProjectConfig
from maison import UserConfig

from my_useful_package import run_useful_action

config = ProjectConfig(project_name="acme")
config = UserConfig(package_name="acme")

if config.get_option("enable_useful_option"):
if config.values["enable_useful_option"]:
run_useful_action()
```

Expand Down
4 changes: 1 addition & 3 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@
## Main API

```{eval-rst}
.. automodule:: maison.ProjectConfig
.. autoclass:: maison.UserConfig
:members:
:imported-members:
:special-members: __init__
```

## Exceptions
Expand Down
142 changes: 71 additions & 71 deletions docs/usage.md
Original file line number Diff line number Diff line change
@@ -1,100 +1,93 @@
# Usage

## Retrieving values

<!-- ```{eval-rst} -->
<!-- .. click:: maison.__main__:main -->
<!-- :prog: maison -->
<!-- :nested: full -->
<!-- ``` -->
Suppose a `pyproject.toml` file lives in the user's directory:

Once an instance of `ProjectConfig` has been created, values can be retrieved through:

```python
>>> config.get_option("foo")
'bar'
```toml
[tool.acme]
foo = "bar"
```

An optional second argument can be provided to `get_option` which will be returned if
the given option isn't found:

```python
>>> config.get_option("baz", "default")
'default'
```
## Retrieving values

`ProjectConfig` also exposes a `to_dict()` method to return all the config
options:
`UserConfig` objects have a `values` property that behaves as a dict which
allows config values to be retrieved:

```python
>>> config.to_dict()
{'foo': 'bar'}
>>> from maison import UserConfig
>>> config = UserConfig(package_name="acme")
>>> config.values
"{'foo': 'bar'}"
>>> config.values["foo"]
'bar'
>>> "baz" in config.values
False
>>> config.values.get("baz", "qux")
'qux'
```

## Source files

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
elsewhere, provide a `source_files` list to `UserConfig` and `maison` will select the
first source file it finds from the list.


```python
from maison import ProjectConfig
from maison import UserConfig

config = ProjectConfig(
project_name="acme",
config = UserConfig(
package_name="acme",
source_files=["acme.ini", "pyproject.toml"]
)

print(config.config_path)
print(config.path)
#> PosixPath(/path/to/acme.ini)
```

```{caution}
Currently only `.toml` and `.ini` files are supported. For `.ini` files,
`maison` assumes that the whole file is relevant. For `pyproject.toml` files,
`maison` assumes that the relevant section will be in a
`[tool.{project_name}]` section. For other `.toml` files `maison` assumes the whole
file is relevant.
Currently only `.toml` and `.ini` files are supported. For `.ini` files,
`maison` assumes that the whole file is relevant. For `pyproject.toml` files,
`maison` assumes that the relevant section will be in a
`[tool.{package_name}]` section. For other `.toml` files `maison` assumes the whole
file is relevant.
```

To verify which source config file has been found, `ProjectConfig` exposes a
`config_path` property:
To verify which source config file has been found, `UserConfig` exposes a
`path` property:

```python
>>> config.config_path
>>> config.path
PosixPath('/path/to/pyproject.toml')
```

The source file can either be a filename or an absolute path to a config:

```python
from maison import ProjectConfig
from maison import UserConfig

config = ProjectConfig(
project_name="acme",
config = UserConfig(
package_name="acme",
source_files=["~/.config/acme.ini", "pyproject.toml"]
)

print(config.config_path)
print(config.path)
#> PosixPath(/Users/tom.jones/.config/acme.ini)
```

## Merging configs

`maison` offers support for merging multiple configs. To do so, set the `merge_configs`
flag to `True` in the constructor for `ProjectConfig`:
flag to `True` in the constructor for `UserConfig`:

```python
from maison import ProjectConfig
from maison import UserConfig

config = ProjectConfig(
project_name="acme",
config = UserConfig(
package_name="acme",
source_files=["~/.config/acme.toml", "~/.acme.ini", "pyproject.toml"],
merge_configs=True
)

print(config.config_path)
print(config.path)
"""
[
PosixPath(/Users/tom.jones/.config/acme.toml),
Expand All @@ -108,10 +101,10 @@ print(config.get_option("foo"))
```

```{warning}
When merging configs, `maison` merges from **right to left**, ie. rightmost sources
take precedence. So in the above example, if `~/config/.acme.toml` and
`pyproject.toml` both set `nice_option`, the value from `pyproject.toml` will be
returned from `config.get_option("nice_option")`.
When merging configs, `maison` merges from **right to left**, ie. rightmost sources
take precedence. So in the above example, if `~/config/.acme.toml` and
`pyproject.toml` both set `nice_option`, the value from `pyproject.toml` will be
returned from `config.get_option("nice_option")`.
```

## Search paths
Expand All @@ -120,42 +113,48 @@ By default, `maison` searches for config files by starting at `Path.cwd()` and m
the tree until it finds the relevant config file or there are no more parent paths.

You can start searching from a different path by providing a `starting_path` property to
`ProjectConfig`:
`UserConfig`:

```python
from maison import ProjectConfig
from maison import UserConfig

config = ProjectConfig(
project_name="acme",
config = UserConfig(
package_name="acme",
starting_path=Path("/some/other/path")
)

print(config.config_path)
print(config.path)
#> PosixPath(/some/other/path/pyproject.toml)
```

## Validation

`maison` offers optional schema validation using [pydantic](https://pydantic-docs.helpmanual.io/).
`maison` offers optional schema validation.

To validate a configuration, first create a schema which subclasses `ConfigSchema`:
To validate a configuration, first create a schema. The schema should implement
a method called `dict`. This can be achieved by writing the schema as a
`pydantic` model:

```python
from maison import ConfigSchema
from pydantic import BaseModel

class MySchema(ConfigSchema):
class MySchema(BaseModel):
foo: str = "my_default"
```

!!! note ""
`ConfigSchema` offers all the same functionality as the `pydantic` [BaseModel](https://pydantic-docs.helpmanual.io/usage/models/)
```{note}
`maison` validation was built with using `pydantic` models as schemas in mind
but this package doesn't explicitly declare `pydantic` as a dependency so you
are free to use another validation package if you wish, you just need to ensure
that your schema follows the `maison.config._IsSchema` protocol.
```

Then inject the schema when instantiating a `ProjectConfig`:
Then inject the schema when instantiating a `UserConfig`:

```python
from maison import ProjectConfig
from maison import UserConfig

config = ProjectConfig(project_name="acme", config_schema=MySchema)
config = UserConfig(package_name="acme", schema=MySchema)
```

To validate the config, simply run `validate()` on the config instance:
Expand All @@ -164,14 +163,15 @@ To validate the config, simply run `validate()` on the config instance:
config.validate()
```

If the configuration is invalid, a `pydantic` `ValidationError` will be raised. If the
configuration is valid, the validated values are returned.
If the configuration is invalid and if you are using a `pydantic` base model as
your schema, a `pydantic` `ValidationError` will be raised. If the 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
config.schema = MySchema
```

## Casting and default values
Expand All @@ -187,21 +187,21 @@ foo = 1
And a schema that looks like this:

```python
class MySchema(ConfigSchema):
class MySchema(BaseModel):
foo: str
bar: str = "my_default"
```

Running the config through validation will render the following:

```python
config = ProjectConfig(project_name="acme", config_schema=MySchema)
config = UserConfig(package_name="acme", schema=MySchema)

print(config.to_dict())
print(config)
#> {"foo": 1}

config.validate()
print(config.to_dict())
print(config)
#> {"foo": "1", "bar": "my_default"}
```

Expand Down
7 changes: 3 additions & 4 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
"mypy",
"tests",
"typeguard",
"xdoctest",
"docs-build",
)

Expand Down Expand Up @@ -121,7 +120,7 @@ def mypy(session: Session) -> None:
"""Type-check using mypy."""
args = session.posargs or ["src", "tests", "docs/conf.py"]
session.install(".")
session.install("mypy", "pytest", "types-toml")
session.install("mypy", "pytest", "types-toml", "pydantic")
session.run("mypy", *args)
if not session.posargs:
session.run("mypy", f"--python-executable={sys.executable}", "noxfile.py")
Expand All @@ -131,7 +130,7 @@ def mypy(session: Session) -> None:
def tests(session: Session) -> None:
"""Run the test suite."""
session.install(".")
session.install("coverage[toml]", "pytest", "pygments")
session.install("coverage[toml]", "pytest", "pygments", "pydantic")
try:
session.run("coverage", "run", "--parallel", "-m", "pytest", *session.posargs)
finally:
Expand All @@ -156,7 +155,7 @@ def coverage(session: Session) -> None:
def typeguard(session: Session) -> None:
"""Runtime type checking using Typeguard."""
session.install(".")
session.install("pytest", "typeguard", "pygments")
session.install("pytest", "typeguard", "pygments", "pydantic")
session.run("pytest", f"--typeguard-packages={package}", *session.posargs)


Expand Down
13 changes: 12 additions & 1 deletion poetry.lock

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

Loading

0 comments on commit da3e0a0

Please sign in to comment.