Usage#
Basic usage#
We suggest organizing a project with the following file layout:
BigProject
├── .git/
├── __init__.py
├── main.py
└── config
├── __init__.py
└── defaults.cfg
BigProject
├── .git/
├── __init__.py
├── main.py
└── config
├── __init__.py
├── defaults.cfg
└── 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.cfg"
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):
__default_config_path__ = "defaults.cfg"
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, which in this example
we named defaults.cfg. It might look something like the following
# defaults.cfg
[DEFAULTS]
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
The path to this file is specified by defining the class variable __default_config_path__. When this variable is undefined or None,
Valconfig presumes that no such file exists.
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.cfg
[DEFAULTS]
log_name = Jane
use_gpu = False
data_source = /home/Jane/project-data
# local.cfg
[DEFAULTS]
log_name = Jane
use_gpu = True
data_source = /shared-data/BigProject
# local.cfg
[DEFAULTS]
log_name = Mary
use_gpu = False
data_source = D:\project-data
We correspondingly add local.cfg to the file layout and the Config definition:
BigProject
├── .git/
├── __init__.py
├── local.cfg
├── main.py
└── config
├── __init__.py
├── defaults.cfg
└── valconfig.py
Config definition# config/__init__.py
from valconfig import ValConfig
from pathlib import Path
from typing import Optional
from pydantic import HttpUrl
from scityping.numpy import Array
class Config(ValConfig):
__default_config_path__ = "defaults.cfg"
__local_config_filename__ = "local.cfg"
data_source: Optional[Path]
log_name: Optional[str]
use_gpu: bool
url: HttpUrl
n_units: int
connectivites: Array[float, 2]
config = Config()
When Config instantiates, it does the following:
Parse the file at the location pointed to by
__default_config_path__and instantiate theconfiginstance.Search the current directory for a file matching
__local_config_filename__.
If one is found, it is parsed andconfigupdated.Move up the directory tree and search again for a file matching
__local_config_filename__.
ValConfigwill continue moving up the directory tree until it hits the root directory.[1]
In our example, to find local.cfg, the project would need to be executed from
within BigProject or one of its subdirectories.
Hint
We can think of repositories as being used either as a “project” or a “library” – where library repositories are 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.<default>: Use the default defined in theBaseModel. Can be used to unset an option from another config file.
To add your own substitutions, update the dictionary __value_subsitutions__
in your Config subclass.
Relative path resolution#
When a value, after validation, is an instance of Path, then it is resolved with the following rules:
If the path is absolute (i.e. it starts with
/), it is not changed.If the path is relative, it is prepended with the directory in which it was defined. For example, if the file
~/my-projects/projectA/local.cfgdefines the path"../data/"it will be resolved to
"~/my-projects/data/"
Hierarchical fields#
TODO: Side-by-side cards
Extending a configuration / Configuration templates#
TODO: Example: add a field to contrib.FiguresConfig
Config class options#
The behaviour of a ValConfig subclass can be customized by setting class
variables. Three have already introduced: __default_config_path__,
__local_config_filename__, __value_substitutions__. The full list is as follows:
__default_config_path__Path to the config file containing defaults. Path is relative to the directory defining the
Configclass (in our example, path is relative to config/)__local_config_filename__Filename to search for local configuration. If
None, no search is performed. Typically this is set toNonefor library packages and a string value for project packages: library packages are best configured by modifying theirconfigobject (perhaps within a project’s ownconfig), than by using a local file. If no file is found, and__create_template_config__isTrue(the default), then a blank config file with instructions is created at the root of the project repository. Default value isNone.__value_substitutions__Dictionary of substitutions for plain text values. Substitutions are applied before other validators, so they can be used to convert invalid values to valid ones, or to avoid interpreting the value as a string.
__create_template_config__Whether a template config file should be created in a standard location when no local config file is found. This has no effect when
__local_config_filename__isNone. The default isTrue, which is equivalent to letting__local_config_filename__determine whether to create a template config. In almost all cases this default should suffice. Typically this is set toFalsefor utility packages, andTruefor project packages.__interpolation__Passed as argument to
ConfigParser. Default isExtendedInterpolation. (Note that, as withConfigParser, an instance must be passed.)__empty_lines_in_values__Passed as argument to
ConfigParser. Default isTrue: this prevents multiline values with empty lines, but makes it much easier to indent without accidentally concatenating values.__top_message_default__The instruction message added to the top of a template config file when it is created.
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
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 ValConfig
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")
def check_gpu(cls, v):
if v and not torch.has_cuda:
print("Cannot use GPU: torch reports CUDA is not available.")
v = False
return v
By default, custom validators are run after a value has been cast to the
prescribed type. To run a validator before type casting, add the pre keyword:
from pathlib import Path
from valconfig import ValConfig
class Config(ValConfig):
data_source: Optional[Path]
@validator("data_source", pre=True):
def default_source(cls, src):
if src is None:
src = "../data" # Because we use pre=True, we don’t need to cast to Path
return src
There is a lot more one can do with validators, as detailed in Pydantic’s documentation.