Usage#
Basic usage#
We suggest organizing a project with the following file layout:
BigProject
├── .git/
├── __init__.py
├── main.py
└── config
├── __init__.py
└── defaults.toml
BigProject
├── .git/
├── __init__.py
├── main.py
└── config
├── __init__.py
├── defaults.toml
└── valconfig.py
Here the Config class is defined inside a module __init__.py,
so that it can be placed alongside a configuration file and still imported from config:
# main.py
from .config import config
...
result = urlopen(config.url)
# config/__init__.py
from valconfig import ValConfig # This line changes between package and source install
from pathlib import Path
from typing import Optional
from pydantic import HttpUrl
from scityping.numpy import Array
class Config(ValConfig):
__default_config_path__ = "defaults.toml"
data_source: Optional[Path]
log_name: Optional[str]
use_gpu: bool
url: HttpUrl
n_units: int
connectivites: Array[float, 2] # 2D array of floats
config = Config()
# config/__init__.py
from .valconfig import ValConfig # This line changes between package and source install
from pathlib import Path
from typing import Optional
from pydantic import HttpUrl
from scityping.numpy import Array
class Config(ValConfig):
data_source: Optional[Path]
log_name: Optional[str]
use_gpu: bool
url: HttpUrl
n_units: int
connectivites: Array[float, 2] # 2D array of floats
config = Config()
Defaults can be specified directly in the Config class, but when possible it
is recommended to specify them in a separate config file defaults.toml.[^defaults-name-can-changed] It might look something like the following
# defaults.toml
data_source = "<None>"
log_name = "<None>"
use_gpu = "False"
n_units = "3"
connectivites = [[.3, -.3, .1],
[.1, .1, -.2],
[.8, 0, -.2]]
url = "http://example.com"
Important
Your Config class should be instantiable without arguments, as
Config(). This means that all parameters should have defaults, either in
the class itself, or in a defaults file.
Finally, it is often convenient to have config available at the top level
of the package. For this we add an import to the root __init__.py file.
# __init__.py
from .config import config
Updating config values#
Because we make Config a singleton, the following are two completely equivalent
ways of updating field values.
# main.py
from .config import config # instance
config.use_gpu = True
# main.py
from .config import Config # class
Config(use_gpu=True)
The keyword form can be useful when updating values programmatically.
That said, if you find yourself updating the config programmatically, consider
whether it might not be better to move that logic to a validator method
of the Config.
User-specific local configuration#
In the example above, data_source, use_gpu and log_name are fields that
may be user- or machine-specific. Suppose for example that two people, Jane
and Mary, are using the BigProject code in different contexts. Both develop
using their own laptops, but Jane’s project is more data heavy, so she tends to
run her analyses on a bigger workstation. The local configuration on each
machine therefore needs to be slightly different. We can accommodate this by
adding local config files:
# local.toml
log_name = "Jane"
use_gpu = "False"
data_source = "/home/Jane/project-data"
# local.toml
log_name = "Jane"
use_gpu = "True"
data_source = "/shared-data/BigProject"
# local.toml
log_name = "Mary"
use_gpu = "False"
data_source = 'D:\project-data'
We correspondingly add local.toml to the file layout
BigProject
├── .git/
├── __init__.py
├── local.toml
├── main.py
└── config
├── __init__.py
└── defaults.toml
BigProject
├── .git/
├── __init__.py
├── local.toml
├── main.py
└── config
├── __init__.py
├── defaults.toml
└── valconfig.py
When Config instantiates, it does the following:
Check
BigProject/configfor a file nameddefaults.toml.Walk up the directory tree, starting at the current directory, until it hits upon a file marking the root of the project. In this case this would be the
.gitfile.[1]As it recurses up the tree, ValConfig keeps track of any
local.tomlfile it encounters.Once ValConfig has found the project root, it parses (“validates” in Pydantic parlance) all configuration values at once, with the following order of precedence:
Keyword arguments passed directly to the
Config(…)callValues defined in local configuration files. If there are many such files, those at deeper levels of the hierarchy (so closest to the current working directory) have higher precedence.
Values defined in the defaults configuration file.
Default values defined in the
Configclass definition.
Hint
We can think of repositories as being used either as a “project” or a “library”, with library repositories being imported by projects. Typically a user-local config file is useful for project repositories.
Special value substitutions#
Config files are typically parsed as text, which leaves it up to the Config
class to define validators which correctly interpret those values. To avoid
having to write custom validators for some common cases, the following special
values are provided:
<None>: Converted toNone.<PROJECTROOT>: InPathtype fields, this will be substituted by the identified project root. A dollar sign$can also be used for the same effect.path1 = "<PROJECTROOT>/this/path/is/relative/to/project/root" path2 = "$/so/is/this/one"
<default>: Scan the sources for a value in reverse order of their precedence, so that defaults defined in theBaseModelordefaults.tomlare preferred. Can be used to unset an option from another config file.
To add your own substitutions, define the dictionary __sentinel_substitutions__
in your Config subclass:
class Config(ValConfig):
__sentinel_substitutions__: ClassVar = ValConfig | {"<SITEURL>": "https://ourcompany.com"}
Relative path resolution#
We apply the following rules to fields of type Path
Absolute paths are never be modified.
Relative paths loaded from a config file can have three different anchors:
A path with no prefix, like
relative/to/file, is relative to the config file.A path with a dot prefix, like
./relative/to/cwd, is relative to the current directory.A path with the special marker
<PROJECTROOT>/root_fileis relative to the project root. The shorthand$/root_filecan also be used for a path relative to the project root.
Relative paths set directly on the Config object are not associated to a config file; these are always relative to the current directory.
Note that the project root is not automatically expanded: it works exactly like the home directory markers.
To fully expand a path, use path.expandprojectroot().
Important
For this logic to apply, the annotation type of a field must be either Path Optional[Path] or Path | None.
If it has other type, e.g. Path | str or Annotated[Path], it will not be recognized as a Path field and no special logic will be applied.
Paths to which ValConfig has applied the logic above will appear as type ConfigPath in the validated config object.
Hierarchical fields#
TODO: Side-by-side cards
Extending a configuration / Configuration templates#
TODO: Example: add a field to contrib.FiguresConfig
Advanced usage: adding logic with validators#
Since a Config class is a normal class, you can all the usual Python functionality
to add arbitrary logic, like overriding __init__ or adding computed fields
via properties:
from valconfig import ValConfig
from pathlib import Path
from typing import Optional
class Config(ValConfig):
data_source: Optional[Path]
log_name: Optional[str]
use_gpu: bool
def __init__(self, **kwds):
kwds["use_gpu"] = False # Modify `kwds` before fields are assigned
super().__init__(**kwds) # <-- Fields are assigned & validators are run here
self.use_gpu = False # Modify fields after they have been assigned
@property
def log_header(self):
return f"{self.logname} ({self.data_source})"
However there should be little use in overriding __init__, since Pydantic
provides validators which can be used to assign arbitrary logic to a field:
import torch
from pydantic import validator
from valconfig import ValConfig
class Config(ValConfig):
use_gpu: bool
@validator("use_gpu", mode="after")
@classmethod
def check_gpu(self, v):
if v and not torch.has_cuda:
print("Cannot use GPU: torch reports CUDA is not available.")
v = False
return v
The "after" model indicates to run the validator after a value has been cast to its target prescribed type. To run a validator before type casting, use the "before" mode:
from pathlib import Path
from valconfig import ValConfig
from typing import Optional
class Config(ValConfig):
tokens: frozenset
@validator("data_source", mode="before"):
@classmethod
def default_source(cls, tks):
return [tk for tk in _tks if not tk.startswith("private_")]
# Because we use mode="before", we don’t need to cast to frozenset
There is a lot more one can do with validators, as detailed in Pydantic’s documentation.