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
  }
}