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" @>