Repository: BrianPugh/cyclopts (language: python)

Issue:
[Discussion] First-class config overrides via pyproject.toml?
Hi! Thanks for building this awesome tool. I'm loving using it so far, and excited to show our users how awesome our CLIs can be. I did want to get your thoughts on how you might envision cyclopts evolving. There are some areas the story seems a little unclear with how configs loaded from pyproject.toml interact with those passed in. These aren't feature requests, but it would be good to get your reaction to these, and the general idea of first-class support for pyproject.toml:

### Meta App Parameter Overrides

While there is a nice example showing how to override values for commands, I was playing around with this a bit and didn't see an immediately obvious way to infer defaults for parameters passed to the meta app. Take the following example, using verbose and quiet logging flags as an example:

```python
log_params = Group(
    "Logging",
    default_parameter=Parameter(
        negative="",  # Disable "--no-" flags
        show_default=False
    ),
    validator=validators.LimitedChoice(),  # Mutually Exclusive Options
)

# This method requires these types to be optional even if they're not
VERBOSE = Annotated[bool | None, Parameter(help="Increase log output", group=log_params)]
QUIET = Annotated[bool | None, Parameter(help="Decrease log output", group=log_params)]

# Separate dict to track actual defaults since we have to have the CLI default to None
DEFAULTS = {
    'verbose': False,
    'quiet': False
}

def get_meta_param(name: str, value: Any, config: dict[str, Any]) -> Any:
    """Get parameter for meta app from config, accounting for pyproject.toml and command line overrides.

    All meta app parameters must be optional/nullable in order to sue this as a flag for whether or not
    they have been set via the command line.

    Args:
        name: Name of the parameter
        value: Value of parameter received from CLI
        config: Config dict from pyproject.toml

    Returns:
        Parameter value
    """
    # Flag was passed via CLI
    if value is not None:
        return value

    # Flag was specified in pyproject.toml
    if config.get(name, None) is not None:
        return config[name]
    
    # Flag was specified in neither, set true default
    return DEFAULTS[name]


@app.meta.default
def meta(
    *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
    verbose: VERBOSE = None,
    quiet: QUIET = None
) -> Any:
    """Meta App. Used to inject configs from pyproject.toml as defaults
    """
    config = load_pyproject_config()

    # The main Cyclopts parsing/conversion
    command, bound = app.parse_args(tokens)

    # Example of using these - the internals here don't matter
    configure_logging(
        verbose=get_meta_param('verbose', verbose, config),
        quiet=get_meta_param('quiet', quiet, config)
    )

    # Other config parsing for commands below here
    ...
```

[edit: in case it's not clear why I felt like I have to use `None` as a default, we otherwise couldn't tell the difference between a passed False config and the method default value of False]

It is entirely possible I am way off the prescribed path here, so if there is an easier way to do this, please let me know. If not, it should be hopefully a little clear how using meta app params ends up feeling a little complex and also locks you out of documenting default values via type hints.

### Config Validation

Configs loaded via pyproject.toml seem to skip the standard validation for CLI parameters. E.g. I could have updated the defaults via pyprject to some invalid combination, and this would not be caught by e.g. `validators.LimitedChoice()`. You can test out the example above, by adding a section to your project.yaml with both `verbose` and `quiet` config set to true.

There's probably a number of ways to work around this, but I think you'd have to load in the pyproject.toml early to get these values set in advance of the current validator logic. Perhaps loading pyproject.toml on init and attaching default_config dict to the App class would be one way to go about this? There are probably some considerations for not accidentally changing the defaults as they show in the CLI help text.

### Schema Validation

It would be really awesome to be able to generate schema validation json for any commands. By this, I mean some compatibility functionality to generate a schema.json file from the command definitions themselves. These schema definitions can be used by IDEs etc to provide inspection capability to users who are adding a section to configure a published tool. An example of ruff's schema.json is https://github.com/astral-sh/ruff/blob/main/ruff.schema.json.

Having a defined schema for pyproject.toml input would also allow auto-coercion for any types from pyproject.toml, so you wouldn't have to manually validate each config.

Thanks again for sharing this library, it really is great, and I'd love to hear more about your thoughts on this problem space.


Discussion / hints:
First off, thanks for all the kind words and for using Cyclopts! Lets see if I can try and answer some of your questions.

> infer defaults for parameters passed to the meta app

If i'm to understand this correctly, it's to determine if the user is explicitly overriding the (possible) value provided in their `pyproject.toml`. A possible solution (albeit not very well tested!) is to use a meta-meta-app 😵 . Meta-apps should work recursively, but I never really tested this as I previously couldn't think of a good use-case.

```
@app.meta.meta.default
def meta_meta(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)]):
    config = load_pyproject_config()

    command, bound = app.parse_args(tokens)

    if command == meta:
        # if bound.arguments['verbose'] is set, then it was provided from the CLI
        bound.arguments.setdefault('verbose', False)
        bound.arguments.setdefault('quiet', False)
        return command(*bound.args, **bounds.kwargs)
    else:
        return command(*bound.args, **bounds.kwargs)
        
        
app.meta.meta()
```

Admittedly, it is getting a bit unwieldy, but at least it's consistent :P. Note: if `meta.meta` doesn't work as expected in this situation, there's a few follow ups:
1. I should fix it.
2. You can just use a totally independent `App(help_flags=[])` object here. The only reason the `meta` app is special in anyway is that it combines help-pages with the main app. In this case, there's nothing additional to document.

> Configs loaded via pyproject.toml seem to skip the standard validation for CLI parameters.

Correct, at that stage Cyclopts has "done it's job." It parsed, converted, and validated CLI arguments, and transformed them into a callable function with arguments. We're then just further modifying those bound arguments.

> Schema Validation

I think as we get to this stage, the solution ends up being some sort of integration with pydantic/cattrs, and let those libraries perform the heavy lifting.

I think you brought up a bunch of good questions, that unfortunately I don't have any immediate good solutions for. I'm all for brainstorming up possible solutions. If we think we can achieve a lot of what you want with pydantic integration, we can combine discussions with #86. 

Fix patch:
diff --git a/cyclopts/__init__.py b/cyclopts/__init__.py
index 70ed42c7..a6a75f58 100644
--- a/cyclopts/__init__.py
+++ b/cyclopts/__init__.py
@@ -15,13 +15,16 @@
     "UnknownOptionError",
     "UnusedCliTokensError",
     "ValidationError",
+    "config",
     "convert",
     "default_name_transform",
+    "env_var_split",
     "types",
     "validators",
 ]
 
 from cyclopts._convert import convert
