Skip to content

Layered configuration with command line arguments #148

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
aschey opened this issue Feb 22, 2025 · 4 comments · May be fixed by #155
Open

Layered configuration with command line arguments #148

aschey opened this issue Feb 22, 2025 · 4 comments · May be fixed by #155

Comments

@aschey
Copy link

aschey commented Feb 22, 2025

Hey, thanks for making this library. I've been really enjoying it. Recently, I wanted to see if it would be easy to support the common three-layered configuration approach of CLI overrides, environment variables, and config files - in order of decreasing precedence. I think this should be doable with a few tweaks.

I tried using partials with clap in order to have a single source of truth for all config values:

#[derive(Config, Args, Debug)]
#[config(env_prefix = "APP_")]
pub struct AppConfig {
    #[arg(long)]
    #[setting(default = 3000)]
    port: usize,
    #[arg(long)]
    #[setting(default = true)]
    secure: bool,
    #[arg(long)]
    #[setting(
        default = vec!["localhost".to_string()],
        parse_env = schematic::env::split_comma, 
    )]
    allowed_hosts: Vec<String>,
}

#[derive(Parser, Debug)]
#[command(version, about)]
struct Cli {
    #[command(flatten)]
    config: PartialAppConfig,
    ...
}

This doesn't work because the extra annotations (#[derive(Args)] plus the #[arg] attributes) are not propagated to the generated PartialAppConfig struct. I think this can work if additional attributes are exposed to allow users to attach arbitrary attributes to the partial struct. Something like this maybe:

#[derive(Config, Args, Debug)]
#[config(partial_derive(Args), env_prefix = "APP_")]
pub struct AppConfig {
    #[setting(default = 3000, partial(arg(long)))]
    port: usize,
    ...
}

The second issue I ran into is with precedence. I'm combining the CLI config with the env/file sources like this:

let cli = Cli::parse();

let mut loader = ConfigLoader::<AppConfig>::new();
loader.file(config_path)?;
let mut partial_config = loader.load_partial(&())?;
partial_config.merge(&(), cli.config)?;
let final_config = config.finalize(&())?;

This almost works, but environment variables currently take precedence over the last partial. Some way to specify the priority might work here:

partial_config.merge_with_priority(&(), cli.config, Priority::Highest)?;

Curious what your thoughts are on this. I'd be happy to work on it if it's something you'd be okay with. Thanks!

@milesj
Copy link
Contributor

milesj commented Feb 22, 2025

Yeah schematic assumes env vars are the highest priority and there's a bit of logic that enforces that. This would be tough to change since this happens in finalize and not the layers/merging.

I'd say your best bet (for the interim) is to just manually mutate the final config outside of schematic.

let mut loader = ConfigLoader::<AppConfig>::new();
loader.file(config_path)?;

let mut config = loader.load()?.config;

if args.some_value {
  config.some_value = args.some_value;
}

@aschey
Copy link
Author

aschey commented Feb 27, 2025

Ah, your comment about finalize gave me an idea. It works if you merge in the final layer after calling finalize.

let mut loader = ConfigLoader::<AppConfig>::new();
loader.file(config_path)?;

let partial_config = loader.load_partial(&())?;
let mut final_partial_config = partial_config.finalize(&())?;
final_partial_config.merge(&(), cli.config)?;
let config = AppConfig::from_partial(final_partial_config);

Guessing this is a slight abuse of finalize, but it's something.

I also made a prototype for the first solution that I mentioned regarding passing additional attributes to the generated partial struct. I haven't tested it thoroughly yet, but I'm happy to clean it up and make a PR if you'd be interested.

@milesj
Copy link
Contributor

milesj commented Mar 5, 2025

Yah I don't think I'll change finalize, but feel free to submit a PR for the derive stuff.

@aschey
Copy link
Author

aschey commented Apr 13, 2025

I just realized that since clap also has environment variable integration, users who want CLI arguments to take precedence can use that instead of schematic's environment variable parsing and that will preserve the expected order of operations. I'm satisfied with that approach.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging a pull request may close this issue.

2 participants