From 8e2ff31e6e532af3a277d79672c2d8b738a3daaa Mon Sep 17 00:00:00 2001 From: dimension-zero Date: Sat, 23 May 2026 18:03:42 +0100 Subject: [PATCH 1/7] feat(Config): Add env-var prefix factory + Argu.MSConfig companion * ConfigReaders.fs: New static member ConfigurationReader.FromEnvironmentVariables(prefix : string) that reads env vars with a fixed prefix prepended to the key. Composed from the existing EnvironmentVariableConfigurationReader via the existing FunctionConfigurationReader, so no new types in core. Existing FromEnvironmentVariables() is untouched. * New project src/Argu.MSConfig: thin adapter exposing any Microsoft.Extensions.Configuration.IConfiguration as an Argu IConfigurationReader. Lives in its own NuGet package so the core stays zero-dep on Microsoft.Extensions.*. * Directory.Packages.props: centrally-managed pin for Microsoft.Extensions.Configuration.Abstractions 8.0.0. * Argu.sln: register the new project under the existing F# tooling configuration. --- Argu.slnx | 1 + Directory.Packages.props | 3 +++ src/Argu.MSConfig/Argu.MSConfig.fsproj | 19 +++++++++++++++++ src/Argu.MSConfig/ConfigurationReader.fs | 27 ++++++++++++++++++++++++ src/Argu/ConfigReaders.fs | 18 ++++++++++++++++ 5 files changed, 68 insertions(+) create mode 100644 src/Argu.MSConfig/Argu.MSConfig.fsproj create mode 100644 src/Argu.MSConfig/ConfigurationReader.fs diff --git a/Argu.slnx b/Argu.slnx index d1cddf5d..8edc9a4d 100644 --- a/Argu.slnx +++ b/Argu.slnx @@ -41,5 +41,6 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index c35f8723..d40a3cc6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,9 @@ + + + diff --git a/src/Argu.MSConfig/Argu.MSConfig.fsproj b/src/Argu.MSConfig/Argu.MSConfig.fsproj new file mode 100644 index 00000000..dddb9de9 --- /dev/null +++ b/src/Argu.MSConfig/Argu.MSConfig.fsproj @@ -0,0 +1,19 @@ + + + netstandard2.0 + true + true + Microsoft.Extensions.Configuration adapter for Argu — exposes any IConfiguration source as an Argu IConfigurationReader. + F#, argument, commandline, parser, configuration + + + + + + + + + + + + diff --git a/src/Argu.MSConfig/ConfigurationReader.fs b/src/Argu.MSConfig/ConfigurationReader.fs new file mode 100644 index 00000000..12d5a48f --- /dev/null +++ b/src/Argu.MSConfig/ConfigurationReader.fs @@ -0,0 +1,27 @@ +namespace Argu.MSConfig + +open Microsoft.Extensions.Configuration + +open Argu + +/// +/// Argu backed by a +/// . +/// The reader returns null when a key is missing, matching the +/// contract of every other Argu configuration reader. +/// +type MicrosoftExtensionsConfigurationReader(configuration : IConfiguration, ?name : string) = + let name = defaultArg name "Microsoft.Extensions.Configuration reader" + interface IConfigurationReader with + member _.Name = name + member _.GetValue(key : string) = + // IConfiguration.[key] returns null for missing keys, which matches Argu's + // expectation. + configuration[key] + +/// Factory helpers mirroring the shape of . +[] +type ConfigurationReader = + /// Create an Argu over an . + static member FromMicrosoftConfiguration(configuration : IConfiguration, ?name : string) : IConfigurationReader = + MicrosoftExtensionsConfigurationReader(configuration, ?name = name) :> _ diff --git a/src/Argu/ConfigReaders.fs b/src/Argu/ConfigReaders.fs index bbc6643a..99fa9f0c 100644 --- a/src/Argu/ConfigReaders.fs +++ b/src/Argu/ConfigReaders.fs @@ -97,6 +97,24 @@ type ConfigurationReader = static member FromEnvironmentVariables() = EnvironmentVariableConfigurationReader() :> IConfigurationReader + /// + /// Create a configuration reader that reads environment variables + /// with a fixed prefix prepended to the requested key. Useful for + /// mapping --foo-bar arguments onto MYAPP_FOO_BAR + /// environment variables without renaming the schema. Reads + /// Process, then User, then Machine targets (same order as + /// ). + /// + /// String prepended to each requested key (e.g. "MYAPP_"). + static member FromEnvironmentVariables(prefix : string) = + let inner = EnvironmentVariableConfigurationReader() :> IConfigurationReader + let read (key : string) = + match inner.GetValue(prefix + key) with + | null -> None + | v -> Some v + FunctionConfigurationReader(read, name = sprintf "Environment Variables (prefix=%s)" prefix) + :> IConfigurationReader + /// Create a configuration reader instance using the application's resident AppSettings configuration static member FromAppSettings() = AppSettingsConfigurationReader() :> IConfigurationReader /// Create a configuration reader instance using a local xml App.Config file From b611d7517142b1d81a9b80856f27138970317e25 Mon Sep 17 00:00:00 2001 From: dimension-zero Date: Sat, 23 May 2026 18:53:08 +0100 Subject: [PATCH 2/7] test(EnvVarPrefix): Add coverage for FromEnvironmentVariables(prefix) 4 new tests: * FromEnvironmentVariables(prefix) prepends the prefix to the requested key, so reader.GetValue('HOST') reads env var MYAPP_HOST. * Missing keys come back as null (Argu's standard contract). * Round-trip through ArgumentParser.ParseConfiguration: a schema using CustomAppSettings keys is populated correctly when only the prefixed env vars are set. * No-arg FromEnvironmentVariables() is still functional (the new overload doesn't shadow the legacy one). Net suite size on this branch: 112 -> 116. --- tests/Argu.Tests/Argu.Tests.fsproj | 1 + tests/Argu.Tests/EnvVarPrefixTests.fs | 53 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 tests/Argu.Tests/EnvVarPrefixTests.fs diff --git a/tests/Argu.Tests/Argu.Tests.fsproj b/tests/Argu.Tests/Argu.Tests.fsproj index 924dcffe..b4148d7b 100644 --- a/tests/Argu.Tests/Argu.Tests.fsproj +++ b/tests/Argu.Tests/Argu.Tests.fsproj @@ -6,6 +6,7 @@ + diff --git a/tests/Argu.Tests/EnvVarPrefixTests.fs b/tests/Argu.Tests/EnvVarPrefixTests.fs new file mode 100644 index 00000000..b0043dfb --- /dev/null +++ b/tests/Argu.Tests/EnvVarPrefixTests.fs @@ -0,0 +1,53 @@ +namespace Argu.Tests + +open System +open Xunit +open Swensen.Unquote + +open Argu + +/// Tests for the env-var prefix configuration reader (PR 20). +module ``Argu Tests EnvVarPrefix`` = + + type Args = + | [] HostName of string + | [] Port of int + interface IArgParserTemplate with member this.Usage = "x" + + let private withEnv (key : string) (value : string) (body : unit -> 'T) : 'T = + let prior = Environment.GetEnvironmentVariable key + try + Environment.SetEnvironmentVariable(key, value) + body () + finally + Environment.SetEnvironmentVariable(key, prior) + + [] + let ``FromEnvironmentVariables(prefix) prepends prefix to key`` () = + let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "MYAPP_") + withEnv "MYAPP_HOST" "example.com" (fun () -> + test <@ reader.GetValue("HOST") = "example.com" @>) + + [] + let ``FromEnvironmentVariables(prefix) returns null for missing keys`` () = + let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "ARGU_TEST_MISSING_") + // Ensure no stray env var; SetEnvironmentVariable(null) clears it. + Environment.SetEnvironmentVariable("ARGU_TEST_MISSING_NOPE", null) + test <@ reader.GetValue("NOPE") = null @> + + [] + let ``FromEnvironmentVariables(prefix) parses round-trip through ArgumentParser`` () = + withEnv "DEMO_HOST" "host.local" (fun () -> + withEnv "DEMO_PORT" "443" (fun () -> + let parser = ArgumentParser.Create(programName = "demo") + let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "DEMO_") + let results = parser.ParseConfiguration(reader, ignoreMissing = true) + test <@ results.GetResult(HostName) = "host.local" @> + test <@ results.GetResult(Port) = 443 @>)) + + [] + let ``No-arg FromEnvironmentVariables still works without prefix`` () = + // Sanity check: the legacy zero-arg overload is preserved. + withEnv "ARGU_TEST_PLAIN" "value" (fun () -> + let reader = ConfigurationReader.FromEnvironmentVariables() + test <@ reader.GetValue("ARGU_TEST_PLAIN") = "value" @>) From 1f581fba9ce2daea4621ad28d136fb05d7b27685 Mon Sep 17 00:00:00 2001 From: dimension-zero <127850950+dimension-zero@users.noreply.github.com> Date: Sun, 7 Jun 2026 08:04:28 +0100 Subject: [PATCH 3/7] refactor: Rename Argu.MSConfig -> Argu.Extensions.Configuration Per review on #308, the M.E.C-style name matches the rest of the Microsoft.Extensions.* ecosystem and avoids the two-letter capitalised 'MSConfig' segment. * src/Argu.MSConfig/ -> src/Argu.Extensions.Configuration/ * Argu.MSConfig.fsproj -> Argu.Extensions.Configuration.fsproj (AssemblyName / RootNamespace / PackageId all default to the project file name) * Namespace Argu.MSConfig -> Argu.Extensions.Configuration in ConfigurationReader.fs * Solution registration updated in Argu.slnx --- Argu.slnx | 2 +- .../Argu.Extensions.Configuration.fsproj} | 0 .../ConfigurationReader.fs | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/{Argu.MSConfig/Argu.MSConfig.fsproj => Argu.Extensions.Configuration/Argu.Extensions.Configuration.fsproj} (100%) rename src/{Argu.MSConfig => Argu.Extensions.Configuration}/ConfigurationReader.fs (96%) diff --git a/Argu.slnx b/Argu.slnx index a25c43ea..16a4b251 100644 --- a/Argu.slnx +++ b/Argu.slnx @@ -42,6 +42,6 @@ - + diff --git a/src/Argu.MSConfig/Argu.MSConfig.fsproj b/src/Argu.Extensions.Configuration/Argu.Extensions.Configuration.fsproj similarity index 100% rename from src/Argu.MSConfig/Argu.MSConfig.fsproj rename to src/Argu.Extensions.Configuration/Argu.Extensions.Configuration.fsproj diff --git a/src/Argu.MSConfig/ConfigurationReader.fs b/src/Argu.Extensions.Configuration/ConfigurationReader.fs similarity index 96% rename from src/Argu.MSConfig/ConfigurationReader.fs rename to src/Argu.Extensions.Configuration/ConfigurationReader.fs index 12d5a48f..e9aea253 100644 --- a/src/Argu.MSConfig/ConfigurationReader.fs +++ b/src/Argu.Extensions.Configuration/ConfigurationReader.fs @@ -1,4 +1,4 @@ -namespace Argu.MSConfig +namespace Argu.Extensions.Configuration open Microsoft.Extensions.Configuration From d3d90985aba4b575a23b5a0dea2651924d3e0248 Mon Sep 17 00:00:00 2001 From: dimension-zero <127850950+dimension-zero@users.noreply.github.com> Date: Mon, 8 Jun 2026 09:57:07 +0100 Subject: [PATCH 4/7] test(EnvVarPrefix): Scope env overrides via use _ = envOverride (IDisposable) Per review feedback: replace the withEnv wrapper-function helper with an IDisposable-returning envOverride, so each test reads `use _ = envOverride key value` and the prior value is restored in Dispose(). Removes the nested-lambda indentation in the multi-var round-trip test. No behaviour change; 147 tests pass. --- tests/Argu.Tests/EnvVarPrefixTests.fs | 36 +++++++++++++-------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/Argu.Tests/EnvVarPrefixTests.fs b/tests/Argu.Tests/EnvVarPrefixTests.fs index b0043dfb..6975cd02 100644 --- a/tests/Argu.Tests/EnvVarPrefixTests.fs +++ b/tests/Argu.Tests/EnvVarPrefixTests.fs @@ -14,19 +14,19 @@ module ``Argu Tests EnvVarPrefix`` = | [] Port of int interface IArgParserTemplate with member this.Usage = "x" - let private withEnv (key : string) (value : string) (body : unit -> 'T) : 'T = + /// Sets an environment variable and restores its prior value on Dispose, + /// so a test can scope the override with `use _ = envOverride key value`. + let private envOverride (key : string) (value : string) : IDisposable = let prior = Environment.GetEnvironmentVariable key - try - Environment.SetEnvironmentVariable(key, value) - body () - finally - Environment.SetEnvironmentVariable(key, prior) + Environment.SetEnvironmentVariable(key, value) + { new IDisposable with + member _.Dispose() = Environment.SetEnvironmentVariable(key, prior) } [] let ``FromEnvironmentVariables(prefix) prepends prefix to key`` () = let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "MYAPP_") - withEnv "MYAPP_HOST" "example.com" (fun () -> - test <@ reader.GetValue("HOST") = "example.com" @>) + use _ = envOverride "MYAPP_HOST" "example.com" + test <@ reader.GetValue("HOST") = "example.com" @> [] let ``FromEnvironmentVariables(prefix) returns null for missing keys`` () = @@ -37,17 +37,17 @@ module ``Argu Tests EnvVarPrefix`` = [] let ``FromEnvironmentVariables(prefix) parses round-trip through ArgumentParser`` () = - withEnv "DEMO_HOST" "host.local" (fun () -> - withEnv "DEMO_PORT" "443" (fun () -> - let parser = ArgumentParser.Create(programName = "demo") - let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "DEMO_") - let results = parser.ParseConfiguration(reader, ignoreMissing = true) - test <@ results.GetResult(HostName) = "host.local" @> - test <@ results.GetResult(Port) = 443 @>)) + use _ = envOverride "DEMO_HOST" "host.local" + use _ = envOverride "DEMO_PORT" "443" + let parser = ArgumentParser.Create(programName = "demo") + let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "DEMO_") + let results = parser.ParseConfiguration(reader, ignoreMissing = true) + test <@ results.GetResult(HostName) = "host.local" @> + test <@ results.GetResult(Port) = 443 @> [] let ``No-arg FromEnvironmentVariables still works without prefix`` () = // Sanity check: the legacy zero-arg overload is preserved. - withEnv "ARGU_TEST_PLAIN" "value" (fun () -> - let reader = ConfigurationReader.FromEnvironmentVariables() - test <@ reader.GetValue("ARGU_TEST_PLAIN") = "value" @>) + use _ = envOverride "ARGU_TEST_PLAIN" "value" + let reader = ConfigurationReader.FromEnvironmentVariables() + test <@ reader.GetValue("ARGU_TEST_PLAIN") = "value" @> From 0bf399f314117fe20eb0d6a4c463f17736e71831 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Mon, 8 Jun 2026 12:26:45 +0100 Subject: [PATCH 5/7] Polish --- RELEASE_NOTES.md | 1 + .../ConfigurationReader.fs | 3 +- src/Argu/ConfigReaders.fs | 87 +++++++++--------- tests/Argu.Tests/Argu.Tests.fsproj | 1 + tests/Argu.Tests/EnvVarPrefixTests.fs | 90 +++++++++---------- 5 files changed, 86 insertions(+), 96 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 58eaa7ca..5c010357 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,6 +10,7 @@ * Add `ArgumentParser.PrintUsage(..., ?UsageStrings)` for localization support [#303](https://github.com/fsprojects/Argu/pull/303) [@dimension-zero](https://github.com/dimension-zero) * Add AOT annotations [#314](https://github.com/fsprojects/Argu/pull/314) [@dimension-zero](https://github.com/dimension-zero) * Add `SourceGenerator.ArguGenerate` marker [#318](https://github.com/fsprojects/Argu/pull/318) [@dimension-zero](https://github.com/dimension-zero) +* Add `ConfigurationReader.FromEnvironmentVariables(prefix : String)` [#308](https://github.com/fsprojects/Argu/pull/308) [@dimension-zero](https://github.com/dimension-zero) * Obsolete `EqualsAssignmentAttribute`, `ColonAssignmentAttribute`, `CustomAssignmentAttribute`, `EqualsAssignmentOrSpacedAttribute`, `ColonAssignmentOrSpacedAttribute` and `CustomAssignmentOrSpacedAttribute` [#315](https://github.com/fsprojects/Argu/pull/315) [@dimension-zero](https://github.com/dimension-zero) * Obsolete `PostProcessResult`, `PostProcessResults`, `TryPostProcessResult` [#296](https://github.com/fsprojects/Argu/pull/296) [@dimension-zero](https://github.com/dimension-zero) diff --git a/src/Argu.Extensions.Configuration/ConfigurationReader.fs b/src/Argu.Extensions.Configuration/ConfigurationReader.fs index e9aea253..9f845bdc 100644 --- a/src/Argu.Extensions.Configuration/ConfigurationReader.fs +++ b/src/Argu.Extensions.Configuration/ConfigurationReader.fs @@ -15,8 +15,7 @@ type MicrosoftExtensionsConfigurationReader(configuration : IConfiguration, ?nam interface IConfigurationReader with member _.Name = name member _.GetValue(key : string) = - // IConfiguration.[key] returns null for missing keys, which matches Argu's - // expectation. + // IConfiguration[key] returns null for missing keys, which matches Argu's expectation. configuration[key] /// Factory helpers mirroring the shape of . diff --git a/src/Argu/ConfigReaders.fs b/src/Argu/ConfigReaders.fs index d16d765f..09f48588 100644 --- a/src/Argu/ConfigReaders.fs +++ b/src/Argu/ConfigReaders.fs @@ -1,9 +1,9 @@ namespace Argu open System -open System.IO open System.Configuration open System.Collections.Generic +open System.IO open System.Reflection /// Abstract key/value configuration reader @@ -11,16 +11,16 @@ type IConfigurationReader = /// Configuration reader identifier abstract Name : string /// Gets value corresponding to supplied key - abstract GetValue : key:string -> string | null + abstract GetValue : key : string -> string | null /// Configuration reader that never returns a value -type NullConfigurationReader() = +type NullConfigurationReader () = interface IConfigurationReader with - member x.Name = "Null Configuration Reader" - member x.GetValue _ = null + member _.Name = "Null Configuration Reader" + member _.GetValue _ = null /// Environment variable-based configuration reader -type EnvironmentVariableConfigurationReader() = +type EnvironmentVariableConfigurationReader () = // order of environment variable target lookup let targets = [| EnvironmentVariableTarget.Process @@ -28,21 +28,22 @@ type EnvironmentVariableConfigurationReader() = EnvironmentVariableTarget.Machine |] interface IConfigurationReader with - member x.Name = "Environment Variables Configuration Reader" - member x.GetValue(key:string) = + member _.Name = "Environment Variables Configuration Reader" + member _.GetValue(key : string) = + // NOTE this logic varies from a targetless Get in that it will pick up changes that a given process tree + // has yet to propagate. A fresh shell should include machine and user level vars in the process env from the off let folder curr (target : EnvironmentVariableTarget) = match curr with | null -> Environment.GetEnvironmentVariable(key, target) | value -> value - - targets |> Array.fold folder null + (null, targets) ||> Array.fold folder /// Configuration reader dictionary proxy type DictionaryConfigurationReader (keyValueDictionary : IDictionary, ?name : string) = let name = defaultArg name "Dictionary configuration reader." interface IConfigurationReader with member _.Name = name - member _.GetValue(key:string) = + member _.GetValue(key : string) = let ok,value = keyValueDictionary.TryGetValue key if ok then value else null @@ -51,23 +52,20 @@ type FunctionConfigurationReader (configFunc : string -> string option, ?name : let name = defaultArg name "Function configuration reader." interface IConfigurationReader with member _.Name = name - member _.GetValue(key:string) = - match configFunc key with - | None -> null - | Some v -> v + member _.GetValue(key : string) = configFunc key |> Option.toObj /// AppSettings XML configuration reader type AppSettingsConfigurationReader () = interface IConfigurationReader with member _.Name = "AppSettings configuration reader" - member _.GetValue(key:string) = ConfigurationManager.AppSettings[key] + member _.GetValue(key : string) = ConfigurationManager.AppSettings[key] /// AppSettings XML configuration reader type AppSettingsConfigurationFileReader private (xmlPath : string, kv : KeyValueConfigurationCollection) = member _.Path = xmlPath interface IConfigurationReader with - member _.Name = sprintf "App.config configuration reader: %s" xmlPath - member _.GetValue(key:string) = + member _.Name = $"App.config configuration reader: %s{xmlPath}" + member _.GetValue(key : string) = match kv[key] with | null -> null | entry -> entry.Value @@ -75,55 +73,50 @@ type AppSettingsConfigurationFileReader private (xmlPath : string, kv : KeyValue /// Create used supplied XML file path static member Create(path : string) = if not <| File.Exists path then raise <| FileNotFoundException(path) - let fileMap = ExeConfigurationFileMap() - fileMap.ExeConfigFilename <- path + let fileMap = ExeConfigurationFileMap(ExeConfigFilename = path) let config = ConfigurationManager.OpenMappedExeConfiguration(fileMap, ConfigurationUserLevel.None) AppSettingsConfigurationFileReader(path, config.AppSettings.Settings) /// Configuration reader implementations +[] type ConfigurationReader = + /// Create a configuration reader that always returns null - static member NullReader = NullConfigurationReader() :> IConfigurationReader + static member NullReader : IConfigurationReader = NullConfigurationReader() /// Create a configuration reader instance using an IDictionary instance - static member FromDictionary(keyValueDictionary : IDictionary, ?name : string) = - DictionaryConfigurationReader(keyValueDictionary, ?name = name) :> IConfigurationReader + static member FromDictionary(keyValueDictionary : IDictionary, ?name : string) : IConfigurationReader = + DictionaryConfigurationReader(keyValueDictionary, ?name = name) /// Create a configuration reader instance using an F# function - static member FromFunction(reader : string -> string option, ?name : string) = - FunctionConfigurationReader(reader, ?name = name) :> IConfigurationReader + static member FromFunction(reader : string -> string option, ?name : string) : IConfigurationReader = + FunctionConfigurationReader(reader, ?name = name) - /// Create a configuration reader instance using environment variables - static member FromEnvironmentVariables() = - EnvironmentVariableConfigurationReader() :> IConfigurationReader + /// Create a configuration reader instance using environment variables. + /// Reads Process variables, falling back to latest User or latest Machine targets if not found. + static member FromEnvironmentVariables() : IConfigurationReader = EnvironmentVariableConfigurationReader() /// - /// Create a configuration reader that reads environment variables - /// with a fixed prefix prepended to the requested key. Useful for - /// mapping --foo-bar arguments onto MYAPP_FOO_BAR - /// environment variables without renaming the schema. Reads - /// Process, then User, then Machine targets (same order as - /// ). + /// Create a configuration reader that reads environment variables with a fixed prefix prepended to the requested key.
+ /// Maps --foo-bar arguments onto MYAPP_FOO_BAR environment variables.
+ /// As per FromEnvironmentVariables(), reads Process variables, falling back to latest User or latest Machine targets if not found. ///
/// String prepended to each requested key (e.g. "MYAPP_"). - static member FromEnvironmentVariables(prefix : string) = + static member FromEnvironmentVariables(prefix : string) : IConfigurationReader = + if isNull prefix then nullArg (nameof prefix) let inner = EnvironmentVariableConfigurationReader() :> IConfigurationReader - let read (key : string) = - match inner.GetValue(prefix + key) with - | null -> None - | v -> Some v - FunctionConfigurationReader(read, name = sprintf "Environment Variables (prefix=%s)" prefix) - :> IConfigurationReader + let read (key : string) = inner.GetValue(prefix + key) |> Option.ofObj + FunctionConfigurationReader(read, name = $"Environment Variables (prefix=%s{prefix})") /// Create a configuration reader instance using the application's resident AppSettings configuration - static member FromAppSettings() = AppSettingsConfigurationReader() :> IConfigurationReader + static member FromAppSettings() : IConfigurationReader = AppSettingsConfigurationReader() + /// Create a configuration reader instance using a local xml App.Config file - static member FromAppSettingsFile(path : string) = AppSettingsConfigurationFileReader.Create(path) :> IConfigurationReader + static member FromAppSettingsFile(path : string) : IConfigurationReader = AppSettingsConfigurationFileReader.Create path + /// Create a configuration reader instance using the location of an assembly file - static member FromAppSettings(assembly : Assembly) = + static member FromAppSettings(assembly : Assembly) : IConfigurationReader = let path = assembly.Location if String.IsNullOrEmpty path then - sprintf "Assembly location for '%O' is null or empty." assembly.Location - |> invalidArg assembly.FullName - + invalidArg assembly.FullName $"Assembly location for '{assembly.Location}' is null or empty." AppSettingsConfigurationFileReader.Create(path + ".config") :> IConfigurationReader diff --git a/tests/Argu.Tests/Argu.Tests.fsproj b/tests/Argu.Tests/Argu.Tests.fsproj index 8bc44fff..e8dbcdaa 100644 --- a/tests/Argu.Tests/Argu.Tests.fsproj +++ b/tests/Argu.Tests/Argu.Tests.fsproj @@ -2,6 +2,7 @@ net10.0 Exe + enable diff --git a/tests/Argu.Tests/EnvVarPrefixTests.fs b/tests/Argu.Tests/EnvVarPrefixTests.fs index 6975cd02..4ce8bbf7 100644 --- a/tests/Argu.Tests/EnvVarPrefixTests.fs +++ b/tests/Argu.Tests/EnvVarPrefixTests.fs @@ -1,53 +1,49 @@ -namespace Argu.Tests +module Argu.Tests.EnvVarPrefixTests +open Swensen.Unquote open System open Xunit -open Swensen.Unquote open Argu -/// Tests for the env-var prefix configuration reader (PR 20). -module ``Argu Tests EnvVarPrefix`` = - - type Args = - | [] HostName of string - | [] Port of int - interface IArgParserTemplate with member this.Usage = "x" - - /// Sets an environment variable and restores its prior value on Dispose, - /// so a test can scope the override with `use _ = envOverride key value`. - let private envOverride (key : string) (value : string) : IDisposable = - let prior = Environment.GetEnvironmentVariable key - Environment.SetEnvironmentVariable(key, value) - { new IDisposable with - member _.Dispose() = Environment.SetEnvironmentVariable(key, prior) } - - [] - let ``FromEnvironmentVariables(prefix) prepends prefix to key`` () = - let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "MYAPP_") - use _ = envOverride "MYAPP_HOST" "example.com" - test <@ reader.GetValue("HOST") = "example.com" @> - - [] - let ``FromEnvironmentVariables(prefix) returns null for missing keys`` () = - let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "ARGU_TEST_MISSING_") - // Ensure no stray env var; SetEnvironmentVariable(null) clears it. - Environment.SetEnvironmentVariable("ARGU_TEST_MISSING_NOPE", null) - test <@ reader.GetValue("NOPE") = null @> - - [] - let ``FromEnvironmentVariables(prefix) parses round-trip through ArgumentParser`` () = - use _ = envOverride "DEMO_HOST" "host.local" - use _ = envOverride "DEMO_PORT" "443" - let parser = ArgumentParser.Create(programName = "demo") - let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "DEMO_") - let results = parser.ParseConfiguration(reader, ignoreMissing = true) - test <@ results.GetResult(HostName) = "host.local" @> - test <@ results.GetResult(Port) = 443 @> - - [] - let ``No-arg FromEnvironmentVariables still works without prefix`` () = - // Sanity check: the legacy zero-arg overload is preserved. - use _ = envOverride "ARGU_TEST_PLAIN" "value" - let reader = ConfigurationReader.FromEnvironmentVariables() - test <@ reader.GetValue("ARGU_TEST_PLAIN") = "value" @> +type Args = + | [] HostName of string + | [] Port of int + interface IArgParserTemplate with member this.Usage = "x" + +/// Sets an environment variable and restores its prior value on Dispose, +/// so a test can scope the override with `use _ = envOverride key value`. +let private envOverride (key : string) (value : string) : IDisposable = + let prior = Environment.GetEnvironmentVariable key + Environment.SetEnvironmentVariable(key, value) + { new IDisposable with member _.Dispose() = Environment.SetEnvironmentVariable(key, prior) } + +[] +let ``FromEnvironmentVariables(prefix) prepends prefix to key`` () = + let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "MYAPP_") + use _ = envOverride "MYAPP_HOST" "example.com" + test <@ reader.GetValue("HOST") = "example.com" @> + +[] +let ``FromEnvironmentVariables(prefix) returns null for missing keys`` () = + let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "ARGU_TEST_MISSING_") + // Ensure no stray env var; SetEnvironmentVariable(null) clears it. + Environment.SetEnvironmentVariable("ARGU_TEST_MISSING_NOPE", null) + test <@ reader.GetValue("NOPE") = null @> + +[] +let ``FromEnvironmentVariables(prefix) parses round-trip through ArgumentParser`` () = + use _ = envOverride "DEMO_HOST" "host.local" + use _ = envOverride "DEMO_PORT" "443" + let parser = ArgumentParser.Create(programName = "demo") + let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "DEMO_") + let results = parser.ParseConfiguration(reader, ignoreMissing = true) + test <@ results.GetResult(HostName) = "host.local" @> + test <@ results.GetResult(Port) = 443 @> + +[] +let ``No-arg FromEnvironmentVariables still works without prefix`` () = + // Sanity check: the legacy zero-arg overload is preserved. + use _ = envOverride "ARGU_TEST_PLAIN" "value" + let reader = ConfigurationReader.FromEnvironmentVariables() + test <@ reader.GetValue("ARGU_TEST_PLAIN") = "value" @> From 53b910b2a0efb0089c4fa28c1d6cb8a5c9ec1402 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Mon, 8 Jun 2026 12:37:31 +0100 Subject: [PATCH 6/7] Cleanup --- tests/Argu.Tests/Argu.Tests.fsproj | 2 +- tests/Argu.Tests/CoverageTests.fs | 19 ++++------ tests/Argu.Tests/EnvVarPrefixTests.fs | 49 -------------------------- tests/Argu.Tests/EnvVarTests.fs | 50 +++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 62 deletions(-) delete mode 100644 tests/Argu.Tests/EnvVarPrefixTests.fs create mode 100644 tests/Argu.Tests/EnvVarTests.fs diff --git a/tests/Argu.Tests/Argu.Tests.fsproj b/tests/Argu.Tests/Argu.Tests.fsproj index e8dbcdaa..d0c25223 100644 --- a/tests/Argu.Tests/Argu.Tests.fsproj +++ b/tests/Argu.Tests/Argu.Tests.fsproj @@ -7,11 +7,11 @@ + - diff --git a/tests/Argu.Tests/CoverageTests.fs b/tests/Argu.Tests/CoverageTests.fs index 19dd6686..521b4087 100644 --- a/tests/Argu.Tests/CoverageTests.fs +++ b/tests/Argu.Tests/CoverageTests.fs @@ -2,9 +2,9 @@ /// These are behavior-locking tests, not exhaustive functional tests. module Argu.Tests.CoverageTests +open Swensen.Unquote open System open System.Collections.Generic -open Swensen.Unquote open Xunit open Argu @@ -153,20 +153,15 @@ let ``AppSettings: ignoreMissing=true skips mandatory check`` () = test <@ results.TryGetResult RequiredKey = None @> -// === Env-var (covers Argu's existing EnvironmentVariableConfigurationReader) === - type EnvArgs = | [] Val of string interface IArgParserTemplate with member this.Usage = "value" [] -let ``EnvironmentVariableConfigurationReader: reads process env`` () = - let key = "ARGU_TEST_VAL" - let prior = Environment.GetEnvironmentVariable key - try Environment.SetEnvironmentVariable(key, "from-env") - let parser = ArgumentParser.Create() - let reader = ConfigurationReader.FromEnvironmentVariables() - let results = parser.ParseConfiguration(reader, ignoreMissing = true) - test <@ results.GetResult Val = "from-env" @> - finally Environment.SetEnvironmentVariable(key, prior) +let ``ParseConfiguration fallback to EnvironmentVariableConfigurationReader picks up process env`` () = + use _ = EnvVarTests.envOverride "ARGU_TEST_VAL" "from-env" + let parser = ArgumentParser.Create() + let reader = ConfigurationReader.FromEnvironmentVariables() + let results = parser.ParseConfiguration reader + test <@ results.GetResult Val = "from-env" @> diff --git a/tests/Argu.Tests/EnvVarPrefixTests.fs b/tests/Argu.Tests/EnvVarPrefixTests.fs deleted file mode 100644 index 4ce8bbf7..00000000 --- a/tests/Argu.Tests/EnvVarPrefixTests.fs +++ /dev/null @@ -1,49 +0,0 @@ -module Argu.Tests.EnvVarPrefixTests - -open Swensen.Unquote -open System -open Xunit - -open Argu - -type Args = - | [] HostName of string - | [] Port of int - interface IArgParserTemplate with member this.Usage = "x" - -/// Sets an environment variable and restores its prior value on Dispose, -/// so a test can scope the override with `use _ = envOverride key value`. -let private envOverride (key : string) (value : string) : IDisposable = - let prior = Environment.GetEnvironmentVariable key - Environment.SetEnvironmentVariable(key, value) - { new IDisposable with member _.Dispose() = Environment.SetEnvironmentVariable(key, prior) } - -[] -let ``FromEnvironmentVariables(prefix) prepends prefix to key`` () = - let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "MYAPP_") - use _ = envOverride "MYAPP_HOST" "example.com" - test <@ reader.GetValue("HOST") = "example.com" @> - -[] -let ``FromEnvironmentVariables(prefix) returns null for missing keys`` () = - let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "ARGU_TEST_MISSING_") - // Ensure no stray env var; SetEnvironmentVariable(null) clears it. - Environment.SetEnvironmentVariable("ARGU_TEST_MISSING_NOPE", null) - test <@ reader.GetValue("NOPE") = null @> - -[] -let ``FromEnvironmentVariables(prefix) parses round-trip through ArgumentParser`` () = - use _ = envOverride "DEMO_HOST" "host.local" - use _ = envOverride "DEMO_PORT" "443" - let parser = ArgumentParser.Create(programName = "demo") - let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "DEMO_") - let results = parser.ParseConfiguration(reader, ignoreMissing = true) - test <@ results.GetResult(HostName) = "host.local" @> - test <@ results.GetResult(Port) = 443 @> - -[] -let ``No-arg FromEnvironmentVariables still works without prefix`` () = - // Sanity check: the legacy zero-arg overload is preserved. - use _ = envOverride "ARGU_TEST_PLAIN" "value" - let reader = ConfigurationReader.FromEnvironmentVariables() - test <@ reader.GetValue("ARGU_TEST_PLAIN") = "value" @> diff --git a/tests/Argu.Tests/EnvVarTests.fs b/tests/Argu.Tests/EnvVarTests.fs new file mode 100644 index 00000000..55764aa2 --- /dev/null +++ b/tests/Argu.Tests/EnvVarTests.fs @@ -0,0 +1,50 @@ +module Argu.Tests.EnvVarTests + +open Swensen.Unquote +open System +open Xunit + +open Argu + +type Args = + | [] HostName of string + | [] Port of int + interface IArgParserTemplate with member this.Usage = "x" + +/// Sets an environment variable and restores its prior value on Dispose, +/// so a test can scope the override with `use _ = envOverride key value`. +let envOverride (key : string) (value : string) : IDisposable = + let prior = Environment.GetEnvironmentVariable key + Environment.SetEnvironmentVariable(key, value) + { new IDisposable with member _.Dispose() = Environment.SetEnvironmentVariable(key, prior) } + +[] +let ``No-arg FromEnvironmentVariables works without prefix`` () = + use _ = envOverride "ARGU_TEST_PLAIN" "value" + let reader = ConfigurationReader.FromEnvironmentVariables() + test <@ reader.GetValue("ARGU_TEST_PLAIN") = "value" @> + +module Prefixed = + + [] + let ``FromEnvironmentVariables(prefix) prepends prefix to key`` () = + let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "MYAPP_") + use _ = envOverride "MYAPP_HOST" "example.com" + test <@ reader.GetValue("HOST") = "example.com" @> + + [] + let ``FromEnvironmentVariables(prefix) returns null for missing keys`` () = + let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "ARGU_TEST_MISSING_") + // Ensure no stray env var; SetEnvironmentVariable(null) clears it. + Environment.SetEnvironmentVariable("ARGU_TEST_MISSING_NOPE", null) + test <@ reader.GetValue("NOPE") = null @> + + [] + let ``FromEnvironmentVariables(prefix) parses round-trip through ArgumentParser`` () = + use _ = envOverride "DEMO_HOST" "host.local" + use _ = envOverride "DEMO_PORT" "443" + let parser = ArgumentParser.Create(programName = "demo") + let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "DEMO_") + let results = parser.ParseConfiguration(reader, ignoreMissing = true) + test <@ results.GetResult(HostName) = "host.local" @> + test <@ results.GetResult(Port) = 443 @> From 61bc252795fbaedc56c6eff5b31c2c0b067e4de9 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Mon, 8 Jun 2026 12:42:53 +0100 Subject: [PATCH 7/7] CL --- RELEASE_NOTES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5c010357..9e561015 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -3,7 +3,7 @@ * Fix Clarify exception in expr2Uci [#293](https://github.com/fsprojects/Argu/pull/293) [@dimension-zero](https://github.com/dimension-zero) * Fix Report all missing args in error message, not just first level [#297](https://github.com/fsprojects/Argu/pull/297) [@dimension-zero](https://github.com/dimension-zero) * Fix Limit min wordwrap column to 20 [#302](https://github.com/fsprojects/Argu/pull/302) [@dimension-zero](https://github.com/dimension-zero) -* Fix [misrendering of usage when help blank](https://github.com/fsprojects/Argu/issues/173) [#323](https://github.com/fsprojects/Argu/pull/323) [@DominikL1999](https://github.com/DominikL1999) +* Fix [misrendering of usage when parameter description blank](https://github.com/fsprojects/Argu/issues/173) [#323](https://github.com/fsprojects/Argu/pull/323) [@DominikL1999](https://github.com/DominikL1999) * Add `Separator("=", orSpace = true)` and `Separator "="` attribute syntax to replace `Obsolete`d `EqualsAssignment`, `ColonAssignment`, `CustomAssignment`, `EqualsAssignmentOrSpaced`, `ColonAssignmentOrSpaced` and `CustomAssignmentOrSpaced` [#315](https://github.com/fsprojects/Argu/pull/315) [@dimension-zero](https://github.com/dimension-zero) * Add `Argu.Samples.Introspect` sample [#298](https://github.com/fsprojects/Argu/pull/298) [@dimension-zero](https://github.com/dimension-zero) * Add `ArgumentParser.Parse(ParseConfig)` [#307](https://github.com/fsprojects/Argu/pull/307) [@dimension-zero](https://github.com/dimension-zero) @@ -11,6 +11,7 @@ * Add AOT annotations [#314](https://github.com/fsprojects/Argu/pull/314) [@dimension-zero](https://github.com/dimension-zero) * Add `SourceGenerator.ArguGenerate` marker [#318](https://github.com/fsprojects/Argu/pull/318) [@dimension-zero](https://github.com/dimension-zero) * Add `ConfigurationReader.FromEnvironmentVariables(prefix : String)` [#308](https://github.com/fsprojects/Argu/pull/308) [@dimension-zero](https://github.com/dimension-zero) +* Add `ConfigurationReader.FromMicrosoftConfiguration(Microsoft.Extensions.Configuration.IConfiguration)` [#308](https://github.com/fsprojects/Argu/pull/308) [@dimension-zero](https://github.com/dimension-zero) * Obsolete `EqualsAssignmentAttribute`, `ColonAssignmentAttribute`, `CustomAssignmentAttribute`, `EqualsAssignmentOrSpacedAttribute`, `ColonAssignmentOrSpacedAttribute` and `CustomAssignmentOrSpacedAttribute` [#315](https://github.com/fsprojects/Argu/pull/315) [@dimension-zero](https://github.com/dimension-zero) * Obsolete `PostProcessResult`, `PostProcessResults`, `TryPostProcessResult` [#296](https://github.com/fsprojects/Argu/pull/296) [@dimension-zero](https://github.com/dimension-zero)