+from cyclopts._env_var import env_var_split
 from cyclopts.core import App
 from cyclopts.exceptions import (
     CoercionError,
@@ -39,4 +42,4 @@
 from cyclopts.protocols import Dispatcher
 from cyclopts.utils import default_name_transform
 
-from . import types, validators
+from . import config, types, validators
diff --git a/cyclopts/_convert.py b/cyclopts/_convert.py
index eafdf550..2d563d22 100644
--- a/cyclopts/_convert.py
+++ b/cyclopts/_convert.py
@@ -52,6 +52,7 @@ def _bool(s: str) -> bool:
     elif s in {"yes", "y", "1", "true", "t"}:
         return True
     else:
+        # Cyclopts is a little bit conservative when coercing strings into boolean.
         raise CoercionError(target_type=bool, input_value=s)
 
 
diff --git a/cyclopts/_env_var.py b/cyclopts/_env_var.py
new file mode 100644
index 00000000..c9c65e3e
--- /dev/null
+++ b/cyclopts/_env_var.py
@@ -0,0 +1,65 @@
+import os
+from pathlib import Path
+from typing import Any, List, Optional, get_args
+
+
+def _is_path(type_) -> bool:
+    from cyclopts._convert import resolve
+
+    if type_ is Path:
+        return True
+
+    for inner_type in get_args(type_):
+        inner_type = resolve(inner_type)
+        if _is_path(inner_type):
+            return True
+
+    return False
+
+
+def env_var_split(
+    type_: Any,
+    val: str,
+    *,
+    delimiter: Optional[str] = None,
+):
+    """Type-dependent environment variable value splitting.
+
+    Performs splitting when:
+
+    * The ``type_`` is some variant of ``Iterable[pathlib.Path]`` objects.
+      If Windows, split on ``;``, otherwise split on ``:``.
+
+    * Otherwise, if the ``type_`` is an ``Iterable``, split on whitespace.
+      Leading/trailing whitespace of each output element will be stripped.
+
+    Is the default value for :attr:`cyclopts.App.env_var_split`.
+
+    Parameters
+    ----------
+    type_: type
+        Type hint that we will eventually coerce into.
+    val: str
+        String to split.
+    delimiter: Optional[str]
+        Delimiter to split ``val`` on.
+        If ``None``, defaults to whitespace.
+
+    Returns
+    -------
+    list[str]
+        List of individual tokens.
+    """
+    from cyclopts._convert import token_count
+    from cyclopts.parameter import get_hint_parameter
+
+    count, consume_all = token_count(type_)
+    type_ = get_hint_parameter(type_)[0]
+
+    if count > 1 or consume_all:
+        if _is_path(type_):
+            return val.split(os.pathsep)
+        else:
+            return val.split(delimiter)
+    else:
+        return [val]
diff --git a/cyclopts/bind.py b/cyclopts/bind.py
index 33fffbb9..095fd23f 100644
--- a/cyclopts/bind.py
+++ b/cyclopts/bind.py
@@ -4,9 +4,10 @@
 import shlex
 import sys
 from contextlib import suppress
-from typing import Iterable, List, Tuple, Union
+from typing import Callable, Dict, Iterable, List, Tuple, Type, Union
 
-from cyclopts._convert import token_count
+from cyclopts._convert import _bool, token_count
+from cyclopts.config import Unset
 from cyclopts.exceptions import (
     CoercionError,
     CycloptsError,
@@ -39,9 +40,7 @@ def _cli_kw_to_f_kw(cli_key: str):
 
 
 def _parse_kw_and_flags(command: ResolvedCommand, tokens, mapping):
-    cli2kw = command.cli2parameter
-
-    kwargs_iparam = next((x for x in command.iparam_to_cparam.keys() if x.kind == x.VAR_KEYWORD), None)
+    kwargs_iparam = next((x for x in command.iparams if x.kind == x.VAR_KEYWORD), None)
 
     if kwargs_iparam:
         mapping[kwargs_iparam] = {}
@@ -71,7 +70,7 @@ def _parse_kw_and_flags(command: ResolvedCommand, tokens, mapping):
             cli_key = token
 
         try:
-            iparam, implicit_value = cli2kw[cli_key]
+            iparam, implicit_value = command.cli2parameter[cli_key]
         except KeyError:
             if kwargs_iparam:
                 iparam = kwargs_iparam
@@ -87,10 +86,17 @@ def _parse_kw_and_flags(command: ResolvedCommand, tokens, mapping):
             # A flag was parsed
             if cli_values:
                 # A value was parsed from "--key=value", and the ``value`` is in ``cli_values``.
-                if implicit_value:  # Only accept values to the positive flag
-                    pass
+                # Immediately convert to actual boolean datatype.
+                if _bool(cli_values[-1]):
+                    # --negative-flag=true or --empty-flag=true
+                    cli_values[-1] = implicit_value
                 else:
-                    raise CycloptsError(msg=f'Cannot assign value to negative flag "{cli_key}".')
+                    # --negative-flag=false or --empty-flag=false
+                    if implicit_value in (True, False):  # This is a boolean "--no-" flag.
+                        cli_values[-1] = not implicit_value
+                    else:  # This is an iterable "--empty-"
+                        # Just skip it, it doesn't mean anything.
+                        continue
             else:
                 cli_values.append(implicit_value)
             tokens_per_element, consume_all = 0, False
@@ -135,16 +141,14 @@ def _parse_kw_and_flags(command: ResolvedCommand, tokens, mapping):
 
 
 def _is_option_like(token: str) -> bool:
-    try:
+    """Checks if a token looks like an option.
+
+    Namely, negative numbers are not options, but a token like ``--foo`` is.
+    """
+    with suppress(ValueError):
         complex(token)
         return False
-    except ValueError:
-        pass
-
-    if token.startswith("-"):
-        return True
-
-    return False
+    return token.startswith("-")
 
 
 def _validate_is_not_option_like(token):
@@ -215,7 +219,7 @@ def remaining_parameters():
     return tokens
 
 
-def _parse_env(command: ResolvedCommand, mapping):
+def _parse_env(command: ResolvedCommand, mapping: ParameterDict):
     """Populate argument defaults from environment variables.
 
     In cyclopts, arguments are parsed with the following priority:
@@ -235,13 +239,16 @@ def _parse_env(command: ResolvedCommand, mapping):
             except KeyError:
                 pass
             else:
-                mapping.setdefault(iparam, [])
-                mapping[iparam].append(env_var_value)
+                mapping[iparam] = cparam.env_var_split(iparam.annotation, env_var_value)
                 break
 
 
-def _is_required(parameter: inspect.Parameter) -> bool:
-    return parameter.default is parameter.empty
+def _is_required(iparam: inspect.Parameter) -> bool:
+    """A token must be provided for the given :class:``inspect.Parameter``."""
+    return iparam.default is iparam.empty and iparam.kind not in (
+        iparam.VAR_KEYWORD,
+        iparam.VAR_POSITIONAL,
+    )
 
 
 def _bind(
@@ -251,7 +258,7 @@ def _bind(
     """Bind the mapping to the function signature.
 
     Better than directly using ``signature.bind`` because this can handle
-    intermingled keywords.
+    intermingled positional and keyword arguments.
     """
     f_pos, f_kwargs = [], {}
     use_pos = True
@@ -262,11 +269,9 @@ def f_pos_append(p):
         try:
             f_pos.append(mapping[p])
         except KeyError:
-            if _is_required(p):
-                raise MissingArgumentError(parameter=p, tokens_so_far=[]) from None
             use_pos = False
 
-    for iparam in command.iparam_to_cparam.keys():
+    for iparam in command.iparams:
         if use_pos and iparam.kind in (iparam.POSITIONAL_ONLY, iparam.POSITIONAL_OR_KEYWORD):
             f_pos_append(iparam)
         elif use_pos and iparam.kind is iparam.VAR_POSITIONAL:  # ``*args``
@@ -275,11 +280,8 @@ def f_pos_append(p):
         elif iparam.kind is iparam.VAR_KEYWORD:
             f_kwargs.update(mapping.get(iparam, {}))
         else:
-            try:
+            with suppress(KeyError):
                 f_kwargs[iparam.name] = mapping[iparam]
-            except KeyError:
-                if _is_required(iparam):
-                    raise MissingArgumentError(parameter=iparam, tokens_so_far=[]) from None
 
     bound = command.bind(*f_pos, **f_kwargs)
     return bound
@@ -296,7 +298,7 @@ def _convert(command: ResolvedCommand, mapping: ParameterDict) -> ParameterDict:
         for parameter_token in parameter_tokens:
             if not isinstance(parameter_token, str):
                 # A token would be non-string if it's the implied-value (from a flag).
-                coerced[iparam] = parameter_tokens[0]
+                coerced[iparam] = parameter_token
                 break
         else:
             try:
@@ -326,9 +328,77 @@ def _convert(command: ResolvedCommand, mapping: ParameterDict) -> ParameterDict:
     return coerced
 
 
+def _walk_name_iparam_implicit_value(command: ResolvedCommand):
+    for name, (iparam, implicit_value) in command.cli2parameter.items():
+        if not name.startswith("--"):
+            continue
+        name = name[2:]  # Strip off the leading "--"
+        yield name, iparam, implicit_value
+
+
+def _parse_configs(command: ResolvedCommand, mapping: ParameterDict, configs):
+    """Iteratively apply each ``config`` callable to the token mapping."""
+    # Remap `mapping` back to CLI values for config parsing.
+    cli_kwargs: Dict[str, Union[Unset, list]] = {}
+    for cli_name, iparam, implicit_value in _walk_name_iparam_implicit_value(command):
+        # Assign existing tokens to the "positive" flag/keyword if it exists.
+        # Otherwise, assign it to the "negative" flag/keyword.
+        cparam = command.iparam_to_cparam[iparam]
+        if implicit_value is None or cparam.name == ("",) or implicit_value:
+            with suppress(KeyError):
+                cli_kwargs[cli_name] = mapping[iparam]
+
+    def repopulate_unset():
+        # Repopulate deleted keys with ``Unset``
+        for name, iparam, _ in _walk_name_iparam_implicit_value(command):
+            if name not in cli_kwargs or not cli_kwargs[name]:
+                cli_kwargs[name] = Unset(iparam, {x[2:] for x in command.parameter2cli[iparam] if x.startswith("--")})
+
+    repopulate_unset()
+
+    for config in configs:
+        config(cli_kwargs)
+        repopulate_unset()
+
+        # Validate that ``config`` produced reasonable modifications.
+        # If there is an error at this stage, it is a developer-error of the config object
+        for cli_name, values in cli_kwargs.items():
+            if not isinstance(cli_name, str):
+                raise TypeError(f"{config.func!r} produced non-str key {cli_name!r}.")
+            if isinstance(values, Unset):
+                continue
+            if not isinstance(values, list):
+                raise TypeError(f"{config.func!r} produced non-list value for key {cli_name!r}.")
+            if "--" + cli_name not in command.cli2parameter:
+                raise ValueError(f"{config.func!r} produced unknown key {cli_name!r}.")
+            if len(values) > 1:
+                # They all must be strings, or reinterpret as a list-of-list
+                for value in values:
+                    if not isinstance(value, str):
+                        raise TypeError(f"{config.func!r} produced non-str element value for key {cli_name!r}.")
+
+    # Rebind updated values to ``mapping``
+    set_iparams = set()
+    for cli_name, value in cli_kwargs.items():
+        iparam, _ = command.cli2parameter["--" + cli_name]
+        if isinstance(value, Unset):
+            if not value.related_set(cli_kwargs):
+                # No other "aliases" have provided values, safe to delete.
+                # This can occur if a config Unsets/deletes a value.
+                mapping.pop(iparam, None)
+        elif id(iparam) in set_iparams and value != mapping[iparam]:
+            # Intended to detect if a config sets different
+            # values to different aliases of the same parameter.
+            raise RepeatArgumentError(parameter=iparam)
+        else:
+            mapping[iparam] = value
+            set_iparams.add(id(iparam))
+
+
 def create_bound_arguments(
     command: ResolvedCommand,
     tokens: List[str],
+    configs: Iterable[Callable],
 ) -> Tuple[inspect.BoundArguments, List[str]]:
     """Parse and coerce CLI tokens to match a function's signature.
 
@@ -346,8 +416,12 @@ def create_bound_arguments(
     unused_tokens: List[str]
         Remaining tokens that couldn't be matched to ``f``'s signature.
     """
-    # Note: mapping is updated inplace
-    mapping = ParameterDict()  # Each value should be a list
+    # ``mapping`` maps inspect.Parameter to list of tokens/values.
+    #    * Each token is USUALLY a string and needs further casting/interpretation.
+    #    * However, if it's NOT a string, the value should be used as-is.
+    #        * This is used for implicit-value keyword tokens like "--flag" and "--empty-iterable"
+    # ``mapping`` is updated inplace throughout this function.
+    mapping = ParameterDict()
     unused_tokens = []
 
     validate_command(command.command)
@@ -357,6 +431,7 @@ def create_bound_arguments(
         unused_tokens = _parse_kw_and_flags(command, tokens, mapping)
         unused_tokens = _parse_pos(command, unused_tokens, mapping)
         _parse_env(command, mapping)
+        _parse_configs(command, mapping, configs)
 
         # For each parameter, convert the list of string tokens.
         coerced = _convert(command, mapping)
@@ -372,7 +447,7 @@ def create_bound_arguments(
                 try:
                     bound.arguments[name] = converted[name]
                 except KeyError:
-                    del bound.arguments[name]
+                    bound.arguments.pop(name, None)
 
         # Apply group validators
         try:
@@ -386,6 +461,10 @@ def create_bound_arguments(
                 value=e.args[0] if e.args else "", group=group  # pyright: ignore[reportPossiblyUnboundVariable]
             ) from e
 
+        for iparam in command.iparams:
+            if _is_required(iparam) and iparam.name not in bound.arguments:
+                raise MissingArgumentError(parameter=iparam)
+
     except CycloptsError as e:
         e.target = command.command
         e.root_input_tokens = tokens
diff --git a/cyclopts/config/__init__.py b/cyclopts/config/__init__.py
new file mode 100644
index 00000000..0e6d7e2f
--- /dev/null
+++ b/cyclopts/config/__init__.py
@@ -0,0 +1,14 @@
+__all__ = [
+    "ConfigFromFile",
+    "Env",
+    "Json",
+    "Toml",
+    "Yaml",
+    "Unset",
+]
+
+from cyclopts.config._common import ConfigFromFile, Unset
+from cyclopts.config._env import Env
+from cyclopts.config._json import Json
+from cyclopts.config._toml import Toml
+from cyclopts.config._yaml import Yaml
diff --git a/cyclopts/config/_common.py b/cyclopts/config/_common.py
new file mode 100644
index 00000000..f3595eb1
--- /dev/null
+++ b/cyclopts/config/_common.py
@@ -0,0 +1,122 @@
+import errno
+import inspect
+import os
+from abc import ABC, abstractmethod
+from contextlib import suppress
+from itertools import chain
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Set, Tuple, Union
+
+from attrs import define, field
+
+from cyclopts.exceptions import UnknownOptionError
+from cyclopts.utils import to_tuple_converter
+
+if TYPE_CHECKING:
+    from cyclopts.core import App
+
+
+@define
+class Unset:
+    """Placeholder object for a parameter that does not yet have any associated string tokens.
+
+    Used with :attr:`App.config <cyclopts.App.config>`.
+
+    Parameters
+    ----------
+    iparam: inspect.Parameter
+        The corresponding :class:`inspect.Parameter` for the unset variable.
+    related: set[str]
+        Other CLI names that map to the same :class:`inspect.Parameter`.
+        These may be aliases, or things like negative flags.
+    """
+
+    iparam: inspect.Parameter
+    related: Set[str] = field(factory=set)
+
+    def related_set(self, mapping: Dict[str, Union["Unset", List[str]]]) -> Set[str]:
+        """Other CLI keys that map to the same :class:`inspect.Parameter` that have parsed token(s).
+
+        Parameters
+        ----------
+        mapping: dict
+            All associated cli_name to tokens.
+
+        Returns
+        -------
+        set[str]
+            CLI keys that map to the same :class:`inspect.Parameter` that have parsed token(s).
+        """
+        return {x for x in self.related.intersection(mapping) if not isinstance(mapping[x], Unset)}
+
+
+@define
+class ConfigFromFile(ABC):
+    path: Union[str, Path] = field(converter=Path)
+    root_keys: Iterable[str] = field(default=(), converter=to_tuple_converter)
+    must_exist: bool = field(default=False, kw_only=True)
+    search_parents: bool = field(default=False, kw_only=True)
+    allow_unknown: bool = field(default=False, kw_only=True)
+
+    _config: Optional[Dict[str, Any]] = field(default=None, init=False, repr=False)
+
+    @abstractmethod
+    def _load_config(self, path: Path) -> Dict[str, Any]:
+        """Load the config dictionary from path.
+
+        Parameters
+        ----------
+        path: Path
+            Path to the file. Guaranteed to exist.
+
+        Returns
+        -------
+        dict
+            Loaded configuration.
+        """
+        raise NotImplementedError
+
+    @property
+    def config(self) -> Dict[str, Any]:
+        if self._config is not None:
+            return self._config
+
+        assert isinstance(self.path, Path)
+        for parent in self.path.parents:
+            candidate = parent / self.path.name
+            if candidate.exists():
+                self._config = self._load_config(candidate)
+                return self._config
+            elif self.search_parents:
+                # Continue iterating over parents.
+                continue
+            elif self.must_exist:
+                raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), str(self.path))
+
+        # No matching file was found.
+        if self.must_exist:
+            raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), str(self.path))
+
+        self._config = {}
+        return self._config
+
+    def __call__(self, apps: List["App"], commands: Tuple[str, ...], mapping: Dict[str, Union[Unset, List[str]]]):
+        config = self.config
+        try:
+            for key in chain(self.root_keys, commands):
+                config = config[key]
+        except KeyError:
+            return
+
+        if not self.allow_unknown and (remaining_keys := set(config) - set(apps[-1]) - set(mapping)):
+            raise UnknownOptionError(token=sorted(remaining_keys)[0])
+
+        for key, value in mapping.items():
+            if not isinstance(value, Unset) or value.related_set(mapping):
+                continue
+
+            with suppress(KeyError):
+                new_value = config[key]
+                if not isinstance(new_value, list):
+                    new_value = [new_value]
+                mapping[key] = [str(x) for x in new_value]
diff --git a/cyclopts/config/_env.py b/cyclopts/config/_env.py
new file mode 100644
index 00000000..661eb75a
--- /dev/null
+++ b/cyclopts/config/_env.py
@@ -0,0 +1,31 @@
+import os
+from typing import TYPE_CHECKING, Callable, Dict, List, Tuple, Union
+
+from attrs import define, field
+
+from cyclopts._env_var import env_var_split
+from cyclopts.config import Unset
+
+if TYPE_CHECKING:
+    from cyclopts.core import App
+
+
+@define
+class Env:
+    prefix: str = ""
+    command: bool = field(default=True, kw_only=True)
+    split: Callable = field(default=env_var_split, kw_only=True)
+
+    def __call__(self, apps: List["App"], commands: Tuple[str, ...], mapping: Dict[str, Union[Unset, List[str]]]):
+        prefix = self.prefix
+        if self.command and commands:
+            prefix += "_".join(commands) + "_"
+        for key, value in mapping.items():
+            if not isinstance(value, Unset) or value.related_set(mapping):
+                continue
+            env_key = (prefix + key).upper().replace("-", "_")
+            try:
+                env_value = os.environ[env_key]
+            except KeyError:
+                continue
+            mapping[key] = self.split(value.iparam.annotation, env_value)
diff --git a/cyclopts/config/_json.py b/cyclopts/config/_json.py
new file mode 100644
index 00000000..0874186a
--- /dev/null
+++ b/cyclopts/config/_json.py
@@ -0,0 +1,12 @@
+from pathlib import Path
+from typing import Any, Dict
+
+from cyclopts.config._common import ConfigFromFile
+
+
+class Json(ConfigFromFile):
+    def _load_config(self, path: Path) -> Dict[str, Any]:
+        import json
+
+        with path.open() as f:
+            return json.load(f)
diff --git a/cyclopts/config/_toml.py b/cyclopts/config/_toml.py
new file mode 100644
index 00000000..34a300f6
--- /dev/null
+++ b/cyclopts/config/_toml.py
@@ -0,0 +1,17 @@
+from pathlib import Path
+from typing import Any, Dict
+
+from cyclopts.config._common import ConfigFromFile
+
+
+class Toml(ConfigFromFile):
+    def _load_config(self, path: Path) -> Dict[str, Any]:
+        try:
+            # Attempt to use builtin >=python3.11
+            import tomllib  # pyright: ignore[reportMissingImports]
+        except ImportError:
+            # Fallback to most popular pypi toml package.
+            import tomli as tomllib  # pyright: ignore[reportMissingImports]
+
+        with path.open("rb") as f:
+            return tomllib.load(f)
diff --git a/cyclopts/config/_yaml.py b/cyclopts/config/_yaml.py
new file mode 100644
index 00000000..4f1a6688
--- /dev/null
+++ b/cyclopts/config/_yaml.py
@@ -0,0 +1,12 @@
+from pathlib import Path
+from typing import Any, Dict
+
+from cyclopts.config._common import ConfigFromFile
+
+
+class Yaml(ConfigFromFile):
+    def _load_config(self, path: Path) -> Dict[str, Any]:
+        from yaml import safe_load  # pyright: ignore[reportMissingImports]
+
+        with path.open() as f:
+            return safe_load(f)
diff --git a/cyclopts/core.py b/cyclopts/core.py
index 6a2e8246..0a26a810 100644
--- a/cyclopts/core.py
+++ b/cyclopts/core.py
@@ -185,6 +185,13 @@ class App:
     default_command: Optional[Callable] = field(default=None, converter=_validate_default_command, kw_only=True)
     default_parameter: Optional[Parameter] = field(default=None, kw_only=True)
 
+    # This can ONLY ever be a Tuple[Callable, ...]
+    config: Union[None, Callable, Iterable[Callable]] = field(
+        default=None,
+        converter=to_tuple_converter,
+        kw_only=True,
+    )
+
     version: Union[None, str, Callable] = field(factory=_default_version, kw_only=True)
     # This can ONLY ever be a Tuple[str, ...]
     _version_flags: Union[str, Iterable[str]] = field(
@@ -405,7 +412,14 @@ def meta(self) -> "App":
             self._meta._meta_parent = self
         return self._meta
 
-    def parse_commands(self, tokens: Union[None, str, Iterable[str]] = None):
+    def parse_commands(
+        self,
+        tokens: Union[
+            None,
+            str,
+            Iterable[str],
+        ] = None,
+    ) -> Tuple[Tuple[str, ...], Tuple["App", ...], List[str]]:
         """Extract out the command tokens from a command.
 
         Parameters
@@ -442,7 +456,7 @@ def parse_commands(self, tokens: Union[None, str, Iterable[str]] = None):
             command_chain.append(token)
             command_mapping = _combined_meta_command_mapping(app)
 
-        return command_chain, apps, unused_tokens
+        return tuple(command_chain), tuple(apps), unused_tokens
 
     def command(
         self,
@@ -570,6 +584,13 @@ def parse_known_args(
         except IndexError:
             parent_app = None
 
+        config: Tuple[Callable, ...] = ()
+        for app in reversed(apps):
+            if app.config:
+                config = app.config  # pyright: ignore[reportAssignmentType]
+                break
+        config = tuple(partial(x, apps, command_chain) for x in config)
+
         # Special flags (help/version) get intercepted by the root app.
         # Special flags are allows to be **anywhere** in the token stream.
 
@@ -600,17 +621,11 @@ def parse_known_args(
             try:
                 if command_app.default_command:
                     command = command_app.default_command
-                    resolved_command = self._resolve_command(
-                        tokens,
-                        parse_docstring=False,
-                    )
+                    resolved_command = self._resolve_command(tokens, parse_docstring=False)
                     # We want the resolved group that ``app`` belongs to.
-                    if parent_app is None:
-                        command_groups = []
-                    else:
-                        command_groups = _get_command_groups(parent_app, command_app)
+                    command_groups = [] if parent_app is None else _get_command_groups(parent_app, command_app)
 
-                    bound, unused_tokens = create_bound_arguments(resolved_command, unused_tokens)
+                    bound, unused_tokens = create_bound_arguments(resolved_command, unused_tokens, config)
                     try:
                         if command_app.converter:
                             bound.arguments = command_app.converter(**bound.arguments)
diff --git a/cyclopts/exceptions.py b/cyclopts/exceptions.py
index 1c17fc80..2acd3fd5 100644
--- a/cyclopts/exceptions.py
+++ b/cyclopts/exceptions.py
@@ -1,6 +1,16 @@
 import inspect
 import re
-from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Type
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Callable,
+    Dict,
+    Iterable,
+    List,
+    Optional,
+    Tuple,
+    Type,
+)
 
 from attrs import define, field
 
@@ -21,8 +31,11 @@
     "DocstringError",
     "InvalidCommandError",
     "MissingArgumentError",
+    "RepeatArgumentError",
+    "UnknownOptionError",
     "UnusedCliTokensError",
     "ValidationError",
+    "format_cyclopts_error",
 ]
 
 
@@ -85,7 +98,7 @@ class CycloptsError(Exception):
     Dictionary mapping function parameters to possible CLI tokens.
     """
 
-    command_chain: Optional[List[str]] = None
+    command_chain: Optional[Iterable[str]] = None
     """
     List of command that lead to ``target``.
     """
@@ -250,7 +263,7 @@ class MissingArgumentError(CycloptsError):
     The parameter that failed to parse.
     """
 
-    tokens_so_far: List[str]
+    tokens_so_far: List[str] = field(factory=list)
     """
     The tokens that were parsed so far for this Parameter.
     """
diff --git a/cyclopts/group.py b/cyclopts/group.py
index b69f187c..ca76642b 100644
--- a/cyclopts/group.py
+++ b/cyclopts/group.py
@@ -1,15 +1,23 @@
 import itertools
-from typing import TYPE_CHECKING, Any, Callable, Iterable, List, Optional, Tuple, Union, cast
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Callable,
+    Iterable,
+    List,
+    Optional,
+    Tuple,
+    Union,
+    cast,
+)
 
 from attrs import define, field
 
-from cyclopts.utils import Sentinel, is_iterable
+from cyclopts.utils import Sentinel, is_iterable, resolve_callables, to_tuple_converter
 
 if TYPE_CHECKING:
     from cyclopts.parameter import Parameter
 
-from cyclopts.utils import resolve_callables, to_tuple_converter
-
 
 def _group_default_parameter_must_be_none(instance, attribute, value: Optional["Parameter"]):
     if value is None:
@@ -23,7 +31,8 @@ def _group_default_parameter_must_be_none(instance, attribute, value: Optional["
 _sort_key_counter = itertools.count()
 
 
-NO_USER_SORT_KEY = Sentinel("NO_USER_SORT_KEY")
+class NO_USER_SORT_KEY(Sentinel):  # noqa: N801
+    pass
 
 
 @define
diff --git a/cyclopts/help.py b/cyclopts/help.py
index 0de14956..fb30ae88 100644
--- a/cyclopts/help.py
+++ b/cyclopts/help.py
@@ -163,7 +163,7 @@ def _is_short(s):
 
 def format_usage(
     app,
-    command_chain: List[str],
+    command_chain: Iterable[str],
 ):
     from rich.text import Text
 
diff --git a/cyclopts/parameter.py b/cyclopts/parameter.py
index eed0936f..f60d4909 100644
--- a/cyclopts/parameter.py
+++ b/cyclopts/parameter.py
@@ -16,6 +16,7 @@
 import attrs
 from attrs import field, frozen
 
+import cyclopts._env_var
 import cyclopts.utils
 from cyclopts._convert import (
     AnnotatedType,
@@ -99,6 +100,8 @@ class Parameter:
         converter=lambda x: cast(Tuple[str, ...], to_tuple_converter(x)),
     )
 
+    env_var_split: Callable = cyclopts._env_var.env_var_split
+
     negative_bool: Tuple[str, ...] = field(
         default=None,
         converter=_negative_converter(("--no-",)),
diff --git a/cyclopts/resolve.py b/cyclopts/resolve.py
index 6a7ed21b..9ccdba1d 100644
--- a/cyclopts/resolve.py
+++ b/cyclopts/resolve.py
@@ -121,7 +121,9 @@ class ResolvedCommand:
     groups_iparams: List[Tuple[Group, List[inspect.Parameter]]]
     iparam_to_groups: ParameterDict
     iparam_to_cparam: ParameterDict
-    name_to_iparam: Dict[str, inspect.Parameter]
+
+    pyname_to_iparam: Dict[str, inspect.Parameter]
+    # Plain python identifier string to inspect.Parameter
 
     def __init__(
         self,
@@ -155,7 +157,7 @@ def __init__(
 
         self.command = f
         signature = cyclopts.utils.signature(f)
-        self.name_to_iparam = cast(Dict[str, inspect.Parameter], signature.parameters)
+        self.pyname_to_iparam = cast(Dict[str, inspect.Parameter], signature.parameters)
 
         # Get:
         # 1. Fully resolved and created Groups.
@@ -178,7 +180,7 @@ def __init__(
                 Parameter(required=iparam.default is iparam.empty),
             )[1]
 
-            # Resolve name now that ``name_transform`` has been resolved.
+            # Resolve ``name`` now that ``name_transform`` has been resolved.
             if iparam.kind in (iparam.POSITIONAL_ONLY, iparam.VAR_POSITIONAL):
                 # Name is only used for help-string
                 names = [iparam.name.upper()]
@@ -191,7 +193,7 @@ def __init__(
             cparam = Parameter.combine(Parameter(name=names), cparam)
             self.iparam_to_cparam[iparam] = cparam
 
-        self.bind = signature.bind_partial if _has_unparsed_parameters(signature, app_parameter) else signature.bind
+        self.bind = signature.bind_partial
 
         # Create a convenient group-to-iparam structure
         self.groups_iparams = [
@@ -202,6 +204,10 @@ def __init__(
             for group in self.groups
         ]
 
+    @property
+    def iparams(self):
+        return self.iparam_to_cparam.keys()
+
     @cached_property
     def cli2parameter(self) -> Dict[str, Tuple[inspect.Parameter, Any]]:
         """Creates a dictionary mapping CLI keywords to python keywords.
@@ -227,12 +233,14 @@ def cli2parameter(self) -> Dict[str, Tuple[inspect.Parameter, Any]]:
             for name in cparam.name:
                 mapping[name] = (iparam, True if hint is bool else None)
             for name in cparam.get_negatives(hint, *cparam.name):
+                # Creates empty versions of iterables.
                 mapping[name] = (iparam, (get_origin(hint) or hint)())
 
         return mapping
 
     @cached_property
     def parameter2cli(self) -> ParameterDict:
+        """Creates a dictinoary mapping :class:`inspect.Parameter` to a list of CLI names."""
         c2p = self.cli2parameter
         p2c = ParameterDict()
 
diff --git a/cyclopts/utils.py b/cyclopts/utils.py
index c6886b62..12415aa4 100644
--- a/cyclopts/utils.py
+++ b/cyclopts/utils.py
@@ -2,7 +2,18 @@
 import inspect
 import sys
 from collections.abc import MutableMapping
-from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple, Type, Union
+from typing import (
+    Any,
+    Dict,
+    Iterable,
+    Iterator,
+    List,
+    Literal,
+    Optional,
+    Tuple,
+    Type,
+    Union,
+)
 
 _union_types = set()
 _union_types.add(Union)
@@ -21,6 +32,18 @@ def signature(f: Any) -> inspect.Signature:
 # fmt: on
 
 
+class SentinelMeta(type):
+    def __repr__(cls) -> str:
+        return f"<{cls.__name__}>"
+
+    def __bool__(cls) -> Literal[False]:
+        return False
+
+
+class Sentinel(metaclass=SentinelMeta):
+    pass
+
+
 def record_init(target: str):
     """Class decorator that records init argument names as a tuple to ``target``."""
 
@@ -49,14 +72,6 @@ def is_union(type_: Optional[Type]) -> bool:
     return type_ in _union_types
 
 
-class Sentinel:
-    def __init__(self, name):
-        self.name = name
-
-    def __repr__(self):
-        return self.name
-
-
 class ParameterDict(MutableMapping):
     """A dictionary implementation that can handle mutable ``inspect.Parameter`` as keys."""
 
@@ -114,15 +129,24 @@ def get(self, key: inspect.Parameter, default: Any = None):
         except KeyError:
             return default
 
+    def clear(self):
+        self.store.clear()
+        self.reverse_mapping.clear()
+
 
 def resolve_callables(t, *args, **kwargs):
     """Recursively resolves callable elements in a tuple."""
+    if isinstance(t, type(Sentinel)):
+        return t
+
     if callable(t):
         return t(*args, **kwargs)
 
     resolved = []
     for element in t:
-        if callable(element):
+        if isinstance(element, type(Sentinel)):
+            resolved.append(element)
+        elif callable(element):
             resolved.append(element(*args, **kwargs))
         elif is_iterable(element):
             resolved.append(resolve_callables(element, *args, **kwargs))
@@ -173,7 +197,7 @@ def optional_to_tuple_converter(value: Union[None, Any, Iterable[Any]]) -> Optio
     return to_tuple_converter(value)
 
 
-def default_name_transform(s: str) -> str:
+def default_name_transform(s: str):
     """Converts a python identifier into a CLI token.
 
     Performs the following operations (in order):
diff --git a/docs/source/api.rst b/docs/source/api.rst
index 0a0e7082..598e4adc 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -93,7 +93,8 @@ API
       :type: Parameter
       :value: None
 
-      Default :class:`Parameter` configuration.
+      Default :class:`Parameter` configuration. Unspecified values of command-annotated :class:`Parameter` will inherit these values.
+      See :ref:`Parameter Resolution Order<Parameter Resolution Order>` for more details.
 
    .. attribute:: group
       :type: Union[None, str, Group, Iterable[Union[str, Group]]]
@@ -177,6 +178,37 @@ API
       If :obj:`None` (default value), uses :func:`cyclopts.default_name_transform`.
       If a subapp, inherits from first non-:obj:`None` parent.
 
+   .. attribute:: config
+      :type: Union[None, Callable, Iterable[Callable]]
+      :value: None
+
+      A function or list of functions that are consecutively applied to keyword arguments.
+      These function(s) are called before any additional conversion and validation.
+      Each function must have signature:
+
+      .. code-block:: python
+
+         def config(apps: Tuple[App, ...], commands: Tuple[str, ...], mapping: Dict[str, Union[Unset, List[str]]]):
+             """Modifies given mapping inplace with some injected values.
+
+             Parameters
+             ----------
+             apps: Tuple[App, ...]
+                The application hierarchy that led to the current command function.
+                The current command app is the last element of this tuple.
+             commands: Tuple[str, ...]
+                The CLI strings that led to the current command function.
+             mapping: Dict[str, Union[Unset, List[str]]]
+                A dictionary mapping CLI keyword names to their tokens (before App and Group converters/validators have been invoked).
+                For example, if the user specifies --my-var=foo, then this dictionary will be {"my-var": ["foo"]}.
+                If the value is an cyclopts.config.Unset object, then no tokens have been parsed for that parameter yet.
+                Deleting keys from this dictionary will unset their value.
+             """
+
+      The intended use-case of this feature is to allow users to specify functions that can load defaults from some external configuration.
+      See :ref:`cyclopts.config <API Config>` for useful builtins.
+
+
 .. autoclass:: cyclopts.Parameter
 
    Cyclopts configuration for individual function parameters.
@@ -320,6 +352,20 @@ API
       If multiple environment variables are given, the left-most environment variable with a set value will be used.
       If no environment variable is set, Cyclopts will fallback to the function-signature default.
 
+   .. attribute:: env_var_split
+      :type: Callable
+      :value: cyclopts.env_var_split
+
+      Function that splits up the read-in :attr:`~cyclopts.Parameter.env_var` value.
+      The function must have signature:
+
+      .. code-block:: python
+
+         def env_var_split(type_: type, val: str) -> List[str]:
+             ...
+
+      where ``type_`` is the associated parameter type-hint, and ``val`` is the environment value.
+
    .. attribute:: name_transform
        :type: Optional[Callable[[str], str]]
        :value: None
@@ -508,11 +554,12 @@ API
          │ --buzz value "bob" needs to be uppercase.               │
          ╰─────────────────────────────────────────────────────────╯
 
-
 .. autofunction:: cyclopts.convert
 
 .. autofunction:: cyclopts.default_name_transform
 
+.. autofunction:: cyclopts.env_var_split
+
 .. _API Validators:
 
 ----------
@@ -600,6 +647,187 @@ Annotated types for checking common int/float value constraints.
 
 .. autodata:: cyclopts.types.NonPositiveInt
 
+.. _API Config:
+
+------
+Config
+------
+Cyclopts has builtin configuration classes to be used with :attr:`App.config <cyclopts.App.config>` for loading user-defined defaults in many common scenarios.
+All Cyclopts builtins index into the configuration file with the following rules:
+
+1. Apply ``root_keys`` (if provided) to enter the project's configuration namespace.
+
+2. Apply the command name(s) to enter the current command's configuration namespace.
+
+3. Apply each key/value pair if CLI arguments have **not** been provided for that parameter.
+
+.. autoclass:: cyclopts.config.Toml
+
+   Automatically read configuration from Toml file.
+
+   .. attribute:: path
+      :type: Union[str, Path]
+
+      Path to the TOML configuration file.
+
+   .. attribute:: root_keys
+      :type: Iterable[str]
+      :value: None
+
+      Keys that lead to your application's configuration.
+      For example, if referencing a ``pyproject.toml``, it is common
+      to store all of your projects configuration under:
+
+      .. code-block:: toml
+
+         [tool.myproject]
+
+      So, your Cyclopts :class:`~cyclopts.App` should be configured as:
+
+      .. code-block:: python
+
+         app = cyclopts.App(config=cyclopts.config.Toml("pyproject.toml", root_keys=("tool", "myproject")))
+
+   .. attribute:: must_exist
+      :type: bool
+      :value: False
+
+      The configuration file must exist. If a matching file is not found, raises :exc:`FileNotFoundError`.
+
+   .. attribute:: search_parents
+      :type: bool
+      :value: False
+
+      Iteratively search parenting directories until a file matching :attr:`~cyclopts.config.Toml.path` is found.
+
+   .. attribute:: allow_unknown
+      :type: bool
+      :value: False
+
+      Allow unknown keywords configuration values.
+
+.. autoclass:: cyclopts.config.Yaml
+
+   Automatically read configuration from Yaml file.
+
+   .. attribute:: path
+      :type: Union[str, Path]
+
+      Path to the YAML configuration file.
+
+   .. attribute:: root_keys
+      :type: Iterable[str]
+      :value: None
+
+      Keys that lead to your application's configuration in the YAML file.
+
+   .. attribute:: must_exist
+      :type: bool
+      :value: False
+
+      The configuration file must exist. If a matching file is not found, raises :exc:`FileNotFoundError`.
+
+   .. attribute:: search_parents
+      :type: bool
+      :value: False
+
+      Iteratively search parenting directories until a file matching :attr:`~cyclopts.config.Yaml.path` is found.
+
+   .. attribute:: allow_unknown
+      :type: bool
+      :value: False
+
+      Allow unknown keywords configuration values.
+
+
+.. autoclass:: cyclopts.config.Json
+
+   Automatically read configuration from Json file.
+
+   .. attribute:: path
+      :type: Union[str, Path]
+
+      Path to the JSON configuration file.
+
+   .. attribute:: root_keys
+      :type: Iterable[str]
+      :value: None
+
+      Keys that lead to your application's configuration in the YAML file.
+
+   .. attribute:: must_exist
+      :type: bool
+      :value: False
+
+      The configuration file must exist. If a matching file is not found, raises :exc:`FileNotFoundError`.
+
+   .. attribute:: search_parents
+      :type: bool
+      :value: False
+
+      Iteratively search parenting directories until a file matching :attr:`~cyclopts.config.Json.path` is found.
+
+   .. attribute:: allow_unknown
+      :type: bool
+      :value: False
+
+      Allow unknown keywords configuration values.
+
+.. autoclass:: cyclopts.config.Env
+
+   Automatically derive environment variable names to read configurations from.
+
+   For example, consider the following app:
+
+   .. code-block:: python
+
+      import cyclopts
+
+      app = cyclopts.App(config=cyclopts.config.Env("MY_SCRIPT_"))
+
+
+      @app.command
+      def my_command(foo, bar):
+          print(f"{foo=} {bar=}")
+
+
+      app()
+
+   If values for ``foo`` and ``bar`` are not supplied by the command line, the app will check
+   the environment variables ``MY_SCRIPT_MY_COMMAND_FOO`` and ``MY_SCRIPT_MY_COMMAND_BAR``, respectively:
+
+   .. code-block:: console
+
+      $ python my_script.py my-command 1 2
+      foo=1 bar=2
+
+      $ export MY_SCRIPT_MY_COMMAND_FOO=100
+      $ python my_script.py my-command --bar=2
+      foo=100 bar=2
+      $ python my_script.py my-command 1 2
+      foo=1 bar=2
+
+
+   .. attribute:: prefix
+      :type: str
+      :value: ""
+
+      String to prepend to all autogenerated environment variable names.
+      Typically ends in ``_``, and is something like ``MY_APP_``.
+
+   .. attribute:: command
+      :type: bool
+      :value: True
+
+   .. attribute:: split
+      :type: Callable
+      :value: cyclopts.env_var_split
+
+      Function that splits up the read-in :attr:`~cyclopts.Parameter.env_var` value.
+
+.. autoclass:: cyclopts.config.Unset
+   :members: related_set
+
 ----------
 Exceptions
 ----------
diff --git a/docs/source/conf.py b/docs/source/conf.py
index eb79b208..25553c87 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -15,10 +15,10 @@
 from sphinx.application import Sphinx
 from sphinx.ext.autodoc import Options
 
-sys.path.insert(0, str(Path("../..").absolute()))
+from cyclopts import __version__
 
+sys.path.insert(0, str(Path("../..").absolute()))
 
-from cyclopts import __version__
 
 git_repo = git.Repo(".", search_parent_directories=True)  # type: ignore[reportPrivateImportUsage]
 git_commit = git_repo.head.commit
diff --git a/docs/source/config_file.rst b/docs/source/config_file.rst
new file mode 100644
index 00000000..23c5d961
--- /dev/null
+++ b/docs/source/config_file.rst
@@ -0,0 +1,133 @@
+============
+Config Files
+============
+For more complicated CLI applications, it is common to have an external user configuration file. For example, the popular python tools ``poetry``, ``ruff``, and ``pytest`` are all configurable from a ``pyproject.toml`` file. The :attr:`App.config <cyclopts.App.config>` attribute accepts a callable (or list of callables) that add (or remove) values to the parsed CLI tokens. The provided callable must have signature:
+
+.. code-block:: python
+
+   def config(apps: Tuple[App, ...], commands: Tuple[str, ...], mapping: Dict[str, Union[Unset, List[str]]]):
+       """Modifies given mapping inplace with some injected values.
+
+       Parameters
+       ----------
+       apps: Tuple[App, ...]
+          The application hierarchy that led to the current command function.
+          The current command app is the last element of this tuple.
+       commands: Tuple[str, ...]
+          The CLI strings that led to the current command function.
+       mapping: Dict[str, Union[Unset, List[str]]]
+          A dictionary mapping CLI keyword names to their tokens (before App and Group converters/validators have been invoked).
+          For example, if the user specifies --my-var=foo, then this dictionary will be {"my-var": ["foo"]}.
+          If the value is an cyclopts.config.Unset object, then no tokens have been parsed for that parameter yet.
+          Deleting keys from this dictionary will unset their value.
+       """
+       ...
+
+The provided ``config`` does not have to be a function; all the Cyclopts builtin configs are classes that implement the ``__call__`` method. The Cyclopts builtins offer good standard functionality for common configuration files like yaml or toml. See :ref:`cyclopts.config <API Config>`.
+
+------------
+TOML Example
+------------
+In this example, we create a small CLI tool that counts the number of times a given character occurs in a file.
+
+.. code-block:: python
+
+   # character-counter.py
+   import cyclopts
+   from pathlib import Path
+
+   app = cyclopts.App(
+       name="character-counter",
+       config=cyclopts.config.Toml(
+           "pyproject.toml",  # Name of the TOML File
+           root_keys=["tool", "character-counter"],  # The project's namespace in the TOML.
+           # If "pyproject.toml" is not found in the current directory,
+           # then iteratively search parenting directories until found.
+           search_parents=True,
+       ),
+   )
+
+
+   @app.command
+   def count(filename: Path, *, character="-"):
+       print(filename.read_text().count(character))
+
+
+   if __name__ == "__main__":
+       app()
+
+Running this code without a ``pyproject.toml`` present:
+
+.. code-block:: console
+
+   $ python character-counter.py count README.md
+   70
+   $ python character-counter.py count README.md --character=t
+   380
+
+We can have the new default character be ``t`` by adding the following to ``pyproject.toml``:
+
+.. code-block:: toml
+
+   [tool.character-counter.count]
+   character = "t"
+
+Rerunning the app without a specified ``--character`` will result in using the toml-provided value:
+
+.. code-block:: console
+
+   $ python character-counter.py count README.md
+   380
+
+----------------------------
+Environment Variable Example
+----------------------------
+To automatically derive and read appropriate environment variables, use the :class:`cyclopts.config.Env` class. Continuing the above TOML example:
+
+
+.. code-block:: python
+
+   # character-counter.py
+   import cyclopts
+   from pathlib import Path
+
+   app = cyclopts.App(
+       name="character-counter",
+       config=cyclopts.config.Env(
+           "CHAR_COUNTER_",  # Every environment variable will begin with this.
+       ),
+   )
+
+
+   @app.command
+   def count(filename: Path, *, character="-"):
+       print(filename.read_text().count(character))
+
+
+   if __name__ == "__main__":
+       app()
+
+:class:`~cyclopts.config.Env` assembles the environment variable name by joining the following components (in-order):
+
+1. The provided ``prefix``. In this case, it is ``"CHAR_COUNTER_"``.
+
+2. The command and subcommand(s) that lead up to the function being executed.
+
+3. The parameter's CLI name, with the leading ``--`` stripped, and hyphens ``-`` replaced with underscores ``_``.
+
+Running this code without a specified ``--character`` results in counting the default ``-`` character.
+
+.. code-block:: console
+
+   $ python character-counter.py count README.md
+   70
+
+By exporting a value to ``CHAR_COUNTER_COUNT_CHARACTER``, that value will now be used as the default:
+
+.. code-block:: console
+
+   $ export CHAR_COUNTER_COUNT_CHARACTER=t
+   $ python character-counter.py count README.md
+   380
+   $ python character-counter.py count README.md --character=q
+   3
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 912be4c5..063a7ab0 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -34,6 +34,7 @@ For extensive documentation on all the features Cyclopts has to offer, checkout
    :caption: Advanced Usage
 
    args_and_kwargs.rst
+   config_file.rst
    packaging.rst
    app_calling.rst
    meta_app.rst
diff --git a/poetry.lock b/poetry.lock
index 543c612b..708ff917 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -13,13 +13,13 @@ files = [
 
 [[package]]
 name = "annotated-types"
-version = "0.6.0"
+version = "0.7.0"
 description = "Reusable constraint types to use with typing.Annotated"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"},
-    {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"},
+    {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
+    {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
 ]
 
 [package.dependencies]
@@ -90,13 +90,13 @@ tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "p
 
 [[package]]
 name = "babel"
-version = "2.14.0"
+version = "2.15.0"
 description = "Internationalization utilities"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"},
-    {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"},
+    {file = "Babel-2.15.0-py3-none-any.whl", hash = "sha256:08706bdad8d0a3413266ab61bd6c34d0c28d6e1e7badf40a2cebe67644e2e1fb"},
+    {file = "babel-2.15.0.tar.gz", hash = "sha256:8daf0e265d05768bc6c7a314cf1321e9a123afc328cc635c18622a2f30a04413"},
 ]
 
 [package.dependencies]
@@ -118,13 +118,13 @@ files = [
 
 [[package]]
 name = "certifi"
-version = "2024.2.2"
+version = "2024.6.2"
 description = "Python package for providing Mozilla's CA Bundle."
 optional = false
 python-versions = ">=3.6"
 files = [
-    {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"},
-    {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"},
+    {file = "certifi-2024.6.2-py3-none-any.whl", hash = "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56"},
+    {file = "certifi-2024.6.2.tar.gz", hash = "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516"},
 ]
 
 [[package]]
@@ -264,63 +264,63 @@ files = [
 
 [[package]]
 name = "coverage"
-version = "7.5.0"
+version = "7.5.3"
 description = "Code coverage measurement for Python"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "coverage-7.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:432949a32c3e3f820af808db1833d6d1631664d53dd3ce487aa25d574e18ad1c"},
-    {file = "coverage-7.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2bd7065249703cbeb6d4ce679c734bef0ee69baa7bff9724361ada04a15b7e3b"},
-    {file = "coverage-7.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbfe6389c5522b99768a93d89aca52ef92310a96b99782973b9d11e80511f932"},
-    {file = "coverage-7.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39793731182c4be939b4be0cdecde074b833f6171313cf53481f869937129ed3"},
-    {file = "coverage-7.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a5dbe1ba1bf38d6c63b6d2c42132d45cbee6d9f0c51b52c59aa4afba057517"},
-    {file = "coverage-7.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:357754dcdfd811462a725e7501a9b4556388e8ecf66e79df6f4b988fa3d0b39a"},
-    {file = "coverage-7.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a81eb64feded34f40c8986869a2f764f0fe2db58c0530d3a4afbcde50f314880"},
-    {file = "coverage-7.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:51431d0abbed3a868e967f8257c5faf283d41ec882f58413cf295a389bb22e58"},
-    {file = "coverage-7.5.0-cp310-cp310-win32.whl", hash = "sha256:f609ebcb0242d84b7adeee2b06c11a2ddaec5464d21888b2c8255f5fd6a98ae4"},
-    {file = "coverage-7.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:6782cd6216fab5a83216cc39f13ebe30adfac2fa72688c5a4d8d180cd52e8f6a"},
-    {file = "coverage-7.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e768d870801f68c74c2b669fc909839660180c366501d4cc4b87efd6b0eee375"},
-    {file = "coverage-7.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:84921b10aeb2dd453247fd10de22907984eaf80901b578a5cf0bb1e279a587cb"},
-    {file = "coverage-7.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:710c62b6e35a9a766b99b15cdc56d5aeda0914edae8bb467e9c355f75d14ee95"},
-    {file = "coverage-7.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c379cdd3efc0658e652a14112d51a7668f6bfca7445c5a10dee7eabecabba19d"},
-    {file = "coverage-7.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fea9d3ca80bcf17edb2c08a4704259dadac196fe5e9274067e7a20511fad1743"},
-    {file = "coverage-7.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:41327143c5b1d715f5f98a397608f90ab9ebba606ae4e6f3389c2145410c52b1"},
-    {file = "coverage-7.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:565b2e82d0968c977e0b0f7cbf25fd06d78d4856289abc79694c8edcce6eb2de"},
-    {file = "coverage-7.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cf3539007202ebfe03923128fedfdd245db5860a36810136ad95a564a2fdffff"},
-    {file = "coverage-7.5.0-cp311-cp311-win32.whl", hash = "sha256:bf0b4b8d9caa8d64df838e0f8dcf68fb570c5733b726d1494b87f3da85db3a2d"},
-    {file = "coverage-7.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c6384cc90e37cfb60435bbbe0488444e54b98700f727f16f64d8bfda0b84656"},
-    {file = "coverage-7.5.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fed7a72d54bd52f4aeb6c6e951f363903bd7d70bc1cad64dd1f087980d309ab9"},
-    {file = "coverage-7.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cbe6581fcff7c8e262eb574244f81f5faaea539e712a058e6707a9d272fe5b64"},
-    {file = "coverage-7.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad97ec0da94b378e593ef532b980c15e377df9b9608c7c6da3506953182398af"},
-    {file = "coverage-7.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd4bacd62aa2f1a1627352fe68885d6ee694bdaebb16038b6e680f2924a9b2cc"},
-    {file = "coverage-7.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:adf032b6c105881f9d77fa17d9eebe0ad1f9bfb2ad25777811f97c5362aa07f2"},
-    {file = "coverage-7.5.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4ba01d9ba112b55bfa4b24808ec431197bb34f09f66f7cb4fd0258ff9d3711b1"},
-    {file = "coverage-7.5.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:f0bfe42523893c188e9616d853c47685e1c575fe25f737adf473d0405dcfa7eb"},
-    {file = "coverage-7.5.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a9a7ef30a1b02547c1b23fa9a5564f03c9982fc71eb2ecb7f98c96d7a0db5cf2"},
-    {file = "coverage-7.5.0-cp312-cp312-win32.whl", hash = "sha256:3c2b77f295edb9fcdb6a250f83e6481c679335ca7e6e4a955e4290350f2d22a4"},
-    {file = "coverage-7.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:427e1e627b0963ac02d7c8730ca6d935df10280d230508c0ba059505e9233475"},
-    {file = "coverage-7.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9dd88fce54abbdbf4c42fb1fea0e498973d07816f24c0e27a1ecaf91883ce69e"},
-    {file = "coverage-7.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a898c11dca8f8c97b467138004a30133974aacd572818c383596f8d5b2eb04a9"},
-    {file = "coverage-7.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07dfdd492d645eea1bd70fb1d6febdcf47db178b0d99161d8e4eed18e7f62fe7"},
-    {file = "coverage-7.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3d117890b6eee85887b1eed41eefe2e598ad6e40523d9f94c4c4b213258e4a4"},
-    {file = "coverage-7.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6afd2e84e7da40fe23ca588379f815fb6dbbb1b757c883935ed11647205111cb"},
-    {file = "coverage-7.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a9960dd1891b2ddf13a7fe45339cd59ecee3abb6b8326d8b932d0c5da208104f"},
-    {file = "coverage-7.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ced268e82af993d7801a9db2dbc1d2322e786c5dc76295d8e89473d46c6b84d4"},
-    {file = "coverage-7.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e7c211f25777746d468d76f11719e64acb40eed410d81c26cefac641975beb88"},
-    {file = "coverage-7.5.0-cp38-cp38-win32.whl", hash = "sha256:262fffc1f6c1a26125d5d573e1ec379285a3723363f3bd9c83923c9593a2ac25"},
-    {file = "coverage-7.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:eed462b4541c540d63ab57b3fc69e7d8c84d5957668854ee4e408b50e92ce26a"},
-    {file = "coverage-7.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d0194d654e360b3e6cc9b774e83235bae6b9b2cac3be09040880bb0e8a88f4a1"},
-    {file = "coverage-7.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:33c020d3322662e74bc507fb11488773a96894aa82a622c35a5a28673c0c26f5"},
-    {file = "coverage-7.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbdf2cae14a06827bec50bd58e49249452d211d9caddd8bd80e35b53cb04631"},
-    {file = "coverage-7.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3235d7c781232e525b0761730e052388a01548bd7f67d0067a253887c6e8df46"},
-    {file = "coverage-7.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2de4e546f0ec4b2787d625e0b16b78e99c3e21bc1722b4977c0dddf11ca84e"},
-    {file = "coverage-7.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4d0e206259b73af35c4ec1319fd04003776e11e859936658cb6ceffdeba0f5be"},
-    {file = "coverage-7.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:2055c4fb9a6ff624253d432aa471a37202cd8f458c033d6d989be4499aed037b"},
-    {file = "coverage-7.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:075299460948cd12722a970c7eae43d25d37989da682997687b34ae6b87c0ef0"},
-    {file = "coverage-7.5.0-cp39-cp39-win32.whl", hash = "sha256:280132aada3bc2f0fac939a5771db4fbb84f245cb35b94fae4994d4c1f80dae7"},
-    {file = "coverage-7.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:c58536f6892559e030e6924896a44098bc1290663ea12532c78cef71d0df8493"},
-    {file = "coverage-7.5.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:2b57780b51084d5223eee7b59f0d4911c31c16ee5aa12737c7a02455829ff067"},
-    {file = "coverage-7.5.0.tar.gz", hash = "sha256:cf62d17310f34084c59c01e027259076479128d11e4661bb6c9acb38c5e19bb8"},
+    {file = "coverage-7.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a6519d917abb15e12380406d721e37613e2a67d166f9fb7e5a8ce0375744cd45"},
+    {file = "coverage-7.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aea7da970f1feccf48be7335f8b2ca64baf9b589d79e05b9397a06696ce1a1ec"},
+    {file = "coverage-7.5.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:923b7b1c717bd0f0f92d862d1ff51d9b2b55dbbd133e05680204465f454bb286"},
+    {file = "coverage-7.5.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62bda40da1e68898186f274f832ef3e759ce929da9a9fd9fcf265956de269dbc"},
+    {file = "coverage-7.5.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8b7339180d00de83e930358223c617cc343dd08e1aa5ec7b06c3a121aec4e1d"},
+    {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:25a5caf742c6195e08002d3b6c2dd6947e50efc5fc2c2205f61ecb47592d2d83"},
+    {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:05ac5f60faa0c704c0f7e6a5cbfd6f02101ed05e0aee4d2822637a9e672c998d"},
+    {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:239a4e75e09c2b12ea478d28815acf83334d32e722e7433471fbf641c606344c"},
+    {file = "coverage-7.5.3-cp310-cp310-win32.whl", hash = "sha256:a5812840d1d00eafae6585aba38021f90a705a25b8216ec7f66aebe5b619fb84"},
+    {file = "coverage-7.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:33ca90a0eb29225f195e30684ba4a6db05dbef03c2ccd50b9077714c48153cac"},
+    {file = "coverage-7.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f81bc26d609bf0fbc622c7122ba6307993c83c795d2d6f6f6fd8c000a770d974"},
+    {file = "coverage-7.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7cec2af81f9e7569280822be68bd57e51b86d42e59ea30d10ebdbb22d2cb7232"},
+    {file = "coverage-7.5.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55f689f846661e3f26efa535071775d0483388a1ccfab899df72924805e9e7cd"},
+    {file = "coverage-7.5.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50084d3516aa263791198913a17354bd1dc627d3c1639209640b9cac3fef5807"},
+    {file = "coverage-7.5.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:341dd8f61c26337c37988345ca5c8ccabeff33093a26953a1ac72e7d0103c4fb"},
+    {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ab0b028165eea880af12f66086694768f2c3139b2c31ad5e032c8edbafca6ffc"},
+    {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5bc5a8c87714b0c67cfeb4c7caa82b2d71e8864d1a46aa990b5588fa953673b8"},
+    {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:38a3b98dae8a7c9057bd91fbf3415c05e700a5114c5f1b5b0ea5f8f429ba6614"},
+    {file = "coverage-7.5.3-cp311-cp311-win32.whl", hash = "sha256:fcf7d1d6f5da887ca04302db8e0e0cf56ce9a5e05f202720e49b3e8157ddb9a9"},
+    {file = "coverage-7.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:8c836309931839cca658a78a888dab9676b5c988d0dd34ca247f5f3e679f4e7a"},
+    {file = "coverage-7.5.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:296a7d9bbc598e8744c00f7a6cecf1da9b30ae9ad51c566291ff1314e6cbbed8"},
+    {file = "coverage-7.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:34d6d21d8795a97b14d503dcaf74226ae51eb1f2bd41015d3ef332a24d0a17b3"},
+    {file = "coverage-7.5.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e317953bb4c074c06c798a11dbdd2cf9979dbcaa8ccc0fa4701d80042d4ebf1"},
+    {file = "coverage-7.5.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:705f3d7c2b098c40f5b81790a5fedb274113373d4d1a69e65f8b68b0cc26f6db"},
+    {file = "coverage-7.5.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1196e13c45e327d6cd0b6e471530a1882f1017eb83c6229fc613cd1a11b53cd"},
+    {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:015eddc5ccd5364dcb902eaecf9515636806fa1e0d5bef5769d06d0f31b54523"},
+    {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fd27d8b49e574e50caa65196d908f80e4dff64d7e592d0c59788b45aad7e8b35"},
+    {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:33fc65740267222fc02975c061eb7167185fef4cc8f2770267ee8bf7d6a42f84"},
+    {file = "coverage-7.5.3-cp312-cp312-win32.whl", hash = "sha256:7b2a19e13dfb5c8e145c7a6ea959485ee8e2204699903c88c7d25283584bfc08"},
+    {file = "coverage-7.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0bbddc54bbacfc09b3edaec644d4ac90c08ee8ed4844b0f86227dcda2d428fcb"},
+    {file = "coverage-7.5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f78300789a708ac1f17e134593f577407d52d0417305435b134805c4fb135adb"},
+    {file = "coverage-7.5.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b368e1aee1b9b75757942d44d7598dcd22a9dbb126affcbba82d15917f0cc155"},
+    {file = "coverage-7.5.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f836c174c3a7f639bded48ec913f348c4761cbf49de4a20a956d3431a7c9cb24"},
+    {file = "coverage-7.5.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:244f509f126dc71369393ce5fea17c0592c40ee44e607b6d855e9c4ac57aac98"},
+    {file = "coverage-7.5.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4c2872b3c91f9baa836147ca33650dc5c172e9273c808c3c3199c75490e709d"},
+    {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dd4b3355b01273a56b20c219e74e7549e14370b31a4ffe42706a8cda91f19f6d"},
+    {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f542287b1489c7a860d43a7d8883e27ca62ab84ca53c965d11dac1d3a1fab7ce"},
+    {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:75e3f4e86804023e991096b29e147e635f5e2568f77883a1e6eed74512659ab0"},
+    {file = "coverage-7.5.3-cp38-cp38-win32.whl", hash = "sha256:c59d2ad092dc0551d9f79d9d44d005c945ba95832a6798f98f9216ede3d5f485"},
+    {file = "coverage-7.5.3-cp38-cp38-win_amd64.whl", hash = "sha256:fa21a04112c59ad54f69d80e376f7f9d0f5f9123ab87ecd18fbb9ec3a2beed56"},
+    {file = "coverage-7.5.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5102a92855d518b0996eb197772f5ac2a527c0ec617124ad5242a3af5e25f85"},
+    {file = "coverage-7.5.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d1da0a2e3b37b745a2b2a678a4c796462cf753aebf94edcc87dcc6b8641eae31"},
+    {file = "coverage-7.5.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8383a6c8cefba1b7cecc0149415046b6fc38836295bc4c84e820872eb5478b3d"},
+    {file = "coverage-7.5.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9aad68c3f2566dfae84bf46295a79e79d904e1c21ccfc66de88cd446f8686341"},
+    {file = "coverage-7.5.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e079c9ec772fedbade9d7ebc36202a1d9ef7291bc9b3a024ca395c4d52853d7"},
+    {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bde997cac85fcac227b27d4fb2c7608a2c5f6558469b0eb704c5726ae49e1c52"},
+    {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:990fb20b32990b2ce2c5f974c3e738c9358b2735bc05075d50a6f36721b8f303"},
+    {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3d5a67f0da401e105753d474369ab034c7bae51a4c31c77d94030d59e41df5bd"},
+    {file = "coverage-7.5.3-cp39-cp39-win32.whl", hash = "sha256:e08c470c2eb01977d221fd87495b44867a56d4d594f43739a8028f8646a51e0d"},
+    {file = "coverage-7.5.3-cp39-cp39-win_amd64.whl", hash = "sha256:1d2a830ade66d3563bb61d1e3c77c8def97b30ed91e166c67d0632c018f380f0"},
+    {file = "coverage-7.5.3-pp38.pp39.pp310-none-any.whl", hash = "sha256:3538d8fb1ee9bdd2e2692b3b18c22bb1c19ffbefd06880f5ac496e42d7bb3884"},
+    {file = "coverage-7.5.3.tar.gz", hash = "sha256:04aefca5190d1dc7a53a4c1a5a7f8568811306d7a8ee231c42fb69215571944f"},
 ]
 
 [package.dependencies]
@@ -605,13 +605,13 @@ testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"]
 
 [[package]]
 name = "jinja2"
-version = "3.1.3"
+version = "3.1.4"
 description = "A very fast and expressive template engine."
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"},
-    {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"},
+    {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"},
+    {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"},
 ]
 
 [package.dependencies]
@@ -821,13 +821,13 @@ traitlets = "*"
 
 [[package]]
 name = "mdit-py-plugins"
-version = "0.4.0"
+version = "0.4.1"
 description = "Collection of plugins for markdown-it-py"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "mdit_py_plugins-0.4.0-py3-none-any.whl", hash = "sha256:b51b3bb70691f57f974e257e367107857a93b36f322a9e6d44ca5bf28ec2def9"},
-    {file = "mdit_py_plugins-0.4.0.tar.gz", hash = "sha256:d8ab27e9aed6c38aa716819fedfde15ca275715955f8a185a8e1cf90fb1d2c1b"},
+    {file = "mdit_py_plugins-0.4.1-py3-none-any.whl", hash = "sha256:1020dfe4e6bfc2c79fb49ae4e3f5b297f5ccd20f010187acc52af2921e27dc6a"},
+    {file = "mdit_py_plugins-0.4.1.tar.gz", hash = "sha256:834b8ac23d1cd60cec703646ffd22ae97b7955a6d596eb1d304be1e251ae499c"},
 ]
 
 [package.dependencies]
@@ -878,27 +878,24 @@ testing-docutils = ["pygments", "pytest (>=8,<9)", "pytest-param-files (>=0.6.0,
 
 [[package]]
 name = "nodeenv"
-version = "1.8.0"
+version = "1.9.1"
 description = "Node.js virtual environment builder"
 optional = false
-python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*"
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
 files = [
-    {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"},
-    {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"},
+    {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
+    {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
 ]
 
-[package.dependencies]
-setuptools = "*"
-
 [[package]]
 name = "packaging"
-version = "24.0"
+version = "24.1"
 description = "Core utilities for Python packages"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"},
-    {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"},
+    {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
+    {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
 ]
 
 [[package]]
@@ -943,13 +940,13 @@ files = [
 
 [[package]]
 name = "platformdirs"
-version = "4.2.1"
+version = "4.2.2"
 description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "platformdirs-4.2.1-py3-none-any.whl", hash = "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1"},
-    {file = "platformdirs-4.2.1.tar.gz", hash = "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf"},
+    {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"},
+    {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"},
 ]
 
 [package.extras]
@@ -992,13 +989,13 @@ virtualenv = ">=20.10.0"
 
 [[package]]
 name = "prompt-toolkit"
-version = "3.0.43"
+version = "3.0.46"
 description = "Library for building powerful interactive command lines in Python"
 optional = false
 python-versions = ">=3.7.0"
 files = [
-    {file = "prompt_toolkit-3.0.43-py3-none-any.whl", hash = "sha256:a11a29cb3bf0a28a387fe5122cdb649816a957cd9261dcedf8c9f1fef33eacf6"},
-    {file = "prompt_toolkit-3.0.43.tar.gz", hash = "sha256:3527b7af26106cbc65a040bcc84839a3566ec1b051bb0bfe953631e704b0ff7d"},
+    {file = "prompt_toolkit-3.0.46-py3-none-any.whl", hash = "sha256:45abe60a8300f3c618b23c16c4bb98c6fc80af8ce8b17c7ae92db48db3ee63c1"},
+    {file = "prompt_toolkit-3.0.46.tar.gz", hash = "sha256:869c50d682152336e23c4db7f74667639b5047494202ffe7670817053fd57795"},
 ]
 
 [package.dependencies]
@@ -1031,18 +1028,18 @@ tests = ["pytest"]
 
 [[package]]
 name = "pydantic"
-version = "2.7.1"
+version = "2.7.3"
 description = "Data validation using Python type hints"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "pydantic-2.7.1-py3-none-any.whl", hash = "sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5"},
-    {file = "pydantic-2.7.1.tar.gz", hash = "sha256:e9dbb5eada8abe4d9ae5f46b9939aead650cd2b68f249bb3a8139dbe125803cc"},
+    {file = "pydantic-2.7.3-py3-none-any.whl", hash = "sha256:ea91b002777bf643bb20dd717c028ec43216b24a6001a280f83877fd2655d0b4"},
+    {file = "pydantic-2.7.3.tar.gz", hash = "sha256:c46c76a40bb1296728d7a8b99aa73dd70a48c3510111ff290034f860c99c419e"},
 ]
 
 [package.dependencies]
 annotated-types = ">=0.4.0"
-pydantic-core = "2.18.2"
+pydantic-core = "2.18.4"
 typing-extensions = ">=4.6.1"
 
 [package.extras]
@@ -1050,90 +1047,90 @@ email = ["email-validator (>=2.0.0)"]
 
 [[package]]
 name = "pydantic-core"
-version = "2.18.2"
+version = "2.18.4"
 description = "Core functionality for Pydantic validation and serialization"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "pydantic_core-2.18.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9e08e867b306f525802df7cd16c44ff5ebbe747ff0ca6cf3fde7f36c05a59a81"},
-    {file = "pydantic_core-2.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f0a21cbaa69900cbe1a2e7cad2aa74ac3cf21b10c3efb0fa0b80305274c0e8a2"},
-    {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0680b1f1f11fda801397de52c36ce38ef1c1dc841a0927a94f226dea29c3ae3d"},
-    {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:95b9d5e72481d3780ba3442eac863eae92ae43a5f3adb5b4d0a1de89d42bb250"},
-    {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fcf5cd9c4b655ad666ca332b9a081112cd7a58a8b5a6ca7a3104bc950f2038"},
-    {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b5155ff768083cb1d62f3e143b49a8a3432e6789a3abee8acd005c3c7af1c74"},
-    {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553ef617b6836fc7e4df130bb851e32fe357ce36336d897fd6646d6058d980af"},
-    {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89ed9eb7d616ef5714e5590e6cf7f23b02d0d539767d33561e3675d6f9e3857"},
-    {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:75f7e9488238e920ab6204399ded280dc4c307d034f3924cd7f90a38b1829563"},
-    {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ef26c9e94a8c04a1b2924149a9cb081836913818e55681722d7f29af88fe7b38"},
-    {file = "pydantic_core-2.18.2-cp310-none-win32.whl", hash = "sha256:182245ff6b0039e82b6bb585ed55a64d7c81c560715d1bad0cbad6dfa07b4027"},
-    {file = "pydantic_core-2.18.2-cp310-none-win_amd64.whl", hash = "sha256:e23ec367a948b6d812301afc1b13f8094ab7b2c280af66ef450efc357d2ae543"},
-    {file = "pydantic_core-2.18.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:219da3f096d50a157f33645a1cf31c0ad1fe829a92181dd1311022f986e5fbe3"},
-    {file = "pydantic_core-2.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc1cfd88a64e012b74e94cd00bbe0f9c6df57049c97f02bb07d39e9c852e19a4"},
-    {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b7133a6e6aeb8df37d6f413f7705a37ab4031597f64ab56384c94d98fa0e90"},
-    {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:224c421235f6102e8737032483f43c1a8cfb1d2f45740c44166219599358c2cd"},
-    {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b14d82cdb934e99dda6d9d60dc84a24379820176cc4a0d123f88df319ae9c150"},
-    {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2728b01246a3bba6de144f9e3115b532ee44bd6cf39795194fb75491824a1413"},
-    {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:470b94480bb5ee929f5acba6995251ada5e059a5ef3e0dfc63cca287283ebfa6"},
-    {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:997abc4df705d1295a42f95b4eec4950a37ad8ae46d913caeee117b6b198811c"},
-    {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75250dbc5290e3f1a0f4618db35e51a165186f9034eff158f3d490b3fed9f8a0"},
-    {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4456f2dca97c425231d7315737d45239b2b51a50dc2b6f0c2bb181fce6207664"},
-    {file = "pydantic_core-2.18.2-cp311-none-win32.whl", hash = "sha256:269322dcc3d8bdb69f054681edff86276b2ff972447863cf34c8b860f5188e2e"},
-    {file = "pydantic_core-2.18.2-cp311-none-win_amd64.whl", hash = "sha256:800d60565aec896f25bc3cfa56d2277d52d5182af08162f7954f938c06dc4ee3"},
-    {file = "pydantic_core-2.18.2-cp311-none-win_arm64.whl", hash = "sha256:1404c69d6a676245199767ba4f633cce5f4ad4181f9d0ccb0577e1f66cf4c46d"},
-    {file = "pydantic_core-2.18.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fb2bd7be70c0fe4dfd32c951bc813d9fe6ebcbfdd15a07527796c8204bd36242"},
-    {file = "pydantic_core-2.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6132dd3bd52838acddca05a72aafb6eab6536aa145e923bb50f45e78b7251043"},
-    {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d904828195733c183d20a54230c0df0eb46ec746ea1a666730787353e87182"},
-    {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9bd70772c720142be1020eac55f8143a34ec9f82d75a8e7a07852023e46617f"},
-    {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8ed04b3582771764538f7ee7001b02e1170223cf9b75dff0bc698fadb00cf3"},
-    {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6dac87ddb34aaec85f873d737e9d06a3555a1cc1a8e0c44b7f8d5daeb89d86f"},
-    {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ca4ae5a27ad7a4ee5170aebce1574b375de390bc01284f87b18d43a3984df72"},
-    {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:886eec03591b7cf058467a70a87733b35f44707bd86cf64a615584fd72488b7c"},
-    {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ca7b0c1f1c983e064caa85f3792dd2fe3526b3505378874afa84baf662e12241"},
-    {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b4356d3538c3649337df4074e81b85f0616b79731fe22dd11b99499b2ebbdf3"},
-    {file = "pydantic_core-2.18.2-cp312-none-win32.whl", hash = "sha256:8b172601454f2d7701121bbec3425dd71efcb787a027edf49724c9cefc14c038"},
-    {file = "pydantic_core-2.18.2-cp312-none-win_amd64.whl", hash = "sha256:b1bd7e47b1558ea872bd16c8502c414f9e90dcf12f1395129d7bb42a09a95438"},
-    {file = "pydantic_core-2.18.2-cp312-none-win_arm64.whl", hash = "sha256:98758d627ff397e752bc339272c14c98199c613f922d4a384ddc07526c86a2ec"},
-    {file = "pydantic_core-2.18.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:9fdad8e35f278b2c3eb77cbdc5c0a49dada440657bf738d6905ce106dc1de439"},
-    {file = "pydantic_core-2.18.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1d90c3265ae107f91a4f279f4d6f6f1d4907ac76c6868b27dc7fb33688cfb347"},
-    {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390193c770399861d8df9670fb0d1874f330c79caaca4642332df7c682bf6b91"},
-    {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:82d5d4d78e4448683cb467897fe24e2b74bb7b973a541ea1dcfec1d3cbce39fb"},
-    {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4774f3184d2ef3e14e8693194f661dea5a4d6ca4e3dc8e39786d33a94865cefd"},
-    {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4d938ec0adf5167cb335acb25a4ee69a8107e4984f8fbd2e897021d9e4ca21b"},
-    {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0e8b1be28239fc64a88a8189d1df7fad8be8c1ae47fcc33e43d4be15f99cc70"},
-    {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:868649da93e5a3d5eacc2b5b3b9235c98ccdbfd443832f31e075f54419e1b96b"},
-    {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:78363590ef93d5d226ba21a90a03ea89a20738ee5b7da83d771d283fd8a56761"},
-    {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:852e966fbd035a6468fc0a3496589b45e2208ec7ca95c26470a54daed82a0788"},
-    {file = "pydantic_core-2.18.2-cp38-none-win32.whl", hash = "sha256:6a46e22a707e7ad4484ac9ee9f290f9d501df45954184e23fc29408dfad61350"},
-    {file = "pydantic_core-2.18.2-cp38-none-win_amd64.whl", hash = "sha256:d91cb5ea8b11607cc757675051f61b3d93f15eca3cefb3e6c704a5d6e8440f4e"},
-    {file = "pydantic_core-2.18.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ae0a8a797a5e56c053610fa7be147993fe50960fa43609ff2a9552b0e07013e8"},
-    {file = "pydantic_core-2.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:042473b6280246b1dbf530559246f6842b56119c2926d1e52b631bdc46075f2a"},
-    {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a388a77e629b9ec814c1b1e6b3b595fe521d2cdc625fcca26fbc2d44c816804"},
-    {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25add29b8f3b233ae90ccef2d902d0ae0432eb0d45370fe315d1a5cf231004b"},
-    {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f459a5ce8434614dfd39bbebf1041952ae01da6bed9855008cb33b875cb024c0"},
-    {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eff2de745698eb46eeb51193a9f41d67d834d50e424aef27df2fcdee1b153845"},
-    {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8309f67285bdfe65c372ea3722b7a5642680f3dba538566340a9d36e920b5f0"},
-    {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f93a8a2e3938ff656a7c1bc57193b1319960ac015b6e87d76c76bf14fe0244b4"},
-    {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:22057013c8c1e272eb8d0eebc796701167d8377441ec894a8fed1af64a0bf399"},
-    {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfeecd1ac6cc1fb2692c3d5110781c965aabd4ec5d32799773ca7b1456ac636b"},
-    {file = "pydantic_core-2.18.2-cp39-none-win32.whl", hash = "sha256:0d69b4c2f6bb3e130dba60d34c0845ba31b69babdd3f78f7c0c8fae5021a253e"},
-    {file = "pydantic_core-2.18.2-cp39-none-win_amd64.whl", hash = "sha256:d9319e499827271b09b4e411905b24a426b8fb69464dfa1696258f53a3334641"},
-    {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a1874c6dd4113308bd0eb568418e6114b252afe44319ead2b4081e9b9521fe75"},
-    {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ccdd111c03bfd3666bd2472b674c6899550e09e9f298954cfc896ab92b5b0e6d"},
-    {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e18609ceaa6eed63753037fc06ebb16041d17d28199ae5aba0052c51449650a9"},
-    {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e5c584d357c4e2baf0ff7baf44f4994be121e16a2c88918a5817331fc7599d7"},
-    {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43f0f463cf89ace478de71a318b1b4f05ebc456a9b9300d027b4b57c1a2064fb"},
-    {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e1b395e58b10b73b07b7cf740d728dd4ff9365ac46c18751bf8b3d8cca8f625a"},
-    {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0098300eebb1c837271d3d1a2cd2911e7c11b396eac9661655ee524a7f10587b"},
-    {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:36789b70d613fbac0a25bb07ab3d9dba4d2e38af609c020cf4d888d165ee0bf3"},
-    {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3f9a801e7c8f1ef8718da265bba008fa121243dfe37c1cea17840b0944dfd72c"},
-    {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3a6515ebc6e69d85502b4951d89131ca4e036078ea35533bb76327f8424531ce"},
-    {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20aca1e2298c56ececfd8ed159ae4dde2df0781988c97ef77d5c16ff4bd5b400"},
-    {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:223ee893d77a310a0391dca6df00f70bbc2f36a71a895cecd9a0e762dc37b349"},
-    {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2334ce8c673ee93a1d6a65bd90327588387ba073c17e61bf19b4fd97d688d63c"},
-    {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cbca948f2d14b09d20268cda7b0367723d79063f26c4ffc523af9042cad95592"},
-    {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b3ef08e20ec49e02d5c6717a91bb5af9b20f1805583cb0adfe9ba2c6b505b5ae"},
-    {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6fdc8627910eed0c01aed6a390a252fe3ea6d472ee70fdde56273f198938374"},
-    {file = "pydantic_core-2.18.2.tar.gz", hash = "sha256:2e29d20810dfc3043ee13ac7d9e25105799817683348823f305ab3f349b9386e"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f76d0ad001edd426b92233d45c746fd08f467d56100fd8f30e9ace4b005266e4"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:59ff3e89f4eaf14050c8022011862df275b552caef8082e37b542b066ce1ff26"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a55b5b16c839df1070bc113c1f7f94a0af4433fcfa1b41799ce7606e5c79ce0a"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4d0dcc59664fcb8974b356fe0a18a672d6d7cf9f54746c05f43275fc48636851"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8951eee36c57cd128f779e641e21eb40bc5073eb28b2d23f33eb0ef14ffb3f5d"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4701b19f7e3a06ea655513f7938de6f108123bf7c86bbebb1196eb9bd35cf724"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e00a3f196329e08e43d99b79b286d60ce46bed10f2280d25a1718399457e06be"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:97736815b9cc893b2b7f663628e63f436018b75f44854c8027040e05230eeddb"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6891a2ae0e8692679c07728819b6e2b822fb30ca7445f67bbf6509b25a96332c"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bc4ff9805858bd54d1a20efff925ccd89c9d2e7cf4986144b30802bf78091c3e"},
+    {file = "pydantic_core-2.18.4-cp310-none-win32.whl", hash = "sha256:1b4de2e51bbcb61fdebd0ab86ef28062704f62c82bbf4addc4e37fa4b00b7cbc"},
+    {file = "pydantic_core-2.18.4-cp310-none-win_amd64.whl", hash = "sha256:6a750aec7bf431517a9fd78cb93c97b9b0c496090fee84a47a0d23668976b4b0"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:942ba11e7dfb66dc70f9ae66b33452f51ac7bb90676da39a7345e99ffb55402d"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b2ebef0e0b4454320274f5e83a41844c63438fdc874ea40a8b5b4ecb7693f1c4"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a642295cd0c8df1b86fc3dced1d067874c353a188dc8e0f744626d49e9aa51c4"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f09baa656c904807e832cf9cce799c6460c450c4ad80803517032da0cd062e2"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98906207f29bc2c459ff64fa007afd10a8c8ac080f7e4d5beff4c97086a3dabd"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19894b95aacfa98e7cb093cd7881a0c76f55731efad31073db4521e2b6ff5b7d"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fbbdc827fe5e42e4d196c746b890b3d72876bdbf160b0eafe9f0334525119c8"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f85d05aa0918283cf29a30b547b4df2fbb56b45b135f9e35b6807cb28bc47951"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e85637bc8fe81ddb73fda9e56bab24560bdddfa98aa64f87aaa4e4b6730c23d2"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2f5966897e5461f818e136b8451d0551a2e77259eb0f73a837027b47dc95dab9"},
+    {file = "pydantic_core-2.18.4-cp311-none-win32.whl", hash = "sha256:44c7486a4228413c317952e9d89598bcdfb06399735e49e0f8df643e1ccd0558"},
+    {file = "pydantic_core-2.18.4-cp311-none-win_amd64.whl", hash = "sha256:8a7164fe2005d03c64fd3b85649891cd4953a8de53107940bf272500ba8a788b"},
+    {file = "pydantic_core-2.18.4-cp311-none-win_arm64.whl", hash = "sha256:4e99bc050fe65c450344421017f98298a97cefc18c53bb2f7b3531eb39bc7805"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6f5c4d41b2771c730ea1c34e458e781b18cc668d194958e0112455fff4e402b2"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fdf2156aa3d017fddf8aea5adfba9f777db1d6022d392b682d2a8329e087cef"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4748321b5078216070b151d5271ef3e7cc905ab170bbfd27d5c83ee3ec436695"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847a35c4d58721c5dc3dba599878ebbdfd96784f3fb8bb2c356e123bdcd73f34"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c40d4eaad41f78e3bbda31b89edc46a3f3dc6e171bf0ecf097ff7a0ffff7cb1"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:21a5e440dbe315ab9825fcd459b8814bb92b27c974cbc23c3e8baa2b76890077"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01dd777215e2aa86dfd664daed5957704b769e726626393438f9c87690ce78c3"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b06beb3b3f1479d32befd1f3079cc47b34fa2da62457cdf6c963393340b56e9"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:564d7922e4b13a16b98772441879fcdcbe82ff50daa622d681dd682175ea918c"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0eb2a4f660fcd8e2b1c90ad566db2b98d7f3f4717c64fe0a83e0adb39766d5b8"},
+    {file = "pydantic_core-2.18.4-cp312-none-win32.whl", hash = "sha256:8b8bab4c97248095ae0c4455b5a1cd1cdd96e4e4769306ab19dda135ea4cdb07"},
+    {file = "pydantic_core-2.18.4-cp312-none-win_amd64.whl", hash = "sha256:14601cdb733d741b8958224030e2bfe21a4a881fb3dd6fbb21f071cabd48fa0a"},
+    {file = "pydantic_core-2.18.4-cp312-none-win_arm64.whl", hash = "sha256:c1322d7dd74713dcc157a2b7898a564ab091ca6c58302d5c7b4c07296e3fd00f"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:823be1deb01793da05ecb0484d6c9e20baebb39bd42b5d72636ae9cf8350dbd2"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ebef0dd9bf9b812bf75bda96743f2a6c5734a02092ae7f721c048d156d5fabae"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae1d6df168efb88d7d522664693607b80b4080be6750c913eefb77e34c12c71a"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9899c94762343f2cc2fc64c13e7cae4c3cc65cdfc87dd810a31654c9b7358cc"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99457f184ad90235cfe8461c4d70ab7dd2680e28821c29eca00252ba90308c78"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18f469a3d2a2fdafe99296a87e8a4c37748b5080a26b806a707f25a902c040a8"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7cdf28938ac6b8b49ae5e92f2735056a7ba99c9b110a474473fd71185c1af5d"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:938cb21650855054dc54dfd9120a851c974f95450f00683399006aa6e8abb057"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:44cd83ab6a51da80fb5adbd9560e26018e2ac7826f9626bc06ca3dc074cd198b"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:972658f4a72d02b8abfa2581d92d59f59897d2e9f7e708fdabe922f9087773af"},
+    {file = "pydantic_core-2.18.4-cp38-none-win32.whl", hash = "sha256:1d886dc848e60cb7666f771e406acae54ab279b9f1e4143babc9c2258213daa2"},
+    {file = "pydantic_core-2.18.4-cp38-none-win_amd64.whl", hash = "sha256:bb4462bd43c2460774914b8525f79b00f8f407c945d50881568f294c1d9b4443"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:44a688331d4a4e2129140a8118479443bd6f1905231138971372fcde37e43528"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a2fdd81edd64342c85ac7cf2753ccae0b79bf2dfa063785503cb85a7d3593223"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86110d7e1907ab36691f80b33eb2da87d780f4739ae773e5fc83fb272f88825f"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46387e38bd641b3ee5ce247563b60c5ca098da9c56c75c157a05eaa0933ed154"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:123c3cec203e3f5ac7b000bd82235f1a3eced8665b63d18be751f115588fea30"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dc1803ac5c32ec324c5261c7209e8f8ce88e83254c4e1aebdc8b0a39f9ddb443"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53db086f9f6ab2b4061958d9c276d1dbe3690e8dd727d6abf2321d6cce37fa94"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abc267fa9837245cc28ea6929f19fa335f3dc330a35d2e45509b6566dc18be23"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a0d829524aaefdebccb869eed855e2d04c21d2d7479b6cada7ace5448416597b"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:509daade3b8649f80d4e5ff21aa5673e4ebe58590b25fe42fac5f0f52c6f034a"},
+    {file = "pydantic_core-2.18.4-cp39-none-win32.whl", hash = "sha256:ca26a1e73c48cfc54c4a76ff78df3727b9d9f4ccc8dbee4ae3f73306a591676d"},
+    {file = "pydantic_core-2.18.4-cp39-none-win_amd64.whl", hash = "sha256:c67598100338d5d985db1b3d21f3619ef392e185e71b8d52bceacc4a7771ea7e"},
+    {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:574d92eac874f7f4db0ca653514d823a0d22e2354359d0759e3f6a406db5d55d"},
+    {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1f4d26ceb5eb9eed4af91bebeae4b06c3fb28966ca3a8fb765208cf6b51102ab"},
+    {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77450e6d20016ec41f43ca4a6c63e9fdde03f0ae3fe90e7c27bdbeaece8b1ed4"},
+    {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d323a01da91851a4f17bf592faf46149c9169d68430b3146dcba2bb5e5719abc"},
+    {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43d447dd2ae072a0065389092a231283f62d960030ecd27565672bd40746c507"},
+    {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:578e24f761f3b425834f297b9935e1ce2e30f51400964ce4801002435a1b41ef"},
+    {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:81b5efb2f126454586d0f40c4d834010979cb80785173d1586df845a632e4e6d"},
+    {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ab86ce7c8f9bea87b9d12c7f0af71102acbf5ecbc66c17796cff45dae54ef9a5"},
+    {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:90afc12421df2b1b4dcc975f814e21bc1754640d502a2fbcc6d41e77af5ec312"},
+    {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:51991a89639a912c17bef4b45c87bd83593aee0437d8102556af4885811d59f5"},
+    {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:293afe532740370aba8c060882f7d26cfd00c94cae32fd2e212a3a6e3b7bc15e"},
+    {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48ece5bde2e768197a2d0f6e925f9d7e3e826f0ad2271120f8144a9db18d5c8"},
+    {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eae237477a873ab46e8dd748e515c72c0c804fb380fbe6c85533c7de51f23a8f"},
+    {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:834b5230b5dfc0c1ec37b2fda433b271cbbc0e507560b5d1588e2cc1148cf1ce"},
+    {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e858ac0a25074ba4bce653f9b5d0a85b7456eaddadc0ce82d3878c22489fa4ee"},
+    {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2fd41f6eff4c20778d717af1cc50eca52f5afe7805ee530a4fbd0bae284f16e9"},
+    {file = "pydantic_core-2.18.4.tar.gz", hash = "sha256:ec3beeada09ff865c344ff3bc2f427f5e6c26401cc6113d77e372c3fdac73864"},
 ]
 
 [package.dependencies]
@@ -1141,28 +1138,27 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
 
 [[package]]
 name = "pygments"
-version = "2.17.2"
+version = "2.18.0"
 description = "Pygments is a syntax highlighting package written in Python."
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"},
-    {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"},
+    {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"},
+    {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"},
 ]
 
 [package.extras]
-plugins = ["importlib-metadata"]
 windows-terminal = ["colorama (>=0.4.6)"]
 
 [[package]]
 name = "pytest"
-version = "8.2.0"
+version = "8.2.2"
 description = "pytest: simple powerful testing with Python"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "pytest-8.2.0-py3-none-any.whl", hash = "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233"},
-    {file = "pytest-8.2.0.tar.gz", hash = "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f"},
+    {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"},
+    {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"},
 ]
 
 [package.dependencies]
@@ -1283,13 +1279,13 @@ files = [
 
 [[package]]
 name = "requests"
-version = "2.31.0"
+version = "2.32.3"
 description = "Python HTTP for Humans."
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"},
-    {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"},
+    {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
+    {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
 ]
 
 [package.dependencies]
@@ -1336,22 +1332,6 @@ files = [
 docutils = "*"
 rich = ">=12.0.0"
 
-[[package]]
-name = "setuptools"
-version = "69.5.1"
-description = "Easily download, build, install, upgrade, and uninstall Python packages"
-optional = false
-python-versions = ">=3.8"
-files = [
-    {file = "setuptools-69.5.1-py3-none-any.whl", hash = "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"},
-    {file = "setuptools-69.5.1.tar.gz", hash = "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987"},
-]
-
-[package.extras]
-docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
-testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
-testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
-
 [[package]]
 name = "shellingham"
 version = "1.5.4"
@@ -1623,6 +1603,17 @@ files = [
 [package.extras]
 tests = ["pytest", "pytest-cov"]
 
+[[package]]
+name = "toml"
+version = "0.10.2"
+description = "Python Library for Tom's Obvious, Minimal Language"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+    {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
+    {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
+]
+
 [[package]]
 name = "tomli"
 version = "2.0.1"
@@ -1701,13 +1692,13 @@ standard = ["rich (>=10.11.0)", "shellingham (>=1.3.0)"]
 
 [[package]]
 name = "typing-extensions"
-version = "4.11.0"
+version = "4.12.2"
 description = "Backported and Experimental Type Hints for Python 3.8+"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"},
-    {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"},
+    {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
+    {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
 ]
 
 [[package]]
@@ -1743,13 +1734,13 @@ zstd = ["zstandard (>=0.18.0)"]
 
 [[package]]
 name = "virtualenv"
-version = "20.26.1"
+version = "20.26.2"
 description = "Virtual Python Environment builder"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "virtualenv-20.26.1-py3-none-any.whl", hash = "sha256:7aa9982a728ae5892558bff6a2839c00b9ed145523ece2274fad6f414690ae75"},
-    {file = "virtualenv-20.26.1.tar.gz", hash = "sha256:604bfdceaeece392802e6ae48e69cec49168b9c5f4a44e483963f9242eb0e78b"},
+    {file = "virtualenv-20.26.2-py3-none-any.whl", hash = "sha256:a624db5e94f01ad993d476b9ee5346fdf7b9de43ccaee0e0197012dc838a0e9b"},
+    {file = "virtualenv-20.26.2.tar.gz", hash = "sha256:82bf0f4eebbb78d36ddaee0283d43fe5736b53880b8a8cdcd37390a07ac3741c"},
 ]
 
 [package.dependencies]
@@ -1774,20 +1765,24 @@ files = [
 
 [[package]]
 name = "zipp"
-version = "3.18.1"
+version = "3.19.2"
 description = "Backport of pathlib-compatible object wrapper for zip files"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "zipp-3.18.1-py3-none-any.whl", hash = "sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b"},
-    {file = "zipp-3.18.1.tar.gz", hash = "sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715"},
+    {file = "zipp-3.19.2-py3-none-any.whl", hash = "sha256:f091755f667055f2d02b32c53771a7a6c8b47e1fdbc4b72a8b9072b3eef8015c"},
+    {file = "zipp-3.19.2.tar.gz", hash = "sha256:bf1dcf6450f873a13e952a29504887c89e6de7506209e5b1bcc3460135d4de19"},
 ]
 
 [package.extras]
-docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
+doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
+
+[extras]
+toml = ["tomli"]
+yaml = ["pyyaml"]
 
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.8"
-content-hash = "caaf256ce644ff0bb1aaf8b1bdace63e91fbeeca4d04932761461278efea56cc"
+content-hash = "3b60f820d714c7af196128ad30b3178c25041b65eaf8abc4635efa37370d04d6"
diff --git a/pyproject.toml b/pyproject.toml
index fd6603f9..6dc877f3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,6 +33,8 @@ attrs = ">=23.1.0"
 rich = ">=13.6.0"
 docstring-parser = "^0.15"
 rich-rst = "^1.3.1"
+tomli = {version = ">=2.0.0", python = "<3.11", optional = true}
+pyyaml = {version = ">=6.0.1", optional = true}
 
 [tool.poetry.group.docs.dependencies]
 sphinx = "~7.1.2"
@@ -52,6 +54,8 @@ typer = "0.12.0"
 arguably = "^1.2.5"
 fire = ">=0.5,<0.7"
 pydantic = "^2.7.0"
+toml = "^0.10.2"
+pyyaml = ">=6.0.1"
 
 [tool.poetry.group.debug]
 optional = true
@@ -60,6 +64,10 @@ optional = true
 ipdb = ">=0.13.9"
 line_profiler = ">=3.5.1"
 
+[tool.poetry.extras]
+toml = ["tomli"]
+yaml = ["pyyaml"]
+
 [tool.coverage.run]
 branch = true
 omit = [
@@ -185,6 +193,9 @@ convention = "numpy"
 "docs/*.py" = [
     "F811",  # redefinition
 ]
+"cyclopts/config/*.py" = [
+    "PTH123",  # `open()` should be replaced by `Path.open()`. Pyright doesn't understand that it must be a Path.
+]
 
 [tool.ruff.lint.pep8-naming]
 staticmethod-decorators = [
@@ -203,6 +214,7 @@ sections=["tool.poetry.dependencies"]
 exclude-deps =[
   "importlib-metadata",
   "typing-extensions",
-  "rich-rst",  # Not detected due to deferred import
-  "rich",  # Not detected due to deferred import
+  "rich-rst",  # Not detected due to deferred import.
+  "rich",  # Not detected due to deferred import.
+  "tomli",  # Not detected due to optional feature.
 ]


Test patch:
diff --git a/tests/apps/config.toml b/tests/apps/config.toml
new file mode 100644
index 00000000..48f2c1f1
--- /dev/null
+++ b/tests/apps/config.toml
@@ -0,0 +1,3 @@
+[create.burger]
+mayo=false
+custom=["sweet-chili", "house-sauce"]
diff --git a/tests/apps/test_burgery.py b/tests/apps/test_burgery.py
index 2b2a1c8c..5bf91516 100644
--- a/tests/apps/test_burgery.py
+++ b/tests/apps/test_burgery.py
@@ -1,9 +1,11 @@
 import sys
+from pathlib import Path
 from textwrap import dedent
-from typing import Literal
+from typing import List, Literal, Optional
 
 import pytest
 
+import cyclopts
 from cyclopts import App, Group, Parameter, validators
 
 if sys.version_info < (3, 9):
@@ -11,8 +13,13 @@
 else:
     from typing import Annotated
 
+config_file = Path(__file__).parent / "config.toml"
 
-app = App(name="burgery", help="Welcome to Cyclopts Burgery!")
+app = App(
+    name="burgery",
+    help="Welcome to Cyclopts Burgery!",
+    config=cyclopts.config.Toml(config_file),
+)
 app.command(create := App(name="create"))
 
 
@@ -22,11 +29,13 @@ def burger(
     quantity: Annotated[int, Parameter(validator=validators.Number(gt=0))] = 1,
     /,
     *,
-    lettuce: Annotated[bool, Parameter(group="Toppings")] = True,
+    lettuce: Annotated[bool, Parameter(name="--iceberg", group="Toppings")] = True,
     tomato: Annotated[bool, Parameter(group="Toppings")] = True,
     onion: Annotated[bool, Parameter(group="Toppings")] = True,
     mustard: Annotated[bool, Parameter(group="Condiments")] = True,
     ketchup: Annotated[bool, Parameter(group="Condiments")] = True,
+    mayo: Annotated[bool, Parameter(group="Condiments")] = True,
+    custom: Annotated[Optional[List[str]], Parameter(group="Condiments")] = None,
 ):
     """Create a burger.
 
@@ -46,15 +55,7 @@ def burger(
     ketchup: bool
         Add ketchup.
     """
-    return {
-        "variety": variety,
-        "quantity": quantity,
-        "lettuce": lettuce,
-        "tomato": tomato,
-        "onion": onion,
-        "ketchup": ketchup,
-        "mustard": mustard,
-    }
+    return locals()
 
 
 def test_create_burger_help(console):
@@ -73,11 +74,13 @@ def test_create_burger_help(console):
         │    QUANTITY  [default: 1]                                          │
         ╰────────────────────────────────────────────────────────────────────╯
         ╭─ Condiments ───────────────────────────────────────────────────────╮
-        │ --mustard,--no-mustard  Add mustard. [default: True]               │
-        │ --ketchup,--no-ketchup  Add ketchup. [default: True]               │
+        │ --mustard,--no-mustard   Add mustard. [default: True]              │
+        │ --ketchup,--no-ketchup   Add ketchup. [default: True]              │
+        │ --mayo,--no-mayo         [default: True]                           │
+        │ --custom,--empty-custom                                            │
         ╰────────────────────────────────────────────────────────────────────╯
         ╭─ Toppings ─────────────────────────────────────────────────────────╮
-        │ --lettuce,--no-lettuce  Add lettuce. [default: True]               │
+        │ --iceberg,--no-iceberg  Add lettuce. [default: True]               │
         │ --tomato,--no-tomato    Add tomato. [default: True]                │
         │ --onion,--no-onion      Add onion. [default: True]                 │
         ╰────────────────────────────────────────────────────────────────────╯
@@ -86,8 +89,14 @@ def test_create_burger_help(console):
     assert actual == expected
 
 
-def test_create_burger():
-    actual = app("create burger classic --lettuce --no-onion --no-ketchup")
+def test_create_burger_1():
+    """Tests generic functionality.
+
+    Detailed:
+        * that config-file overrides (mayo, custom) work.
+        * typical boolean flags work.
+    """
+    actual = app("create burger classic --iceberg --no-onion --no-ketchup --custom sriracha --custom egg")
     assert actual == {
         "variety": "classic",
         "quantity": 1,
@@ -96,8 +105,42 @@ def test_create_burger():
         "onion": False,
         "ketchup": False,
         "mustard": True,
+        "mayo": False,  # Set from config file.
+        "custom": ["sriracha", "egg"],
+    }
+
+
+def test_create_burger_2():
+    """Tests that the list from the toml file correctly populates."""
+    actual = app("create burger classic")
+    assert actual == {
+        "variety": "classic",
+        "quantity": 1,
+        "lettuce": True,
+        "tomato": True,
+        "onion": True,
+        "ketchup": True,
+        "mustard": True,
+        "mayo": False,  # Set from config file.
+        "custom": ["sweet-chili", "house-sauce"],  # Set from config file.
+    }
+
+
+def test_create_burger_3():
+    """Tests the --empty- config override."""
+    actual = app("create burger classic --empty-custom")
+    assert actual == {
+        "variety": "classic",
+        "quantity": 1,
+        "lettuce": True,
+        "tomato": True,
+        "onion": True,
+        "ketchup": True,
+        "mustard": True,
+        "mayo": False,  # Set from config file.
+        "custom": [],
     }
 
 
 if __name__ == "__main__":
-    test_create_burger()
+    test_create_burger_1()
diff --git a/tests/config/test_common.py b/tests/config/test_common.py
new file mode 100644
index 00000000..02274fb7
--- /dev/null
+++ b/tests/config/test_common.py
@@ -0,0 +1,140 @@
+import inspect
+from pathlib import Path
+from typing import Any, Dict
+
+import pytest
+
+from cyclopts.config._common import ConfigFromFile, Unset
+
+
+class Dummy(ConfigFromFile):
+    def _load_config(self, path: Path) -> Dict[str, Any]:
+        return {
+            "key1": "foo1",
+            "key2": "foo2",
+            "function1": {
+                "key1": "bar1",
+                "key2": "bar2",
+            },
+        }
+
+
+class DummyRootKeys(ConfigFromFile):
+    def _load_config(self, path: Path) -> Dict[str, Any]:
+        return {
+            "tool": {
+                "cyclopts": {
+                    "key1": "foo1",
+                    "key2": "foo2",
+                    "function1": {
+                        "key1": "bar1",
+                        "key2": "bar2",
+                    },
+                }
+            }
+        }
+
+
+def function1(key1, key2):
+    pass
+
+
+@pytest.fixture
+def config(tmp_path):
+    return Dummy(tmp_path / "cyclopts-config-test-file.dummy")
+
+
+@pytest.fixture
+def config_root_keys(tmp_path):
+    return DummyRootKeys(tmp_path / "cyclopts-config-test-file.dummy")
+
+
+@pytest.fixture
+def bound():
+    return {
+        "key1": ["cli1"],
+        "key2": Unset(inspect.Parameter("FAKE_NAME", inspect.Parameter.POSITIONAL_OR_KEYWORD)),
+    }
+
+
+@pytest.fixture
+def apps():
+    """App is only used as a dictionary in these tests."""
+    return [{"function1": None}]
+
+
+def test_config_common_root_keys_empty(apps, config, bound):
+    config.path.touch()
+    config(apps, (), bound)
+    expected = {"key1": ["cli1"], "key2": ["foo2"]}
+    assert expected == bound
+
+
+def test_config_common_root_keys_populated(apps, config_root_keys, bound):
+    config_root_keys.path.touch()
+    config_root_keys.root_keys = ["tool", "cyclopts"]
+    config_root_keys(apps, (), bound)
+    expected = {"key1": ["cli1"], "key2": ["foo2"]}
+    assert expected == bound
+
+
+def test_config_common_must_exist_false(config, mocker):
+    """If ``must_exist==False``, then the specified file is allowed to not exist.
+
+    If the file does not exist, then have an empty config.
+    """
+    spy_load_config = mocker.spy(config, "_load_config")
+    config.must_exist = False
+    _ = config.config  # does NOT raise a FileNotFoundError
+    assert config.config == {}
+    spy_load_config.assert_not_called()
+
+
+def test_config_common_must_exist_true(config):
+    """If ``must_exist==True``, then the specified file must exist."""
+    config.must_exist = True
+    with pytest.raises(FileNotFoundError):
+        _ = config.config
+
+
+@pytest.mark.parametrize("must_exist", [True, False])
+def test_config_common_search_parents_true_exists(tmp_path, must_exist, config, mocker):
+    """Tests finding an existing parent."""
+    spy_load_config = mocker.spy(config, "_load_config")
+
+    original_path = config.path
+    original_path.touch()
+    config.path = tmp_path / "folder1" / "folder2" / "folder3" / "folder4" / config.path.name
+    config.must_exist = must_exist
+    config.search_parents = True
+
+    _ = config.config
+
+    spy_load_config.assert_called_once_with(original_path)
+
+
+def test_config_common_must_exist_true_search_parents_true_missing(tmp_path, config, mocker):
+    """Tests finding a missing parent."""
+    spy_load_config = mocker.spy(config, "_load_config")
+
+    config.path = tmp_path / "folder1" / "folder2" / "folder3" / "folder4" / config.path.name
+    config.must_exist = True
+    config.search_parents = True
+
+    with pytest.raises(FileNotFoundError):
+        _ = config.config
+
+    spy_load_config.assert_not_called()
+
+
+def test_config_common_must_exist_false_search_parents_true_missing(tmp_path, config, mocker):
+    """Tests finding a missing parent."""
+    spy_load_config = mocker.spy(config, "_load_config")
+
+    config.path = tmp_path / "folder1" / "folder2" / "folder3" / "folder4" / config.path.name
+    config.must_exist = False
+    config.search_parents = True
+
+    assert config.config == {}
+
+    spy_load_config.assert_not_called()
diff --git a/tests/config/test_end2end.py b/tests/config/test_end2end.py
new file mode 100644
index 00000000..c9d0cbfc
--- /dev/null
+++ b/tests/config/test_end2end.py
@@ -0,0 +1,68 @@
+from textwrap import dedent
+
+import pytest
+
+from cyclopts.config import Toml
+from cyclopts.exceptions import MissingArgumentError
+
+
+def test_config_end2end(app, tmp_path, assert_parse_args):
+    config_fn = tmp_path / "config.toml"
+
+    config_fn.write_text(
+        dedent(
+            """\
+            [tool.cyclopts]
+            key1 = "foo1"
+            key2 = "foo2"
+
+            [tool.cyclopts.function1]
+            key3 = "bar1"
+            key4 = "bar2"
+            """
+        )
+    )
+
+    app.config = Toml(config_fn, root_keys=["tool", "cyclopts"])
+
+    @app.default
+    def default(key1, key2):
+        pass
+
+    @app.command
+    def function1(key3, key4):
+        pass
+
+    assert_parse_args(default, "foo", key1="foo", key2="foo2")
+    assert_parse_args(function1, "function1 --key4=fizz", key3="bar1", key4="fizz")
+
+
+def test_config_end2end_deleting_keys(app, assert_parse_args):
+    def my_config_foo(apps, commands, mapping):
+        # Sets all values to foo
+        for key in mapping:
+            mapping[key] = ["foo"]
+
+    def my_config_del(apps, commands, mapping):
+        # Just unsets everything
+        mapping.clear()
+
+    @app.command
+    def my_command(key1, key2):
+        pass
+
+    app.config = my_config_foo
+    assert_parse_args(my_command, "my-command", key1="foo", key2="foo")
+
+    # ``my_config_del`` will delete all the passed in arguments,
+    # resulting in a MissingArgumentError.
+    app.config = my_config_del
+    with pytest.raises(MissingArgumentError):
+        app("my-command foo foo", exit_on_error=False)
+    app.config = (my_config_foo, my_config_del)
+    with pytest.raises(MissingArgumentError):
+        app("my-command foo foo", exit_on_error=False)
+
+    # Deleting, then applying foo value should work.
+    app.config = (my_config_del, my_config_foo)
+    assert_parse_args(my_command, "my-command", key1="foo", key2="foo")
diff --git a/tests/config/test_env.py b/tests/config/test_env.py
new file mode 100644
index 00000000..d373207e
--- /dev/null
+++ b/tests/config/test_env.py
@@ -0,0 +1,35 @@
+import os
+from pathlib import Path
+from typing import Iterable, List, Optional, Tuple, Union
+
+import pytest
+
+import cyclopts
+
+
+def test_config_env_default(app):
+    app = cyclopts.App(
+        config=cyclopts.config.Env("CYCLOPTS_TEST_APP_"),
+    )
+
+    @app.command
+    def foo(bar_baz: int):
+        assert bar_baz == 100
+
+    os.environ["CYCLOPTS_TEST_APP_FOO_BAR_BAZ"] = "100"
+
+    app("foo")
+
+
+def test_config_env_command_false(app):
+    app = cyclopts.App(
+        config=cyclopts.config.Env("CYCLOPTS_TEST_APP_", command=False),
+    )
+
+    @app.command
+    def foo(bar: int):
+        assert bar == 100
+
+    os.environ["CYCLOPTS_TEST_APP_BAR"] = "100"
+
+    app("foo")
diff --git a/tests/config/test_json.py b/tests/config/test_json.py
new file mode 100644
index 00000000..e9d943b7
--- /dev/null
+++ b/tests/config/test_json.py
@@ -0,0 +1,34 @@
+from textwrap import dedent
+
+from cyclopts.config._json import Json
+
+
+def test_config_json(tmp_path):
+    fn = tmp_path / "test.yaml"
+    fn.write_text(
+        dedent(
+            """\
+            {
+                "foo": {
+                    "key1": "foo1",
+                    "key2": "foo2",
+                    "function1": {
+                        "key1": "bar1",
+                        "key2": "bar2"
+                    }
+                }
+            }
+            """
+        )
+    )
+    config = Json(fn)
+    assert config.config == {
+        "foo": {
+            "key1": "foo1",
+            "key2": "foo2",
+            "function1": {
+                "key1": "bar1",
+                "key2": "bar2",
+            },
+        }
+    }
diff --git a/tests/config/test_toml.py b/tests/config/test_toml.py
new file mode 100644
index 00000000..26c7339d
--- /dev/null
+++ b/tests/config/test_toml.py
@@ -0,0 +1,31 @@
+from textwrap import dedent
+
+from cyclopts.config._toml import Toml
+
+
+def test_config_toml(tmp_path):
+    fn = tmp_path / "test.toml"
+    fn.write_text(
+        dedent(
+            """\
+            [foo]
+            key1 = "foo1"
+            key2 = "foo2"
+
+            [foo.function1]
+            key1 = "bar1"
+            key2 = "bar2"
+            """
+        )
+    )
+    config = Toml(fn)
+    assert config.config == {
+        "foo": {
+            "key1": "foo1",
+            "key2": "foo2",
+            "function1": {
+                "key1": "bar1",
+                "key2": "bar2",
+            },
+        }
+    }
diff --git a/tests/config/test_yaml.py b/tests/config/test_yaml.py
new file mode 100644
index 00000000..68671b2f
--- /dev/null
+++ b/tests/config/test_yaml.py
@@ -0,0 +1,31 @@
+from textwrap import dedent
+
+from cyclopts.config._yaml import Yaml
+
+
+def test_config_yaml(tmp_path):
+    fn = tmp_path / "test.yaml"
+    fn.write_text(
+        dedent(
+            """\
+            foo:
+                key1: foo1
+                key2: foo2
+
+                function1:
+                    key1: bar1
+                    key2: bar2
+            """
+        )
+    )
+    config = Yaml(fn)
+    assert config.config == {
+        "foo": {
+            "key1": "foo1",
+            "key2": "foo2",
+            "function1": {
+                "key1": "bar1",
+                "key2": "bar2",
+            },
+        }
+    }
diff --git a/tests/test_bind_boolean_flag.py b/tests/test_bind_boolean_flag.py
index 84a3be70..68514fa4 100644
--- a/tests/test_bind_boolean_flag.py
+++ b/tests/test_bind_boolean_flag.py
@@ -9,6 +9,7 @@
     UnknownOptionError,
     ValidationError,
 )
+from cyclopts.exceptions import CoercionError
 
 if sys.version_info < (3, 9):
     from typing_extensions import Annotated
@@ -33,24 +34,6 @@ def foo(my_flag: bool = True):
     assert_parse_args(foo, cmd_str, expected)
 
 
-@pytest.mark.parametrize(
-    "cmd_str",
-    [
-        "--no-my-flag=True",
-        "--no-my-flag=False",
-    ],
-)
-def test_boolean_flag_negative_assignment_not_allowed(app, cmd_str, assert_parse_args):
-    @app.default
-    def foo(my_flag: bool = True):
-        pass
-
-    with pytest.raises(CycloptsError) as e:
-        app.parse_args(cmd_str, exit_on_error=False, print_error=True)
-
-    assert str(e.value) == 'Cannot assign value to negative flag "--no-my-flag".'
-
-
 def test_boolean_flag_app_parameter_default(app, assert_parse_args):
     app.default_parameter = Parameter(negative="")
 
@@ -66,7 +49,7 @@ def foo(my_flag: bool = True):
     assert str(e.value) == 'Unknown option: "--no-my-flag".'
 
 
-def test_boolean_flag_app_parameter_default_negative_only(app, assert_parse_args):
+def test_boolean_flag_app_parameter_negative(app, assert_parse_args):
     @app.default
     def foo(my_flag: Annotated[bool, Parameter("", negative="--no-my-flag")] = True):
         pass
@@ -76,11 +59,11 @@ def foo(my_flag: Annotated[bool, Parameter("", negative="--no-my-flag")] = True)
     with pytest.raises(UnknownOptionError):
         app.parse_args("--my-flag", exit_on_error=False, print_error=True)
 
-    with pytest.raises(CycloptsError):
-        app.parse_args("--no-my-flag=True", exit_on_error=False, print_error=True)
+    assert_parse_args(foo, "--no-my-flag=True", False)
+    assert_parse_args(foo, "--no-my-flag=False", True)
 
-    with pytest.raises(CycloptsError):
-        app.parse_args("--no-my-flag=False", exit_on_error=False, print_error=True)
+    with pytest.raises(CoercionError):
+        app("--no-my-flag=", exit_on_error=False)
 
 
 def test_boolean_flag_app_parameter_default_annotated_override(app, assert_parse_args):
diff --git a/tests/test_bind_empty_iterable.py b/tests/test_bind_empty_iterable.py
index 84e9e267..699ead3b 100644
--- a/tests/test_bind_empty_iterable.py
+++ b/tests/test_bind_empty_iterable.py
@@ -9,6 +9,8 @@
     [
         ("", None),
         ("--empty-my-list", []),
+        ("--empty-my-list=True", []),
+        ("--empty-my-list=False", None),
     ],
 )
 def test_optional_list_empty_flag_default(app, cmd_str, expected, assert_parse_args):
@@ -27,6 +29,8 @@ def foo(my_list: Optional[List[int]] = None):
     [
         ("", None),
         ("--empty-my-set", set()),
+        ("--empty-my-set=True", set()),
+        ("--empty-my-set=False", None),
     ],
 )
 def test_optional_set_empty_flag_default(app, cmd_str, expected, assert_parse_args):
diff --git a/tests/test_env_var.py b/tests/test_env_var.py
new file mode 100644
index 00000000..5836d798
--- /dev/null
+++ b/tests/test_env_var.py
@@ -0,0 +1,49 @@
+from pathlib import Path
+from typing import Iterable, List, Optional, Tuple, Union
+
+import pytest
+
+from cyclopts._env_var import env_var_split
+
+
+def test_env_var_split_path_windows(mocker):
+    mocker.patch("cyclopts._env_var.os.pathsep", ";")
+
+    assert env_var_split(List[Path], r"C:\foo\bar;D:\fizz\buzz") == [
+        r"C:\foo\bar",
+        r"D:\fizz\buzz",
+    ]
+
+
+@pytest.mark.parametrize(
+    "type_",
+    [
+        List[Path],
+        List[Optional[Path]],
+        Tuple[Path, ...],
+        Tuple[Optional[Path], ...],
+    ],
+)
+def test_env_var_split_path_posix_multiple(mocker, type_):
+    mocker.patch("cyclopts._env_var.os.pathsep", ":")
+
+    assert env_var_split(type_, "/foo/bar;:/fizz/buzz") == [
+        "/foo/bar;",
+        "/fizz/buzz",
+    ]
+
+
+def test_env_var_split_path_posix_single(mocker):
+    """Dont split when a single Path is desired."""
+    mocker.patch("cyclopts._env_var.os.pathsep", ":")
+    assert ["foo:bar"] == env_var_split(Path, "foo:bar")
+
+
+def test_env_var_split_path_general():
+    assert ["foo"] == env_var_split(str, "foo")
+    assert ["foo"] == env_var_split(Optional[str], "foo")
+    assert ["foo bar"] == env_var_split(str, "foo bar")
+    assert ["foo", "bar"] == env_var_split(List[str], "foo bar")
+    assert ["foo", "bar"] == env_var_split(Tuple[str, ...], "foo bar")
+    assert ["foo", "bar"] == env_var_split(Iterable[str], "foo bar")
+    assert ["1"] == env_var_split(int, "1")
diff --git a/tests/test_group.py b/tests/test_group.py
index 87bed431..7addb196 100644
--- a/tests/test_group.py
+++ b/tests/test_group.py
@@ -15,10 +15,6 @@
     from typing import Annotated
 
 
-class SentinelError(Exception):
-    pass
-
-
 def upper(type_, *args: str):
     return args[0].upper()
 
diff --git a/tests/test_resolve.py b/tests/test_resolve.py
index ed6fdc4e..42749d0c 100644
--- a/tests/test_resolve.py
+++ b/tests/test_resolve.py
@@ -2,16 +2,15 @@
 
 import pytest
 
+from cyclopts import Group, Parameter
 from cyclopts.exceptions import DocstringError
+from cyclopts.resolve import ResolvedCommand
 
 if sys.version_info < (3, 9):
     from typing_extensions import Annotated
 else:
     from typing import Annotated
 
-from cyclopts import Group, Parameter
-from cyclopts.resolve import ResolvedCommand
-
 
 def test_resolve_docstring():
     def foo(bar):
@@ -24,7 +23,7 @@ def foo(bar):
         pass
 
     res = ResolvedCommand(foo)
-    cparam = res.iparam_to_cparam[res.name_to_iparam["bar"]]
+    cparam = res.iparam_to_cparam[res.pyname_to_iparam["bar"]]
     assert cparam.help == "Bar Docstring."
 
 
@@ -39,7 +38,7 @@ def foo(bar: Annotated[str, Parameter(help="This has priority.")]):
         pass
 
     res = ResolvedCommand(foo)
-    cparam = res.iparam_to_cparam[res.name_to_iparam["bar"]]
+    cparam = res.iparam_to_cparam[res.pyname_to_iparam["bar"]]
     assert cparam.help == "This has priority."