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/DIARY-ANNUAL.md b/DIARY-ANNUAL.md
new file mode 100644
index 00000000..00aa5b3c
--- /dev/null
+++ b/DIARY-ANNUAL.md
@@ -0,0 +1,2 @@
+# Annual Diary
+
diff --git a/DIARY-DAILY.md b/DIARY-DAILY.md
new file mode 100644
index 00000000..8094e698
--- /dev/null
+++ b/DIARY-DAILY.md
@@ -0,0 +1,104 @@
+# Daily Diary
+
+## 2026-06-10
+**Docs Cleanup & Repo Hygiene**
+
+- Removed stale ParseConfig reference from release notes to keep docs accurate.
+- Merged latest master changes into async config reader branch to stay in sync.
+- Added ignore patterns for bash, .NET, and IDE artifacts to keep repo clean.
+## 2026-06-08
+**AOT Compatibility & Async Config Reader**
+
+- Trimmed .gitignore down to repo-specific entries, removing 341 lines of generic noise.
+- Marked AOT-incompatible APIs with RequiresDynamicCode and RequiresUnreferencedCode attributes (#314) for native-AOT compatibility.
+- Scaffolded a source generator project with an [ArguGenerate] marker attribute (#318) to support compile-time code generation.
+- Finalized ConfigurationReader.FromEnvironmentVariables(prefix) and FromMicrosoftConfiguration (#308), iterating through several revisions to land the API.
+- Merged upstream master and Ruben Bartelink's tidy-up and combined-commit changes into the async config reader branch, keeping the PR in sync.
+## 2026-06-07
+**Async Config Reader & Benchmarks**
+
+- Redesigned IAsyncConfigurationReader around batched reads for efficiency
+- Added Azure Key Vault sample, tutorial section, and failure-mode docs for async config
+- Added BenchmarkDotNet harness for performance testing (#313)
+- Cleaned up repo by removing .recode tooling artifacts and adding ignore rule
+## 2026-06-05
+**Attribute Cleanup & Config Reader Merge**
+
+- Added unified SeparatorAttribute, marked 6 old separator attributes obsolete (#315)
+- Fixed usage rendering bug when param description empty (#173, #323)
+- Shipped Obsolete markers on deprecated PostProcess overloads (#296)
+- Swapped PrefixDictionary internals to char-keyed trie for perf gain (#316)
+- Merged master into async-config-reader branch, pulling in recent upstream changes
+- Added F# build state to gitignore
+## 2026-06-04
+**ParseConfig Overload & Usage Strings**
+
+- Added a record-based Parse(config) overload for ParseConfig, giving callers a way to invoke parsing with a configuration object instead of separate parameters (#307).
+- Merged latest changes from origin/master, resolving conflicts across 22 files via git-sync.
+- Introduced a UsageStrings record holding localisable labels for the usage/help message, laying groundwork for translating CLI output (#303).
+## 2026-06-03
+**Argu Parser Performance & Cleanup**
+
+- Reverted premature .gitignore expansion (removed 341 lines) to keep repo config minimal
+- Optimized ParseResults.getAllResults by replacing Seq pipeline with direct buffer access
+- Flattened UnionCaseArgInfo fields (Name, CommandLineNames, AppSettingsName) for reduced overhead across 7 files
+- Refactored Cli.parseCommandLinePartial into named helper functions for readability
+- Fixed usage/help output to narrow the Console.WindowWidth exception catch and floor the wrap width calculation
+## 2026-06-02
+**F# Nullable Refs & Test Suite Split**
+
+- Enabled F# nullable reference types on the Argu.fsproj to catch null-related bugs at compile time.
+- Refactored UnParsers to use mutable closure state instead of workarounds, and fixed broken RELEASE_NOTES URLs.
+- Split the monolithic Tests.fs file into three focused modules: Shared, Tests, and PrimitiveTests, improving test organization and maintainability.
+## 2026-06-01
+**Argu Introspection & Async Fixes**
+
+- Fixed ParseAsync so faulted GetValueAsync treated as missing, matching sync parser behaviour
+- Added Argu.Samples.Introspect sample demonstrating introspection API
+- Fixed mandatory-group validation to report all missing groups in single error message instead of one at a time
+- Refactored UnionCaseArgInfo to remove unneeded laziness, tidied parent backreferences
+- Cleaned up repo tooling: removed .recode artifacts and added ignore rules
+## 2026-05-30
+```json
+{
+ "title": "Build Migration & Test Framework Cleanup",
+ "bullets": [
+ "Merged PR #08 adding null-guard checks on entry assembly resolution, plus a changelog correction.",
+ "Migrated the test suite to xunit v3 running on Microsoft Testing Platform v2, cleaning up leftover test config in a follow-up commit.",
+ "Converted the solution file to the new .slnx format, trimming 97 lines from the old .sln.",
+ "Scoped an nowarn suppression to the specific case it was needed for (issue #289) instead of leaving it broad.",
+ "Bumped DotNet.ReproducibleBuilds to 2.0.2.",
+ "Clarified the exception message thrown by expr2Uci for easier debugging (issue #293).",
+ "Added a build workaround to keep SDK 10 compatible with Microsoft Testing Platform v2 after the migration surfaced a break.",
+ "Did general top-and-tail cleanup pass across the touched files."
+ ]
+}
+```
+## 2026-05-28
+**Repo Hygiene: Gitignore Cleanup**
+
+- Expanded .gitignore with build artifacts and IDE-specific patterns for bash, .NET, and common IDEs.
+- Touched 3 files (1 added, 1 modified) to reduce untracked noise in future git status checks.
+## 2026-05-27
+**Docs Toolchain Bump**
+
+- Bumped fsdocs-tool dependency from version 20.0.1 to 22.1.0 to keep the documentation generation toolchain current.
+## 2026-05-25
+**Argu Cleanup & Docs**
+
+- Stripped reimplemented stdlib helpers from Utils.fs (#290), trimming 44 lines of dead weight.
+- Added XML doc summaries to previously-undocumented public members (#304).
+- Documented last-wins attribute pickup behavior in TryGetAttribute (#291).
+- Documented order-sensitive equality on ParseResults (#295).
+- Added 204 lines of coverage tests for error text, AppSettings, and deep subcommand scenarios (#312).
+- Merged master into the async-config-reader branch to stay current.
+## 2026-05-23
+**Async Configuration Reader**
+
+- Added IAsyncConfigurationReader interface plus ParseAsync overload for async config parsing
+- Added test coverage for new IAsyncConfigurationReader and ParseAsync functionality
+## 2026-05-22
+**Program Name Resolution & Late-Binding Refactor**
+
+- Added null-guard around Assembly.GetEntryAssembly() when deriving program name, preventing crash when entry assembly is unavailable (e.g. certain host/test environments)
+- Refactored late-binding logic from ref-cell pattern to a mutable let binding, simplifying state handling across 14 added and 8 removed lines in the parser
diff --git a/DIARY-MONTHLY.md b/DIARY-MONTHLY.md
new file mode 100644
index 00000000..a33d9b4c
--- /dev/null
+++ b/DIARY-MONTHLY.md
@@ -0,0 +1,53 @@
+# Monthly Diary
+
+## 2026-06
+**Docs Cleanup & Repo Hygiene**
+
+- Removed stale ParseConfig reference from release notes to keep docs accurate.
+- Merged latest master changes into async config reader branch to stay in sync.
+- Added ignore patterns for bash, .NET, and IDE artifacts to keep repo clean.
+**AOT Compatibility & Async Config Reader**
+
+- Trimmed .gitignore down to repo-specific entries, removing 341 lines of generic noise.
+- Marked AOT-incompatible APIs with RequiresDynamicCode and RequiresUnreferencedCode attributes (#314) for native-AOT compatibility.
+- Scaffolded a source generator project with an [ArguGenerate] marker attribute (#318) to support compile-time code generation.
+- Finalized ConfigurationReader.FromEnvironmentVariables(prefix) and FromMicrosoftConfiguration (#308), iterating through several revisions to land the API.
+- Merged upstream master and Ruben Bartelink's tidy-up and combined-commit changes into the async config reader branch, keeping the PR in sync.
+**Async Config Reader & Benchmarks**
+
+- Redesigned IAsyncConfigurationReader around batched reads for efficiency
+- Added Azure Key Vault sample, tutorial section, and failure-mode docs for async config
+- Added BenchmarkDotNet harness for performance testing (#313)
+- Cleaned up repo by removing .recode tooling artifacts and adding ignore rule
+**Attribute Cleanup & Config Reader Merge**
+
+- Added unified SeparatorAttribute, marked 6 old separator attributes obsolete (#315)
+- Fixed usage rendering bug when param description empty (#173, #323)
+- Shipped Obsolete markers on deprecated PostProcess overloads (#296)
+- Swapped PrefixDictionary internals to char-keyed trie for perf gain (#316)
+- Merged master into async-config-reader branch, pulling in recent upstream changes
+- Added F# build state to gitignore
+**ParseConfig Overload & Usage Strings**
+
+- Added a record-based Parse(config) overload for ParseConfig, giving callers a way to invoke parsing with a configuration object instead of separate parameters (#307).
+- Merged latest changes from origin/master, resolving conflicts across 22 files via git-sync.
+- Introduced a UsageStrings record holding localisable labels for the usage/help message, laying groundwork for translating CLI output (#303).
+**Argu Parser Performance & Cleanup**
+
+- Reverted premature .gitignore expansion (removed 341 lines) to keep repo config minimal
+- Optimized ParseResults.getAllResults by replacing Seq pipeline with direct buffer access
+- Flattened UnionCaseArgInfo fields (Name, CommandLineNames, AppSettingsName) for reduced overhead across 7 files
+- Refactored Cli.parseCommandLinePartial into named helper functions for readability
+- Fixed usage/help output to narrow the Console.WindowWidth exception catch and floor the wrap width calculation
+**F# Nullable Refs & Test Suite Split**
+
+- Enabled F# nullable reference types on the Argu.fsproj to catch null-related bugs at compile time.
+- Refactored UnParsers to use mutable closure state instead of workarounds, and fixed broken RELEASE_NOTES URLs.
+- Split the monolithic Tests.fs file into three focused modules: Shared, Tests, and PrimitiveTests, improving test organization and maintainability.
+**Argu Introspection & Async Fixes**
+
+- Fixed ParseAsync so faulted GetValueAsync treated as missing, matching sync parser behaviour
+- Added Argu.Samples.Introspect sample demonstrating introspection API
+- Fixed mandatory-group validation to report all missing groups in single error message instead of one at a time
+- Refactored UnionCaseArgInfo to remove unneeded laziness, tidied parent backreferences
+- Cleaned up repo tooling: removed .recode artifacts and added ignore rules
diff --git a/DIARY-WEEKLY.md b/DIARY-WEEKLY.md
new file mode 100644
index 00000000..33056c2d
--- /dev/null
+++ b/DIARY-WEEKLY.md
@@ -0,0 +1,2 @@
+# Weekly Diary
+
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" @>