fica
documentation#
fica
is a Python library for managing and documenting the structure of user-specified
configurations. With it, you can create Python classes with easily-documentable fields that make it
easy to configure applications with user input and leverage the code analysis and completion tools
provided by your IDE to make development quicker and less error-prone.
Defining configurations#
Configurations in fica
are represented as subclasses of the fica.Config
class which
contain fields set to instances of the fica.Key
class which stores information about the
key (e.g. description, default value, subkeys).
Here’s a simple configuration class:
class MyConfig(fica.Config):
foo = fica.Key(description="a value for foo")
bar = fica.Key(description="a value for bar", default=1)
As shown above, you can provide a description for the key using the description
argument and
a default value using the default
argument. If you don’t provide a default value, fica
will
default the value to None
.
For keys that have nested subconfigurations, you can define a nested fica.Config
class
and pass this to the subkey_container
argument. fica
will handle instantiating and
populating nested config ojects when you instatiate the root config object.
class MyConfig(fica.Config):
foo = fica.Key(description="a value for foo")
class BarValue:
baz = fica.Key(description="a value for baz", default=True)
quux = fica.Key(description="a value for quux", default=1)
bar = fica.Key(description="a value for bar", subkey_container=BarValue)
If you provide a subkey container but do not specify a default value, fica
automatically sets
the default to the special value fica.SUBKEYS
, a singleton object provided by
fica
that represents that a key’s default value should be an instance of its subkey container
class with its default values.
Factories#
For cases in which a new instance of the default value must be generated each time the config is
created, you can pass a zero-argument factory function to the factory
argument to generate the
value. This is very useful for creating keys for pass-by-reference types like lists and
dictionaries or other stateful values:
fica.Key("foo", factory=lambda: [])
my_counter = 0
def my_factory():
global my_counter
my_counter += 1
return my_counter
fica.Key("bar", factory=my_factory)
Note that the factory function is only called if the user does not specify a value for the key.
Validating values#
Keys can also type-check the values users provide using the type_
argument, which
accepts a single type or a tuple of types (like isinstance
).
fica.Key("foo", type_=(int, float))
If type_
is provided and the user inputs a value that is not of the specified type(s), the key
will raise a TypeError
.
For simplicity, if a key is of a specific type or nullable, you can set allow_none
to True
instead of providing NoneType
as one of the allowed types.
fica.Key("foo", type_=(int, float), allow_none=True)
For more complex validations, fica
provides some validators that can be used to check values
specified by the user. These validators are in the fica.validators
module, and instances
of these classes can be passed to the validator
argument of the fica.Key
constructor. When a user specifies a value that is invalid according to this validator, a
ValueError
is raised with a message for the user.
For example, to assert that a value is one of a specific set of possible values, you can use the
fica.validators.choice
validator. This validator takes as its only argument a list of
possible values:
fica.Key("foo", validator=fica.validators.choice([1, 2, 3]))
You can also specify a custom validation function that has been decorated with the
fica.validators.validator
decorator. Validator functions should accept a single argument
and return None
if the value is valid and a string with an error message for the user if it is
invalid. If a validator function does not return a value of type str | None
, a TypeError
is
raised.
@fica.validators.validator
def is_even_validator(value):
if value % 2 != 0:
return f"{value} is not even"
fica.Key("foo", validator=is_even_validator)
fica
checks the type of a value before calling any validators, so if you’re using type_
in
conjunction with a validator, you can rely on the value passed to your validation function being of
the correct type.
A full list of available validators can be found in the API reference.
Using configurations#
The fica.Config
class provides a simple constructor that accepts as its only argument
a dict
mapping strings corresponding to field names to the values specified by the user. Any
fields that don’t have an entry in the dictionary are mapped to their default values in the
resulting instance.
Because the subkey containers are themselves subclasses of fica.Config
, users need only
specify the keys in a nested structure that they wish to edit. In the example above, passing
{"bar": {"baz": False}}
to the MyConfig
constructor would produce a BarValue
instance
with baz
set to False
and quux
set to 1
(its default).
# this class is the same as above, but reproduced here for convenience
class MyConfig(fica.Config):
foo = fica.Key(description="a value for foo")
class BarValue:
baz = fica.Key(description="a value for baz", default=True)
quux = fica.Key(description="a value for quux", default=1)
bar = fica.Key(description="a value for bar", subkey_container=BarValue)
MyConfig({"foo": False}) # results in foo=False, bar={baz=True, quux=1}
MyConfig({"bar": {"baz": False}}) # results in foo=None, bar={baz=False, quux=1}
MyConfig("foo": False, "bar": 3}) # results in foo=False, bar=3
By default, fica
assumes that the name of a key in the user-specified configuration is the same
as the name of the attribute in the fica.Config
subclass (e.g., in the example above,
a user-specified value with key foo
maps to the foo
attribute of MyConfig
). To specify
a different name from the attribute name, add the name
argument to the fica.Key
constructor; when this value is provided, fica
ignores the name of the attribute in the config
class and instead looks for a key with the specified name in the user config. This behavior can be
useful for allowing key names that collide with the names of methods offered by the
fica.Config
class.
class MyConfig(fica.Config):
foo = fica.Key(name="bar")
MyConfig({"bar": 2}) # results in foo=2
Note that the default constructor for fica.Config
has a documentation_mode
argument
that defaults to False
. When fica
creates an instance of this config class to document its
configurations, it will set this argument to True
; this can be useful for cases in which you
override the default constructor to perform validations or set other values before initializing the
config. The example below demonstrates the use of this argument.
class MyConfig(fica.Config):
foo = fica.Key(description="an even number", type_=int)
def __init__(self, user_config, documentation_mode=False):
if not documentation_mode:
if foo % 2 != 0:
raise ValueError("foo is odd!")
super().__init__(user_config, documentation_mode=documentation_mode)
(Note that it is possible to achieve the same effect as in this example with validators, which is
the preferred method for doing so, but we did it this way here to illustrate the use of the
documentation_mode
argument.)
Once you have instantiated the config class, accessing the values of each field is the same as any attribute access in Python. Fields that have subkey containers (and aren’t defaulted/overridden to a value other than an instance of a config class) are mapped to instances of their subkey container class.
>>> my_config = MyConfig()
>>> my_config.foo
>>> my_config.bar.baz
... True
>>> my_config = MyConfig({"bar": 1})
>>> my_config.bar
... 1
By default, a user can override the default value of a key with subkeys to be some other value that
will prevent the nested configurations from being accessible via the fica.Config
instance. For example, consider the following case:
class MyConfig(fica.Config):
class BarValue:
baz = fica.Key(description="a value for baz", default=True)
bar = fica.Key(description="a value for bar", subkey_container=BarValue)
my_config = MyConfig({"bar": 1})
This results in my_config.bar
being set to 1
, meaning that attempts to access fields in the
BarValue
config (i.e. my_config.bar.baz
) will error. To prevent users from being able to
override the subkey container with their own value, set enforce_subkeys
to True
in the
fica.Key
constructor. This will require that the user-specified value for that key be
a dictionary that contains values for the fields of the subkey container.
class MyConfig(fica.Config):
class BarValue:
baz = fica.Key(description="a value for baz", default=True)
bar = fica.Key(
description="a value for bar", subkey_container=BarValue, enforce_subkeys=True)
my_config = MyConfig({"bar": 1}) # throws an error
my_config = MyConfig({"bar": {"baz": False}})
my_config.bar.baz # returns False
To update the values in a fica.Config
object after it has been instantiated, use the
update
method:
class MyConfig(fica.Config):
foo = fica.Key(description="a value for foo")
class BarValue:
baz = fica.Key(description="a value for baz", default=True)
quux = fica.Key(description="a value for quux", default=1)
bar = fica.Key(description="a value for bar", subkey_container=BarValue)
my_config = MyConfig()
my_config.update({"bar": {"quux": 2}})
my_config.bar.baz # returns True
my_config.bar.quux # returns 2
By default, the provided user config dictionary can contain keys that are not present in the config
class, and they are ignored. To validate that a user has not provided unexpected configs (e.g. to
alert the user to typos), set require_valid_keys=True
in the constructor. This setting is also
applied to calls to update
.
# continuing with MyConfig from above
my_config = MyConfig({"baz": 1}, require_valid_keys=True) # throws an error
my_config = MyConfig({"foo": 1}, require_valid_keys=True) # no error
my_config.update({"baz": 1}) # throws an error
fica.Config
also provides a method fica.Config.get_user_config()
for generating
a dictionary that could be passed to the config class constructor to re-create the config. The
returned dictionary contains all keys that are mapped to values other than their defaults, recursing
into keys with subkeys. This can be useful for serializing configuration objects to be reloaded
later.
# continuing with MyConfig from the last example
>>> my_config = MyConfig({"foo": 1, "bar": {"quux": 2}})
>>> my_config.get_user_config()
... {"foo": 1, "bar": {"quux": 2}}
>>> my_config = MyConfig({"foo": 1, "bar": False})
>>> my_config.get_user_config()
... {"foo": 1, "bar": False}
Documenting configurations#
fica
provides a Sphinx extension that can be used to create code blocks for documenting
configurations, their default values, and their descriptions. The main piece of this extension is
the fica
directive, which uses Sphinx’s code blocks to display the configurations. To use the
directive, pass the importable name of a fica.Config
subclass as the only argument to
the directive.
To use the fica
Sphinx extension, add fica.sphinx
to the extensions
list in your Sphinx
conf.py
file:
extensions = [
...,
"fica.sphinx",
]
For example, say that we have the following in a file called fica_demo.py
:
import fica
from typing import Any, Optional
class Config(fica.Config):
foo: bool = fica.Key(
description="a value for foo",
default=False,
)
class BarValue(fica.Config):
baz: int = fica.Key(default=1)
qux: str = fica.Key(
default="qux",
description="a value for qux",
)
bar: BarValue = fica.Key(
description="a value for bar",
subkey_container=BarValue,
)
quuz: Optional[Any] = fica.Key(description="a value for quuz")
class CorgeValue(fica.Config):
grault = fica.Key(description="a value for grault")
corge: CorgeValue = fica.Key(
description="a value for corge",
default=False,
subkey_container=CorgeValue,
)
To document the class fica_demo.Config
, you would use the following in your RST file:
.. fica:: fica_demo.Config
This would produce the following:
foo: false # a value for foo
bar: # a value for bar
baz: 1
qux: qux # a value for qux
quuz: null # a value for quuz
corge: # a value for corge
# Default value: false
grault: null # a value for grault
The default format for configurations is YAML, but you can also choose JSON by setting the
format
option to json
:
.. fica:: fica_demo.Config
:format: json
This produces:
{
"foo": false, // a value for foo
"bar": { // a value for bar
"baz": 1,
"qux": "qux" // a value for qux
},
"quuz": null, // a value for quuz
"corge": { // a value for corge
// Default value: false
"grault": null // a value for grault
}
}