diff --git a/.gitignore b/.gitignore
index e7716108..5f9ea4e2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -202,4 +202,183 @@ launchSettings.json
/tools/
# Ionide
-.ionide/
\ No newline at end of file
+.ionide/
+
+# Local Recode tooling artifacts (per-machine session data)
+.recode/
+
+# bash
+.fuse_hidden*
+.directory
+.Trash-*
+.nfs*
+nohup.out
+
+# fsharp
+*.rsuser
+*.userosscache
+*.sln.docstates
+*.env
+mono_crash.*
+[Dd]ebugPublic/
+[Rr]eleases/
+[Dd]ebug/x64/
+[Dd]ebugPublic/x64/
+[Rr]elease/x64/
+[Rr]eleases/x64/
+bin/x64/
+obj/x64/
+[Dd]ebug/x86/
+[Dd]ebugPublic/x86/
+[Rr]elease/x86/
+[Rr]eleases/x86/
+bin/x86/
+obj/x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+[Aa][Rr][Mm]64[Ee][Cc]/
+bld/
+[Oo]ut/
+[Ll]og/
+[Ll]ogs/
+**/[Bb]in/*
+Generated\ Files/
+*.trx
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+*.received.*
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+BenchmarkDotNet.Artifacts/
+project.fragment.lock.json
+artifacts/
+.artifacts/
+ScaffoldingReadMe.txt
+StyleCopReport.xml
+*_h.h
+*.idb
+*.iobj
+*.ipdb
+!Directory.Build.rsp
+*_wpftmp.csproj
+*.tlog
+*.svclog
+_Chutzpah*
+*.opendb
+*.VC.db
+*.VC.VC.opendb
+*.sap
+*.e2e
+$tf/
+*.DotSettings.user
+.axoCover/*
+!.axoCover/settings.json
+coverage*.json
+coverage*.xml
+coverage*.info
+*.coverage
+*.coveragexml
+_NCrunch_*
+.NCrunch_*
+nCrunchTemp_*
+*.mm.*
+AutoTest.Net/
+.sass-cache/
+*.azurePubxml
+*.pubxml
+*.publishproj
+PublishScripts/
+*.nupkg
+*.snupkg
+**/[Pp]ackages/*
+!**/[Pp]ackages/build/
+*.nuget.props
+*.nuget.targets
+csx/
+ecf/
+rcf/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+*.[Cc]ache
+!?*.[Cc]ache/
+*.dbproj.schemaview
+*.jfm
+orleans.codegen.cs
+ServiceFabricBackup/
+*.rptproj.bak
+*.mdf
+*.ldf
+*.ndf
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+FakesAssemblies/
+*.GhostDoc.xml
+.ntvs_analysis.dat
+node_modules/
+*.plg
+*.opt
+*.vbw
+*.dsw
+*.dsp
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+**/.paket/paket.exe
+paket-files/
+**/.fake/
+**/.cr/personal
+**/__pycache__/
+*.pyc
+*.tss
+*.jmconfig
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+OpenCover/
+ASALocalRun/
+*.binlog
+MSBuild_Logs/
+.aws-sam
+*.nvuser
+**/.mfractor/
+**/.localhistory/
+.vshistory/
+healthchecksdb
+MigrationBackup/
+**/.ionide/
+FodyWeavers.xsd
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+!.vscode/*.code-snippets
+.history/
+*.vsix
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# OS/IDE
+.idea/
+*.swp
+*.swo
+
diff --git a/Argu.slnx b/Argu.slnx
index 998efbc9..55bb976e 100644
--- a/Argu.slnx
+++ b/Argu.slnx
@@ -35,6 +35,7 @@
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 10331162..3d3a6475 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -19,5 +19,9 @@
+
+
+
+
\ No newline at end of file
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 8a4ecfc3..be889d5d 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -11,6 +11,8 @@
* 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)
+* Add `ConfigurationReader.FromFunctionAsync`, `ConfigurationReader.WithFallback`: Provides support and error handling for batched retrieval from remote configuration stores [#317](https://github.com/fsprojects/Argu/pull/317) [@dimension-zero](https://github.com/dimension-zero)
+* Add `ArgumentParser.ParseAsync`: Handles parsing from an asynchronously loaded configuration (i.e. `ConfigurationReader.FromFunctionAsync`) [#317](https://github.com/fsprojects/Argu/pull/317) [@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/docs/tutorial.fsx b/docs/tutorial.fsx
index d81db7c2..1e74cb7a 100644
--- a/docs/tutorial.fsx
+++ b/docs/tutorial.fsx
@@ -450,6 +450,90 @@ which would yield the following:
+## Async Configuration Sources
+
+When the configuration source is genuinely remote (a secrets vault, a
+distributed key/value store, a hosted config service) the synchronous
+`Parse` shape forces the caller to block on I/O at startup, and involves
+ a roundtrip per setting looked up.
+
+ `ParseAsync` takes an `IAsyncConfigurationReader` instead:
+
+ [lang=fsharp]
+ type Args =
+ | [] DbHost of string
+ | [] Port of int
+ interface IArgParserTemplate with
+ member _.Usage = "..."
+
+ let parser = ArgumentParser.Create()
+ // myVaultReader implements IAsyncConfigurationReader
+ let! results = parser.ParseAsync(argv, configurationReader = myVaultReader)
+
+The interface is **batched by design**: `ParseAsync` collects every
+`AppSettings`-mapped key in the top-level schema and issues a single
+`GetValuesAsync` call. A remote source that supports batched retrieval
+(e.g. AWS SSM `GetParameters`, Azure Key Vault list-and-filter) satisfies
+the whole parse in one round-trip. Implementations should return only
+keys that were found; absent keys are treated as missing values
+(equivalent to `null` from the synchronous reader).
+
+### When to use `ParseAsync` vs alternatives
+
+`ParseAsync` is the right tool when (a) the configuration source is
+async-native and (b) command-line arguments and remote configuration must
+be merged through Argu's normal precedence rules (CLI overrides config).
+
+Alternatives that may fit better:
+
+ * **`Microsoft.Extensions.Configuration`**: aggregate appsettings.json,
+ env vars, command-line, user secrets, and Key Vault behind one
+ `IConfiguration`, then bind to `Argu` via the
+ `Argu.Extensions.Configuration` adapter package. This is the right
+ choice for ASP.NET Core hosting and any app already invested in the
+ Microsoft.Extensions.* ecosystem.
+ * **Host-level resolution**: fetch async config in the host's startup
+ code, hand the resolved values to Argu's synchronous `Parse` via a
+ `DictionaryConfigurationReader`. Appropriate when the host already has an
+ async startup phase the CLI parse can hook into.
+
+`ParseAsync` is most relevant when the config source is genuinely
+per-app and the host has no other async startup phase to leverage.
+
+### Failure modes
+
+`ParseAsync` distinguishes three failure modes:
+
+ 1. **Schema is malformed** — `ArgumentParser.Create` throws an
+ `ArguException` synchronously at construction time. `ParseAsync`
+ never reaches the reader in this case. This is programmer error;
+ the test suite, not the user at runtime, should be seeing such cases.
+ 2. **Remote source fails the batch** — the `Task` returned by
+ `GetValuesAsync` faults (network error, auth failure, vault
+ unreachable). By default the fault propagates out of `ParseAsync`
+ as a fatal startup error. Wrap the reader with
+ `ConfigurationReader.WithFallback` for a fallback policy
+ that substitutes fallback values and/or logs to stderr etc:
+
+ [lang=fsharp]
+ let reader =
+ let handle (ex: exn) =
+ log.Warn("vault unavailable: {0}", ex)
+ readOnlyDict Seq.null
+ ConfigurationReader.WithFallback(myVaultReader, handle)
+ let! results = parser.ParseAsync(configurationReader = reader)
+
+ 3. **User input is bad** — missing mandatory parameters, type
+ coercion failures, conflicting subcommands. These surface as
+ `ArguParseException` from the synchronous parse phase Argu runs
+ after the batch resolves. `ArguParseException` carries a friendly
+ error message; the default error handler prints usage and exits.
+
+Treat the three flavors separately when wiring host-level error
+handling: `ArguException` is a build-the-app failure, `Task` faults
+without a fallback wrapper are a deploy-the-app failure, and
+`ArguParseException` is a user-call-the-app failure.
+
## More Examples
Check out the [samples](https://github.com/fsprojects/Argu/tree/master/samples)
diff --git a/samples/Argu.Samples.AsyncConfig/Argu.Samples.AsyncConfig.fsproj b/samples/Argu.Samples.AsyncConfig/Argu.Samples.AsyncConfig.fsproj
new file mode 100644
index 00000000..2c7d373f
--- /dev/null
+++ b/samples/Argu.Samples.AsyncConfig/Argu.Samples.AsyncConfig.fsproj
@@ -0,0 +1,18 @@
+
+
+ net10.0
+ Exe
+ argu-async-config
+ false
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Argu.Samples.AsyncConfig/Program.fs b/samples/Argu.Samples.AsyncConfig/Program.fs
new file mode 100644
index 00000000..239fd684
--- /dev/null
+++ b/samples/Argu.Samples.AsyncConfig/Program.fs
@@ -0,0 +1,173 @@
+module Argu.Samples.AsyncConfig.Program
+
+open System
+open System.Collections.Generic
+open System.Threading.Tasks
+
+open Argu
+
+// -----------------------------------------------------------------------------
+// CLI schema
+//
+// The schema mixes a few flavours of input so the sample exercises the full
+// ParseAsync contract:
+// --vault-url : required, taken from CLI only (--simulate path ignores it)
+// --db-host : remote-overridable; AppSettings name "db-host"
+// --port : remote-overridable; AppSettings name "port"
+// --feature-flag : remote-overridable; AppSettings name "feature-flag"
+// --simulate : run against an in-process fake reader instead of a real
+// Azure Key Vault. Default true so the sample runs without
+// Azure credentials.
+// -----------------------------------------------------------------------------
+
+type Args =
+ | [] Vault_Url of url:string
+ | [] Db_Host of host:string
+ | [] Port of port:int
+ | [] Feature_Flag of name:string
+ | [] Simulate of value:bool
+
+ interface IArgParserTemplate with
+ member s.Usage =
+ match s with
+ | Vault_Url _ -> "Azure Key Vault URL (https://.vault.azure.net/). Ignored under --simulate true."
+ | Db_Host _ -> "DB hostname. Override the secret 'db-host'."
+ | Port _ -> "TCP port. Override the secret 'port'."
+ | Feature_Flag _ -> "Active feature flag. Override the secret 'feature-flag'."
+ | Simulate _ -> "Use an in-process fake reader instead of Azure Key Vault. Defaults to true."
+
+
+// -----------------------------------------------------------------------------
+// Azure Key Vault reader
+//
+// The Azure SDK exposes GetSecretAsync per secret, no batched primitive. To
+// honour Argu's "one round-trip per parse" intent we issue every requested
+// secret in parallel via Task.WhenAll. For a vault with N relevant secrets
+// this collapses to ceil(N / concurrencyLimit) Azure round-trips - effectively
+// one when N is small.
+// -----------------------------------------------------------------------------
+
+module KeyVault =
+
+ open Azure
+ open Azure.Identity
+ open Azure.Security.KeyVault.Secrets
+
+ /// Build a SecretClient using DefaultAzureCredential (picks up Azure CLI
+ /// login, managed identity, etc., in the documented chain).
+ let createClient (vaultUrl : string) : SecretClient =
+ SecretClient(Uri(vaultUrl), DefaultAzureCredential())
+
+ /// IAsyncConfigurationReader backed by Azure Key Vault.
+ ///
+ /// Failure semantics:
+ /// - Vault unreachable / auth failure -> the underlying Task faults
+ /// with RequestFailedException; the fault propagates out of
+ /// GetValuesAsync. Callers wrap with WithFallback if best-effort
+ /// is desired.
+ /// - Individual secret missing (404) -> caught locally and treated as
+ /// "key not present" (absent from the returned dictionary), matching
+ /// the IConfigurationReader.GetValue null contract.
+ let asReader (client : SecretClient) : IAsyncConfigurationReader =
+ { new IAsyncConfigurationReader with
+ member _.Name = sprintf "Azure Key Vault @ %O" client.VaultUri
+ member _.GetValuesAsync keys = task {
+ let tasks = seq {
+ for k in keys -> task {
+ try let! resp = client.GetSecretAsync(k)
+ return Some (k, resp.Value.Value)
+ // Simulate handling of individual read failures - note we are not trapping generic connectivity errors
+ // For such cases, ConfigurationReader.WithFallback
+ with :? RequestFailedException as ex when ex.Status = 404 ->
+ return None
+ } }
+ let! all = Task.WhenAll tasks
+ return all |> Seq.choose id |> readOnlyDict } }
+
+
+// -----------------------------------------------------------------------------
+// Simulated reader for offline runs
+// -----------------------------------------------------------------------------
+
+module Simulated =
+ /// Fake "remote" reader. Demonstrates the batched contract without
+ /// requiring Azure credentials. The 50ms delay stands in for network RTT
+ /// and proves the call is genuinely async.
+ let reader : IAsyncConfigurationReader =
+ let seed =
+ readOnlyDict [
+ "db-host", "db.prod.internal"
+ "port", "5432"
+ "feature-flag", "v2-routing"
+ ]
+ { new IAsyncConfigurationReader with
+ member _.Name = "simulated-in-process-vault"
+ member _.GetValuesAsync(keys : IReadOnlyCollection) = task {
+ do! Task.Delay 50
+ let result = Dictionary(keys.Count)
+ for k in keys do
+ match seed.TryGetValue k with
+ | true, v -> result[k] <- v
+ | _ -> ()
+ return result :> IReadOnlyDictionary } }
+
+
+// -----------------------------------------------------------------------------
+// Entry point
+//
+// Demonstrates the three-flavour failure model:
+// 1. ArguException at Create -> schema is broken; fatal. exit code 2
+// 2. Faulted Task from reader -> source unavailable; fatal unless wrapped
+// with WithFallback.
+// 3. ArguParseException at Parse -> user input invalid; print usage + exit 1.
+// -----------------------------------------------------------------------------
+
+[]
+let main argv =
+ // (1) ArguException at construction: schema-level errors. We let this
+ // propagate; in real apps a unit test for ArgumentParser.CheckStructure()
+ // ensures this never reaches production. See V7 design in fsprojects/Argu#326.
+ let parser = ArgumentParser.Create(programName = "argu-async-config")
+
+ // Pre-parse the CLI synchronously so we know whether to wire a real vault
+ // reader or the simulated one. The actual parse-with-config runs below.
+ let cli =
+ try parser.Parse(argv, raiseOnUsage = true)
+ with :? ArguParseException as ex ->
+ // (3) ArguParseException: user gave us bad CLI input. Standard friendly error path.
+ eprintfn $"%s{ex.Message}"
+ exit 2
+
+ let useSimulated = cli.GetResult(Simulate, defaultValue = true)
+
+ let asyncReader =
+ if useSimulated then
+ Simulated.reader
+ else
+ let url = cli.GetResult(Vault_Url)
+ KeyVault.asReader (KeyVault.createClient url)
+
+ // (2) Faulted Task from the reader: wrap with WithFallback so vault
+ // unavailability degrades to a stderr output.
+ // Without this wrapper, the fault would propagate and abort startup,
+ // which is also a legitimate choice for hard-required secrets.
+ let reader =
+ let handle (ex: exn) =
+ eprintfn $"WARNING: %s{asyncReader.Name} unavailable; CLI defaults only (%s{ex.Message})"
+ readOnlyDict Seq.empty // TOCONSIDER could provide a set of fallback values
+ |> Task.FromResult
+ ConfigurationReader.WithFallback(asyncReader, fallback = handle)
+
+ let results =
+ try parser.ParseAsync(argv, configurationReader = reader).GetAwaiter().GetResult() // TODO in F# 11, Task.await
+ with :? ArguParseException as ex ->
+ eprintfn $"%s{ex.Message}"
+ exit 1
+
+ printfn $"""Resolved configuration:
+ source = %s{reader.Name}
+ db-host = %s{results.GetResult(Db_Host, defaultValue = "")}
+ port = %s{results.TryGetResult(Port, string) |> Option.defaultValue ""}
+ feature-flag = %s{results.GetResult(Feature_Flag, defaultValue = "")}"""
+
+ 0
diff --git a/src/Argu/ArgumentParser.fs b/src/Argu/ArgumentParser.fs
index d328b872..0f98a702 100644
--- a/src/Argu/ArgumentParser.fs
+++ b/src/Argu/ArgumentParser.fs
@@ -1,4 +1,7 @@
-namespace Argu
+namespace Argu
+
+open System.Collections.Generic
+open System.Threading.Tasks
open FSharp.Quotations
open System.Diagnostics.CodeAnalysis
@@ -198,6 +201,44 @@ and []
with ParserExn (errorCode, msg) -> errorHandler.Exit (msg, errorCode)
+ ///
+ /// Parse command line args, defaulting based on settings retrieved via an async configuration reader.
+ /// The reader is issued a single batched GetValuesAsync call covering every AppSettings-mapped union case in the top-level schema;
+ /// once it resolves, the regular synchronous parse runs against the resulting snapshot.
+ /// This keeps the parser purely synchronous below this method and collapses N round-trips into one when the source supports batched retrieval.
+ ///
+ ///
+ /// Failure modes:
+ /// - A faulted from GetValuesAsync propagates as a fatal startup error.
+ /// Wrap the reader with that defaults to a safe configuration when source unavailable.
+ /// - Keys absent from the returned dictionary are treated as missing values,
+ /// matching the per-key null contract of .
+ ///
+ /// The command line input. NOTE Process System.Environment,Commandline is not used.
+ /// Async configuration reader. NOTE local AppSettings are not used.
+ /// Ignore errors caused by the Mandatory attribute. Defaults to false.
+ /// Ignore CLI arguments that do not match the schema. Defaults to false.
+ /// Treat '--help' parameters as parse errors. Defaults to true.
+ member x.ParseAsync (inputs : string[], configurationReader : IAsyncConfigurationReader, ?ignoreMissing, ?ignoreUnrecognized, ?raiseOnUsage) : Task> = task {
+ // Collect every AppSettings key the top-level schema references.
+ // The sync parser walks subcommand-local schemas separately, so only top-level keys go in this batch.
+ let keys = HashSet()
+ for case in argInfo.Cases.Value do
+ match case.AppSettingsName with
+ | None -> ()
+ | Some key -> keys.Add key |> ignore
+ let! prefetched = configurationReader.GetValuesAsync(keys)
+ let syncReader =
+ { new IConfigurationReader with
+ member _.Name = configurationReader.Name
+ member _.GetValue key = prefetched[key] } // null if missing
+ return x.Parse(
+ inputs = inputs,
+ configurationReader = syncReader,
+ ?ignoreMissing = ignoreMissing,
+ ?ignoreUnrecognized = ignoreUnrecognized,
+ ?raiseOnUsage = raiseOnUsage) }
+
///
/// Converts a sequence of template argument inputs into a ParseResults instance
///
@@ -215,7 +256,7 @@ and []
match case.ParameterInfo.Value with
| SubCommand (_,nestedUnion,_) ->
new ArgumentParser<'SubTemplate>(nestedUnion, _programName, helpTextMessage, _usageStringCharacterWidth, errorHandler)
- | _ -> arguExn "internal error when fetching subparser %O." uci
+ | _ -> arguExn $"internal error when fetching subparser {uci}."
///
/// Gets the F# union tag representation for given argument
diff --git a/src/Argu/ConfigReaders.fs b/src/Argu/ConfigReaders.fs
index 09f48588..ac6a3239 100644
--- a/src/Argu/ConfigReaders.fs
+++ b/src/Argu/ConfigReaders.fs
@@ -1,10 +1,11 @@
-namespace Argu
+namespace Argu
open System
open System.Configuration
open System.Collections.Generic
open System.IO
open System.Reflection
+open System.Threading.Tasks
/// Abstract key/value configuration reader
type IConfigurationReader =
@@ -13,6 +14,35 @@ type IConfigurationReader =
/// Gets value corresponding to supplied key
abstract GetValue : key : string -> string | null
+///
+/// Asynchronous, batched flavour of .
+/// Use when the underlying source is genuinely async (remote config
+/// server, secrets vault, key/value store).
+///
+///
+/// The interface is batched by design:
+/// receives every schema-derived key in a single call so a network
+/// source can satisfy the whole parse with one round-trip.
+/// Implementations should return only keys that were found; missing
+/// keys must be absent from the returned dictionary (matching the
+/// null contract of ).
+/// A faulted propagates to the caller as a fatal
+/// startup error; wrap with
+/// for a best-effort policy that treats source unavailability as
+/// "all keys missing".
+///
+type IAsyncConfigurationReader =
+ /// Configuration reader identifier
+ abstract Name : string
+ ///
+ /// Asynchronously fetches values for the supplied keys.
+ /// Implementations should return a dictionary containing only the
+ /// keys that were found; absent keys are treated as missing
+ /// (equivalent to null from
+ /// ).
+ ///
+ abstract GetValuesAsync : keys : IReadOnlyCollection -> Task>
+
/// Configuration reader that never returns a value
type NullConfigurationReader () =
interface IConfigurationReader with
@@ -39,12 +69,12 @@ type EnvironmentVariableConfigurationReader () =
(null, targets) ||> Array.fold folder
/// Configuration reader dictionary proxy
-type DictionaryConfigurationReader (keyValueDictionary : IDictionary, ?name : string) =
+type DictionaryConfigurationReader (values: IReadOnlyDictionary, ?name : string) =
let name = defaultArg name "Dictionary configuration reader."
interface IConfigurationReader with
member _.Name = name
member _.GetValue(key : string) =
- let ok,value = keyValueDictionary.TryGetValue key
+ let ok,value = values.TryGetValue key
if ok then value else null
/// Function configuration reader proxy
@@ -85,8 +115,8 @@ type ConfigurationReader =
static member NullReader : IConfigurationReader = NullConfigurationReader()
/// Create a configuration reader instance using an IDictionary instance
- static member FromDictionary(keyValueDictionary : IDictionary, ?name : string) : IConfigurationReader =
- DictionaryConfigurationReader(keyValueDictionary, ?name = name)
+ static member FromDictionary(values: IReadOnlyDictionary, ?name : string) : IConfigurationReader =
+ DictionaryConfigurationReader(values, ?name = name)
/// Create a configuration reader instance using an F# function
static member FromFunction(reader : string -> string option, ?name : string) : IConfigurationReader =
@@ -119,4 +149,51 @@ type ConfigurationReader =
let path = assembly.Location
if String.IsNullOrEmpty path then
invalidArg assembly.FullName $"Assembly location for '{assembly.Location}' is null or empty."
- AppSettingsConfigurationFileReader.Create(path + ".config") :> IConfigurationReader
+ AppSettingsConfigurationFileReader.Create(path + ".config")
+
+ ///
+ /// Wraps a synchronous as an .
+ /// Each batch issues one synchronous lookup per key against the wrapped reader;
+ /// no real batching is possible from a per-key source, but the async contract is satisfied via .
+ ///
+ static member AsAsync(reader : IConfigurationReader) : IAsyncConfigurationReader =
+ { new IAsyncConfigurationReader with
+ member _.Name = reader.Name
+ member _.GetValuesAsync(keys : IReadOnlyCollection) =
+ let dict = Dictionary(keys.Count)
+ for k in keys do
+ match reader.GetValue k with
+ | null -> ()
+ | v -> dict[k] <- v
+ Task.FromResult(dict) }
+
+ ///
+ /// Bridges a per-key async source into an .
+ /// The function is invoked once per requested key, sequentially.
+ /// If the underlying source supports batched retrieval natively,
+ /// implement directly to collapse the N round-trips into one.
+ ///
+ static member FromFunctionAsync(reader : string -> Task, ?name : string) : IAsyncConfigurationReader =
+ let name = defaultArg name "Async function configuration reader."
+ { new IAsyncConfigurationReader with
+ member _.Name = name
+ member _.GetValuesAsync(keys : IReadOnlyCollection) = task {
+ let dict = Dictionary(keys.Count)
+ for k in keys do
+ match! reader k with
+ | None -> ()
+ | Some v -> dict[k] <- v
+ return dict } }
+
+ ///
+ /// Wraps an so errors (faulted ) are substituted
+ /// by a substitute configuration rather than failing the parse.
+ ///
+ /// The Async reader to be wrapped.
+ /// Callback that sees the triggering exception, facilitating logging to stderr or telemetry.
+ static member WithFallback(reader : IAsyncConfigurationReader, fallback: exn -> Task>) : IAsyncConfigurationReader =
+ { new IAsyncConfigurationReader with
+ member _.Name = reader.Name + " (with null fallback)"
+ member _.GetValuesAsync(keys : IReadOnlyCollection) = task {
+ try return! reader.GetValuesAsync keys
+ with ex -> return! fallback ex } }
diff --git a/tests/Argu.Tests/Argu.Tests.fsproj b/tests/Argu.Tests/Argu.Tests.fsproj
index a7369f96..749050e3 100644
--- a/tests/Argu.Tests/Argu.Tests.fsproj
+++ b/tests/Argu.Tests/Argu.Tests.fsproj
@@ -11,6 +11,7 @@
+
diff --git a/tests/Argu.Tests/CoverageTests.fs b/tests/Argu.Tests/CoverageTests.fs
index 521b4087..e2f7f142 100644
--- a/tests/Argu.Tests/CoverageTests.fs
+++ b/tests/Argu.Tests/CoverageTests.fs
@@ -126,8 +126,8 @@ let ``AppSettings: missing mandatory key raises`` () =
[]
let ``AppSettings: null or empty value is treated as absent`` () =
let parser = ArgumentParser.Create()
- let dict = dict [ "required-key", "x" // satisfy mandatory
- "optional-key", "" ] // empty string should be treated as absent
+ let dict = readOnlyDict [ "required-key", "x" // satisfy mandatory
+ "optional-key", "" ] // empty string should be treated as absent
let reader = ConfigurationReader.FromDictionary dict
let results = parser.ParseConfiguration(reader, ignoreMissing = false)
test <@ results.TryGetResult(OptionalKey) = None @>
@@ -135,8 +135,8 @@ let ``AppSettings: null or empty value is treated as absent`` () =
[]
let ``AppSettings: invalid type-parse raises with key in message`` () =
let parser = ArgumentParser.Create()
- let dict = dict [ "required-key", "x"
- "int-key", "not-a-number" ]
+ let dict = readOnlyDict [ "required-key", "x"
+ "int-key", "not-a-number" ]
let reader = ConfigurationReader.FromDictionary dict
raisesWith
@@ -147,7 +147,7 @@ let ``AppSettings: invalid type-parse raises with key in message`` () =
[]
let ``AppSettings: ignoreMissing=true skips mandatory check`` () =
let parser = ArgumentParser.Create()
- let reader = ConfigurationReader.FromDictionary(dict [])
+ let reader = ConfigurationReader.FromDictionary(readOnlyDict [])
// Should not raise.
let results = parser.ParseConfiguration(reader, ignoreMissing = true)
test <@ results.TryGetResult RequiredKey = None @>
diff --git a/tests/Argu.Tests/ParseAsyncTests.fs b/tests/Argu.Tests/ParseAsyncTests.fs
new file mode 100644
index 00000000..f7e98f9e
--- /dev/null
+++ b/tests/Argu.Tests/ParseAsyncTests.fs
@@ -0,0 +1,116 @@
+module Argu.Tests.ParseAsync
+
+open Swensen.Unquote
+open System.Collections.Generic
+open System.Threading
+open System.Threading.Tasks
+open Xunit
+
+open Argu
+
+type Args =
+ | TagKey of string
+ | [] PortKey of int
+ interface IArgParserTemplate with member this.Usage = "x"
+
+[]
+let ``ConfigurationReader.AsAsync wraps a sync reader`` () =
+ let dict = readOnlyDict [ "x", "y" ]
+ let sync = ConfigurationReader.FromDictionary dict
+ let async = ConfigurationReader.AsAsync sync
+ let keys : IReadOnlyCollection = [| "x"; "missing" |] :> _
+ let t = async.GetValuesAsync(keys)
+ t.Wait()
+ test <@ t.Result["x"] = "y" @>
+ test <@ not (t.Result.ContainsKey "missing") @>
+ test <@ async.Name = sync.Name @>
+
+[]
+let ``ParseAsync via AsAsync(sync reader) matches sync Parse`` () =
+ let parser = ArgumentParser.Create()
+ let dict = readOnlyDict [ "tagkey", "release"; "port-key", "9090" ]
+ let syncReader = ConfigurationReader.FromDictionary dict
+ let asyncReader = ConfigurationReader.AsAsync syncReader
+
+ let syncResults = parser.Parse(inputs = [||], configurationReader = syncReader)
+ let asyncResults = parser.ParseAsync(inputs = [||], configurationReader = asyncReader).Result
+
+ test <@ syncResults.GetResult(TagKey) = asyncResults.GetResult(TagKey) @>
+ test <@ syncResults.GetResult(PortKey) = asyncResults.GetResult(PortKey) @>
+
+[]
+let ``ParseAsync issues exactly one batched call covering all schema keys`` () =
+ let parser = ArgumentParser.Create()
+ let mutable calls = 0
+ let mutable observedKeys : string [] = [||]
+ let reader =
+ { new IAsyncConfigurationReader with
+ member _.Name = "counted-batched-reader"
+ member _.GetValuesAsync(keys) =
+ Interlocked.Increment(&calls) |> ignore
+ observedKeys <- keys |> Seq.toArray
+ Task.FromResult(readOnlyDict [ "tagkey", "alpha" ]) }
+ let r = parser.ParseAsync(inputs = [||], configurationReader = reader).Result
+ test <@ r.GetResult(TagKey) = "alpha" @>
+ // Single round-trip regardless of key count.
+ test <@ calls = 1 @>
+ // Both schema-derived AppSettings keys land in the one batch.
+ test <@ set observedKeys = set [| "port-key"; "tagkey" |] @>
+
+[]
+let ``FromAsyncFunction adapts an F# async function`` () =
+ let parser = ArgumentParser.Create()
+ let asyncReader =
+ ConfigurationReader.FromFunctionAsync(
+ fun key -> task {
+ return
+ if key = "tagkey" then Some "from-async"
+ else None })
+ let r = parser.ParseAsync(inputs = [||], configurationReader = asyncReader).Result
+ test <@ r.GetResult(TagKey) = "from-async" @>
+
+[]
+let ``Bare faulted reader propagates exception out of ParseAsync`` () = task {
+ let parser = ArgumentParser.Create()
+ let reader =
+ { new IAsyncConfigurationReader with
+ member _.Name = "faulting-reader"
+ member _.GetValuesAsync(_keys) =
+ Task.FromException>(
+ System.AggregateException "vault unavailable") }
+ // No fallback wrapper - the fault is fatal, matching the documented contract.
+ let! agg = Assert.ThrowsAsync(fun () -> parser.ParseAsync(inputs = [||], configurationReader = reader))
+ test <@ agg.Message = "vault unavailable" @> }
+
+[]
+let ``WithFallback downgrades a faulted batch to substitute values`` () =
+ let parser = ArgumentParser.Create()
+ let inner =
+ { new IAsyncConfigurationReader with
+ member _.Name = "faulting-reader"
+ member _.GetValuesAsync(_keys) =
+ Task.FromException>(
+ System.Exception "vault unavailable") }
+ let mutable seenFault : exn option = None
+ let reader =
+ let handle ex =
+ seenFault <- Some ex
+ readOnlyDict [ "tagkey", "fallback" ]
+ |> Task.FromResult
+ ConfigurationReader.WithFallback(inner, fallback = handle)
+ // Fault is swallowed; CLI args still satisfy the parse.
+ let r = parser.ParseAsync(inputs = [||], configurationReader = reader).Result
+ test <@ r.GetResult(TagKey) = "fallback" @>
+ test <@ seenFault |> Option.exists (fun e -> e.Message = "vault unavailable") @>
+
+[]
+let ``ParseAsync passes ignoreUnrecognized through`` () =
+ let parser = ArgumentParser.Create()
+ let reader = ConfigurationReader.AsAsync(ConfigurationReader.NullReader)
+ let r =
+ parser.ParseAsync(
+ inputs = [| "--bogus" |],
+ configurationReader = reader,
+ ignoreUnrecognized = true,
+ raiseOnUsage = false).Result
+ test <@ r.UnrecognizedCliParams |> List.contains "--bogus" @>