Overview of the process

How is a configuration processed by configpile, for example when calling from_command_line_()?

  1. First, the configuration class, which is a dataclass() inheriting from Config is inspected. All the dataclass fields should be annotated with a Param instance, see Params (parameters). configpile makes a list of triggers, for environment variables, INI files key/value pairs, and command-line arguments.

  2. For each parameter, configpile prepares a list of values. If the parameter does not have a default value, the list is initially empty. If the parameter has a default value, this value is parsed (see Parsers) and stored in the list.

  3. Then, configpile inspects environment variables. If any of the environment variables corresponds to a parameter, the variable content is parsed and added to the list.

    If any environment variable points to an INI file, the file is read, and for each parameter key present in the INI file, the corresponding value is parsed and added to the list.

  4. After that, configpile processes command-line arguments. Some arguments will point to an INI file: again, those files are parsed and the corresponding values added to the list of the corresponding parameter. Some arguments provide parameter values directly: those values are parsed and again added to the corresponding list.

  5. At this point, configpile may have failed to parse some of the strings, have had problems reading a configuration file, or some values may have failed validation (see Parameter and configuration validation). All these errors are collected and reported together to the user.

  6. If everything is fine, configpile looks at each parameter in turn. Now, each parameter corresponds to a list of values. The question is how to process this list to populate the configuration with a single value. This is the job of the Collector: the standard collectors provided by configpile either append the values together (useful when collecting a list of files to process, for example), or return the last value (useful when later parameter value override the values provided previously).

    This process can eventually fail for some or all the parameters: for example, if a collector needs to return the last value provided, and nothing was provided at all, it will error. All the errors are collected together, in that case, and reported to the user.

  7. The next step is the construction of the configuration dataclass, with the computed parameter values. After that, the validation methods of the configuration dataclass are called for the final validation round, see Parameter and configuration validation. If everything works, the constructed dataclass is returned.

This whole process is short-circuited in a few cases.

  • By default, the command-line flags -h and --help display the usage information and exit the program using sys.exit().

  • In the future, we may support other special actions such as displaying the program version using a --version flag for example.

There are two static methods on Config that can be called to construct a configuration.

  • The from_command_line_() method, in case of errors, displays usage information and exits through sys.exit().

  • The parse_command_line_() method, always returns to the caller, and returns either a constructed configuration dataclass, a token corresponding to a special action (such as a command to display help information or the version number), or finally a Err error.

Demonstrating the process

Here is a simple configuration class, and we will make its construction fail at several points in the construction.

from configpile import *
from dataclasses import dataclass
from typing import Optional
from typing_extensions import Annotated

@dataclass(frozen=True)
class TestConfig(Config):
    """
    A superb program
    """

    #: First parameter, must be greater than the second parameter
    a: Annotated[int, Param.store(parsers.int_parser, short_flag_name='-a')]

    #: Second parameter
    b: Annotated[int, Param.store(parsers.int_parser, short_flag_name='-b')]

    def validate_a_greater_than_b(self) -> Optional[Err]:
        if not self.a > self.b:
            return Err.make(f"Parameter a={self.a} should be > than b={self.b}")
        return None

Parse errors

Note that all parse errors are collected and reported. The rest of the construction process is not done.

res = TestConfig.parse_command_line_(args = ['-a', 'invalid', '-b','also_invalid', '-a', 'last_invalid'], env = {})
res
ManyErr(errs=[Err1(msg="Error 'invalid literal for int() with base 10: 'invalid'' in 'invalid'", contexts=[('flag', '-a'), ('param', 'a')]), Err1(msg="Error 'invalid literal for int() with base 10: 'also_invalid'' in 'also_invalid'", contexts=[('flag', '-b'), ('param', 'b')]), Err1(msg="Error 'invalid literal for int() with base 10: 'last_invalid'' in 'last_invalid'", contexts=[('flag', '-a'), ('param', 'a')])])
res.pretty_print()
In flag: -a                                                                               
In param: a                                                                            
       0 Error 'invalid literal for int() with base 10: 'invalid'' in 'invalid'              
       1 Error 'invalid literal for int() with base 10: 'last_invalid'' in 'last_invalid'    

 0 In flag: -b                                                                               
   In param: b                                                                               
   Error 'invalid literal for int() with base 10: 'also_invalid'' in 'also_invalid'          

Collection errors

Those errors happen during the collection process. Here, because of missing values.

res = TestConfig.parse_command_line_(args = ['-a', '2'], env = {})
res
Err1(msg='Argument is required', contexts=[('param', 'b')])
res.pretty_print()
In param: b                                                                                  

Argument is required                                                                         

Instance-level validation errors

Those errors are reported in a last step, and will only be shown if the previous steps were completed successfully.

res = TestConfig.parse_command_line_(args = ['-a', '2', '-b','3'], env = {})
res
Err1(msg='Parameter a=2 should be > than b=3', contexts=[])