From e3fb3e9fa768793b496585c00bff09fdac83ae21 Mon Sep 17 00:00:00 2001 From: dimension-zero Date: Sat, 23 May 2026 18:24:14 +0100 Subject: [PATCH 01/20] feat(Async): Add IAsyncConfigurationReader + ParseAsync overload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ConfigReaders.fs: - New IAsyncConfigurationReader interface mirroring IConfigurationReader with GetValueAsync : string -> Task. - ConfigurationReader.AsAsync(reader) wraps any sync reader as async via Task.FromResult — useful when callers want to pass existing readers through the async parse path. - ConfigurationReader.FromAsyncFunction(asyncFn, ?name) builds a reader from an F# Async. * ArgumentParser.fs new ParseAsync overload: fetches every top-level AppSettings key the schema references via the async reader, awaits each, then runs the regular sync Parse against a Dictionary-backed snapshot. One round-trip per declared key; below ParseAsync, the parser stays purely synchronous so existing tests and behaviour are unaffected. Hosts that previously had to block on an async config source can now use ParseAsync directly: do! parser.ParseAsync(configurationReader = remoteAsync) |> Async.AwaitTask --- src/Argu/ArgumentParser.fs | 44 ++++++++++++++++++++++++++++++++++++++ src/Argu/ConfigReaders.fs | 44 +++++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/Argu/ArgumentParser.fs b/src/Argu/ArgumentParser.fs index cfc3ce47..5f036fca 100644 --- a/src/Argu/ArgumentParser.fs +++ b/src/Argu/ArgumentParser.fs @@ -1,5 +1,8 @@ namespace Argu +open System.Collections.Generic +open System.Threading.Tasks + open FSharp.Quotations open Argu.UnionArgInfo @@ -182,6 +185,47 @@ and [] with ParserExn (errorCode, msg) -> errorHandler.Exit (msg, errorCode) + /// Parse both command line args and an async configuration reader. + /// The async reader is fully drained before the synchronous parse runs: + /// the parser pre-fetches a value for every AppSettings-mapped union + /// case in the schema, awaits each, then runs the regular sync parse + /// against a snapshot. This keeps the parser purely synchronous below + /// this method, at the cost of one round-trip per AppSettings key. + /// The command line input. Taken from System.Environment if not specified. + /// Async configuration reader. If not supplied, the synchronous default AppSettings reader is 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 ap.ParseAsync (?inputs : string [], ?configurationReader : IAsyncConfigurationReader, ?ignoreMissing, ?ignoreUnrecognized, ?raiseOnUsage) : Task> = + task { + let reader = + match configurationReader with + | Some r -> r + | None -> ConfigurationReader.AsAsync(ConfigurationReader.FromAppSettings()) + // Pre-fetch every AppSettings key the top-level schema references. + // The sync parser walks subcommand-local schemas separately, so only + // top-level keys are reached on this pass. + let prefetched = Dictionary() + for case in argInfo.Cases.Value do + match case.AppSettingsName.Value with + | Some key when not (prefetched.ContainsKey key) -> + let! v = reader.GetValueAsync key + prefetched[key] <- v + | _ -> () + let syncReader = + { new IConfigurationReader with + member _.Name = reader.Name + member _.GetValue key = + let ok, v = prefetched.TryGetValue key + if ok then v else null } + return ap.Parse( + ?inputs = inputs, + configurationReader = syncReader, + ?ignoreMissing = ignoreMissing, + ?ignoreUnrecognized = ignoreUnrecognized, + ?raiseOnUsage = raiseOnUsage) + } + /// /// Converts a sequence of template argument inputs into a ParseResults instance /// diff --git a/src/Argu/ConfigReaders.fs b/src/Argu/ConfigReaders.fs index bbc6643a..3ca0f2bb 100644 --- a/src/Argu/ConfigReaders.fs +++ b/src/Argu/ConfigReaders.fs @@ -5,6 +5,7 @@ open System.IO open System.Configuration open System.Collections.Generic open System.Reflection +open System.Threading.Tasks /// Abstract key/value configuration reader type IConfigurationReader = @@ -13,6 +14,19 @@ type IConfigurationReader = /// Gets value corresponding to supplied key abstract GetValue : key:string -> string +/// Asynchronous flavour of . Use when +/// the underlying source is genuinely async (remote config server, +/// secrets vault, etc.); a sync can +/// also be exposed through +/// when the parser already takes async readers. +type IAsyncConfigurationReader = + /// Configuration reader identifier + abstract Name : string + /// Asynchronously gets the value corresponding to the supplied key. + /// Implementations should return null for missing keys (same + /// contract as ). + abstract GetValueAsync : key:string -> Task + /// Configuration reader that never returns a value type NullConfigurationReader() = interface IConfigurationReader with @@ -108,4 +122,32 @@ type ConfigurationReader = sprintf "Assembly location for '%O' is null or empty." assembly.Location |> invalidArg assembly.FullName - AppSettingsConfigurationFileReader.Create(path + ".config") :> IConfigurationReader \ No newline at end of file + AppSettingsConfigurationFileReader.Create(path + ".config") :> IConfigurationReader + + /// + /// Wraps a synchronous as an + /// . GetValueAsync + /// returns a completed Task; useful for adapting existing readers + /// into an async pipeline. + /// + static member AsAsync(reader : IConfigurationReader) : IAsyncConfigurationReader = + { new IAsyncConfigurationReader with + member _.Name = reader.Name + member _.GetValueAsync(key : string) = Task.FromResult(reader.GetValue key) } + + /// + /// Create an from an + /// F# async function. The function returns None for missing keys. + /// + static member FromAsyncFunction(reader : string -> Async, ?name : string) : IAsyncConfigurationReader = + let name = defaultArg name "Async function configuration reader." + { new IAsyncConfigurationReader with + member _.Name = name + member _.GetValueAsync(key : string) = + task { + let! v = reader key + return + match v with + | None -> null + | Some v -> v + } } \ No newline at end of file From 65c81bde7665a21041a63d84cde747c64f35c321 Mon Sep 17 00:00:00 2001 From: dimension-zero Date: Sat, 23 May 2026 18:56:26 +0100 Subject: [PATCH 02/20] test(ParseAsync): Coverage for IAsyncConfigurationReader + ParseAsync 5 new tests: * ConfigurationReader.AsAsync wraps a sync reader; the wrapped Task resolves to the same value and preserves Name. * ParseAsync via AsAsync(sync reader) yields the same parse results as the original sync Parse against that reader. * ParseAsync pre-fetches each schema-declared AppSettings key exactly once (Interlocked-counted via a custom IAsyncConfigurationReader), guarding the implementation's contract. * ConfigurationReader.FromAsyncFunction adapts an F# Async to IAsyncConfigurationReader and parses round-trip. * ParseAsync forwards ignoreUnrecognized to the underlying sync Parse (CLI tokens not in the schema land in UnrecognizedCliParams). Net suite size on this branch: 112 -> 117. --- tests/Argu.Tests/Argu.Tests.fsproj | 1 + tests/Argu.Tests/ParseAsyncTests.fs | 87 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/Argu.Tests/ParseAsyncTests.fs diff --git a/tests/Argu.Tests/Argu.Tests.fsproj b/tests/Argu.Tests/Argu.Tests.fsproj index 0953bf58..7ebd12d8 100644 --- a/tests/Argu.Tests/Argu.Tests.fsproj +++ b/tests/Argu.Tests/Argu.Tests.fsproj @@ -4,6 +4,7 @@ + diff --git a/tests/Argu.Tests/ParseAsyncTests.fs b/tests/Argu.Tests/ParseAsyncTests.fs new file mode 100644 index 00000000..030e9f85 --- /dev/null +++ b/tests/Argu.Tests/ParseAsyncTests.fs @@ -0,0 +1,87 @@ +namespace Argu.Tests + +open System.Collections.Generic +open System.Threading +open System.Threading.Tasks +open Xunit +open Swensen.Unquote + +open Argu + +/// Tests for IAsyncConfigurationReader + ParseAsync (PR 29). +module ``Argu Tests ParseAsync`` = + + type Args = + | TagKey of string + | [] PortKey of int + interface IArgParserTemplate with member this.Usage = "x" + + [] + let ``ConfigurationReader.AsAsync wraps a sync reader`` () = + let dict = Dictionary() + dict["x"] <- "y" + let sync = ConfigurationReader.FromDictionary dict + let async = ConfigurationReader.AsAsync sync + let t = async.GetValueAsync("x") + t.Wait() + test <@ t.Result = "y" @> + test <@ async.Name = sync.Name @> + + [] + let ``ParseAsync via AsAsync(sync reader) matches sync Parse`` () = + let parser = ArgumentParser.Create(programName = "app") + let dict = Dictionary() + dict["tagkey"] <- "release" + dict["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 pre-fetches each schema key exactly once`` () = + let parser = ArgumentParser.Create(programName = "app") + let mutable lookups = 0 + let reader = + { new IAsyncConfigurationReader with + member _.Name = "counted-async-reader" + member _.GetValueAsync(key) = + Interlocked.Increment(&lookups) |> ignore + let v = if key = "tagkey" then "alpha" else null + Task.FromResult v } + let r = parser.ParseAsync(inputs = [||], configurationReader = reader).Result + test <@ r.GetResult(TagKey) = "alpha" @> + // Schema has two AppSettings keys (tagkey, port-key); each should be + // fetched once. + test <@ lookups = 2 @> + + [] + let ``FromAsyncFunction adapts an F# async function`` () = + let parser = ArgumentParser.Create(programName = "app") + let asyncReader = + ConfigurationReader.FromAsyncFunction( + fun key -> + async { + 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 ``ParseAsync passes ignoreUnrecognized through`` () = + let parser = ArgumentParser.Create(programName = "app") + 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" @> From 0dd505c1ba162ac4c2e04f561192d2d127688129 Mon Sep 17 00:00:00 2001 From: dimension-zero Date: Thu, 28 May 2026 21:25:52 +0100 Subject: [PATCH 03/20] chore: expand .gitignore with build and IDE artifacts risk: LOW (score: 0.0, no analysable symbols) --- .gitignore | 339 +++++++++++++++++- .recode/get-recap/recap.md | 66 ++++ .../8cd4a4a8-3f71-4106-992a-cf0d07354f54.json | 26 ++ .../8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json | 26 ++ 4 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 .recode/get-recap/recap.md create mode 100644 .recode/get-recap/sessions/8cd4a4a8-3f71-4106-992a-cf0d07354f54.json create mode 100644 .recode/get-recap/sessions/8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json diff --git a/.gitignore b/.gitignore index 95b96ef2..ddc3cda9 100644 --- a/.gitignore +++ b/.gitignore @@ -202,4 +202,341 @@ launchSettings.json /tools/ # Ionide -.ionide/ \ No newline at end of file +.ionide/ + +# bash +.fuse_hidden* +.directory +.Trash-* +.nfs* +nohup.out + +# csharp +*.rsuser +*.userosscache +*.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 + +# fsharp +*.rsuser +*.userosscache +*.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/.recode/get-recap/recap.md b/.recode/get-recap/recap.md new file mode 100644 index 00000000..b43bed47 --- /dev/null +++ b/.recode/get-recap/recap.md @@ -0,0 +1,66 @@ +# Project Recap +Generated: 2026-05-28 21:25:51 +Path: C:\Shared\source\repos\OSS\Argu + +--- +## Activity: 2026-05-28 21:25 + +### Git Commits (659 new) + +- `657524d` Merge branch 'master' into pr/29-async-config-reader (dimension-zero) +- `fddacf7` docs: Add XML summary on previously-undocumented public members (#304) (dimension-zero) +- `536f1f0` docs: Document order-sensitive equality on ParseResults (#295) (dimension-zero) +- `bee9966` test: Add coverage tests for error text, AppSettings, deep subcommands (#312) (dimension-zero) +- `4c57d94` docs: Document last-wins attribute pickup in TryGetAttribute (#291) (dimension-zero) +- `cdd7d77` refactor: Remove reimplemented stdlib helpers from Utils.fs (#290) (dimension-zero) +- `65c81bd` test(ParseAsync): Coverage for IAsyncConfigurationReader + ParseAsync (dimension-zero) +- `e3fb3e9` feat(Async): Add IAsyncConfigurationReader + ParseAsync overload (dimension-zero) +- `eefaa65` chore: Remove branch restriction on build.yml (#280) (Andres G. Aragoneses) +- `cd01076` chore(Deps): Update SDK to v10 (#279) (webwarrior-ws) +- `54517ea` Fix https://github.com/fsprojects/Argu/issues/277 (#278) (John Wostenberg) +- `8cf83db` chore(Deps): Drop Min FSharp.Core to 6.0.0 (#264) (Ruben Bartelink) +- `7ed0472` Bump xunit from 2.7.0 to 2.9.2 (#259) (dependabot[bot]) +- `6e1e278` Bump fake-cli from 6.1.1 to 6.1.3 (#260) (dependabot[bot]) +- `a9837e4` Bump DotNet.ReproducibleBuilds from 1.1.1 to 1.2.25 (#256) (dependabot[bot]) +- `a58623e` Bump Microsoft.NET.Test.Sdk from 17.11.0 to 17.11.1 (#255) (dependabot[bot]) +- `c2ca56e` Bump fake-cli from 5.23.1 to 6.1.1 (#254) (dependabot[bot]) +- `f269850` Bump Microsoft.NET.Test.Sdk from 17.10.0 to 17.11.0 (#253) (dependabot[bot]) +- `ff83fd3` Bump fsdocs-tool from 20.0.0 to 20.0.1 (#247) (dependabot[bot]) +- `c5b6ab3` Bump Microsoft.NET.Test.Sdk from 17.9.0 to 17.10.0 (#244) (dependabot[bot]) +- `ea16d85` fixes #242: add missing attribute targets (#243) (Daniel Lidström) +- `e3bc012` fix(Mandatory args error): Show missing cases (#236) (Florent Pellet) +- `5fb339f` fix(ArgumentParser): Correct programName when run via wrapper EXE (#233) (Ruben Bartelink) +- `40e0e26` chore(Tests): Add learning/doc test re AltCommandLine (#232) (Ruben Bartelink) +- `6b60199` fix(ParseResults.ProgramName): Make it public (#231) (Ruben Bartelink) +- `1dc67c9` feat(ParseResults): Add PostProcess aliases+overloads (#230) (Ruben Bartelink) +- `292b98f` feat(ArgumentParser): Expose ProgramName (#229) (Ruben Bartelink) +- `ddb3254` Bump xunit.runner.visualstudio from 2.5.6 to 2.5.7 (#227) (dependabot[bot]) +- `71625ae` Bump xunit from 2.6.6 to 2.7.0 (#228) (dependabot[bot]) +- `10ec279` Bump fsdocs-tool from 20.0.0-beta-002 to 20.0.0 (#226) (dependabot[bot]) +- `952075d` Bump fsdocs-tool from 20.0.0-beta-001 to 20.0.0-beta-002 (#225) (dependabot[bot]) +- `42b30f6` Bump Microsoft.NET.Test.Sdk from 17.8.0 to 17.9.0 (#224) (dependabot[bot]) +- `389e2bc` Bump fsdocs-tool from 20.0.0-alpha-019 to 20.0.0-beta-001 (#223) (dependabot[bot]) +- `4c87985` Bump fsdocs-tool from 20.0.0-alpha-018 to 20.0.0-alpha-019 (#222) (dependabot[bot]) +- `e0f5c5d` fix: mandatory parameters handling (#221) (Florent Pellet) +- `6ab9eef` refactor: tweak tests (#206) (Ruben Bartelink) +- `d30ef78` Bump xunit from 2.6.3 to 2.6.6 (#219) (dependabot[bot]) +- `cc5cc10` Bump fsdocs-tool from 20.0.0-alpha-016 to 20.0.0-alpha-018 (#218) (dependabot[bot]) +- `feca25a` Bump actions/deploy-pages from 3 to 4 (#212) (dependabot[bot]) +- `eaa5f44` Bump actions/upload-pages-artifact from 2 to 3 (#213) (dependabot[bot]) +- `5a95215` Bump xunit.runner.visualstudio from 2.5.5 to 2.5.6 (#215) (dependabot[bot]) +- `ddf5e8e` Bump Microsoft.NET.Test.Sdk from 17.3.2 to 17.8.0 (#210) (dependabot[bot]) +- `6bf96ac` Bump xunit.runner.visualstudio from 2.4.5 to 2.5.5 (#209) (dependabot[bot]) +- `227154e` Bump xunit from 2.4.2 to 2.6.3 (#208) (dependabot[bot]) +- `e439ac4` Bump actions/deploy-pages from 2 to 3 (#207) (dependabot[bot]) +- `dc7dcd3` chore: Remove paket (#205) (Florian Verdonck) +- `07c1bbe` Reorg README (#204) (Ruben Bartelink) +- `4f330d6` fix: Remove incorrect Dotnet.ReproducibleBuilds ref (#202) (Ruben Bartelink) +- `92b9315` feat: Add GetResult(expr, defThunk) (#187) (Ruben Bartelink) +- `b6893ea` Use DotNet.ReproducibleBuilds (#174) (Ruben Bartelink) +- ... and 609 more + +### Claude Code Sessions (2 new) + +- Session `8cd4a4a8`: 3 messages (1U/2A), 21:25-21:25 +- Session `8f94dfb6`: 0 messages (0U/0A), unknown time + diff --git a/.recode/get-recap/sessions/8cd4a4a8-3f71-4106-992a-cf0d07354f54.json b/.recode/get-recap/sessions/8cd4a4a8-3f71-4106-992a-cf0d07354f54.json new file mode 100644 index 00000000..d5573277 --- /dev/null +++ b/.recode/get-recap/sessions/8cd4a4a8-3f71-4106-992a-cf0d07354f54.json @@ -0,0 +1,26 @@ +{ + "SchemaVersion": 1, + "SessionId": "8cd4a4a8-3f71-4106-992a-cf0d07354f54", + "Machine": "CJC-2021-TECH-1", + "Account": "mathew.burkitt", + "Cwd": null, + "GitBranch": null, + "ParentSessionId": null, + "MessageCount": 3, + "UserMessages": 1, + "AssistantMessages": 2, + "FirstTimestamp": "2026-05-28T21:25:44.693+01:00", + "LastTimestamp": "2026-05-28T21:25:49.073+01:00", + "FirstUserPrompt": "generate a git commit message.\r\n\r\nbased on git-status and git-diff (in TOON format for token efficiency), you are a technical analyst helping to write good informative git commit messages.\r\nBe extremely concise. Output ONLY the commit message, nothing else.\r\n\r\nStatus (TOON format - code,meaning,count):\r\nstatus[2]{code,meaning,count}:\r\n!,Unknown,16\r\nM,Modified,1\r\n\r\n\r\nDiff (TOON format - file,\u002B,-):\r\nfiles[1]{file,\u002B,-}:\r\n.gitignore,338,1\r\n\n--- Diff Content ---\r\ndiff --git a/.gitignore b/.gitignore\r", + "LastPrompt": "generate a git commit message.\r \r based on git-status and git-diff (in TOON format for token efficiency), you are a technical analyst helping to write good informative git commit messages.\r Be extreme\u2026", + "AiTitle": "Expand gitignore with C# and F# rules", + "CustomTitle": null, + "Summary": null, + "AiSummary": null, + "AiSummaryGeneratedAt": null, + "Tokens": { + "Input": 18, + "Output": 716, + "CacheRead": 67204 + } +} \ No newline at end of file diff --git a/.recode/get-recap/sessions/8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json b/.recode/get-recap/sessions/8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json new file mode 100644 index 00000000..3a9f37e8 --- /dev/null +++ b/.recode/get-recap/sessions/8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json @@ -0,0 +1,26 @@ +{ + "SchemaVersion": 1, + "SessionId": "8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c", + "Machine": "CJC-2021-TECH-1", + "Account": "mathew.burkitt", + "Cwd": null, + "GitBranch": null, + "ParentSessionId": null, + "MessageCount": 0, + "UserMessages": 0, + "AssistantMessages": 0, + "FirstTimestamp": null, + "LastTimestamp": null, + "FirstUserPrompt": null, + "LastPrompt": null, + "AiTitle": null, + "CustomTitle": null, + "Summary": null, + "AiSummary": null, + "AiSummaryGeneratedAt": null, + "Tokens": { + "Input": 0, + "Output": 0, + "CacheRead": 0 + } +} \ No newline at end of file From a79626694e8f6b6d941b140d0c13661a0e5a3a26 Mon Sep 17 00:00:00 2001 From: dimension-zero Date: Sat, 23 May 2026 18:24:14 +0100 Subject: [PATCH 04/20] feat(Async): Add IAsyncConfigurationReader + ParseAsync overload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ConfigReaders.fs: - New IAsyncConfigurationReader interface mirroring IConfigurationReader with GetValueAsync : string -> Task. - ConfigurationReader.AsAsync(reader) wraps any sync reader as async via Task.FromResult — useful when callers want to pass existing readers through the async parse path. - ConfigurationReader.FromAsyncFunction(asyncFn, ?name) builds a reader from an F# Async. * ArgumentParser.fs new ParseAsync overload: fetches every top-level AppSettings key the schema references via the async reader, awaits each, then runs the regular sync Parse against a Dictionary-backed snapshot. One round-trip per declared key; below ParseAsync, the parser stays purely synchronous so existing tests and behaviour are unaffected. Hosts that previously had to block on an async config source can now use ParseAsync directly: do! parser.ParseAsync(configurationReader = remoteAsync) |> Async.AwaitTask # Conflicts: # src/Argu/ConfigReaders.fs --- src/Argu/ArgumentParser.fs | 44 ++++++++++++++++++++++++++++++++++++++ src/Argu/ConfigReaders.fs | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/src/Argu/ArgumentParser.fs b/src/Argu/ArgumentParser.fs index 0286e649..6da1c84a 100644 --- a/src/Argu/ArgumentParser.fs +++ b/src/Argu/ArgumentParser.fs @@ -1,5 +1,8 @@ namespace Argu +open System.Collections.Generic +open System.Threading.Tasks + open FSharp.Quotations open Argu.UnionArgInfo @@ -182,6 +185,47 @@ and [] with ParserExn (errorCode, msg) -> errorHandler.Exit (msg, errorCode) + /// Parse both command line args and an async configuration reader. + /// The async reader is fully drained before the synchronous parse runs: + /// the parser pre-fetches a value for every AppSettings-mapped union + /// case in the schema, awaits each, then runs the regular sync parse + /// against a snapshot. This keeps the parser purely synchronous below + /// this method, at the cost of one round-trip per AppSettings key. + /// The command line input. Taken from System.Environment if not specified. + /// Async configuration reader. If not supplied, the synchronous default AppSettings reader is 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 ap.ParseAsync (?inputs : string [], ?configurationReader : IAsyncConfigurationReader, ?ignoreMissing, ?ignoreUnrecognized, ?raiseOnUsage) : Task> = + task { + let reader = + match configurationReader with + | Some r -> r + | None -> ConfigurationReader.AsAsync(ConfigurationReader.FromAppSettings()) + // Pre-fetch every AppSettings key the top-level schema references. + // The sync parser walks subcommand-local schemas separately, so only + // top-level keys are reached on this pass. + let prefetched = Dictionary() + for case in argInfo.Cases.Value do + match case.AppSettingsName.Value with + | Some key when not (prefetched.ContainsKey key) -> + let! v = reader.GetValueAsync key + prefetched[key] <- v + | _ -> () + let syncReader = + { new IConfigurationReader with + member _.Name = reader.Name + member _.GetValue key = + let ok, v = prefetched.TryGetValue key + if ok then v else null } + return ap.Parse( + ?inputs = inputs, + configurationReader = syncReader, + ?ignoreMissing = ignoreMissing, + ?ignoreUnrecognized = ignoreUnrecognized, + ?raiseOnUsage = raiseOnUsage) + } + /// /// Converts a sequence of template argument inputs into a ParseResults instance /// diff --git a/src/Argu/ConfigReaders.fs b/src/Argu/ConfigReaders.fs index 687e8d8a..6addebc4 100644 --- a/src/Argu/ConfigReaders.fs +++ b/src/Argu/ConfigReaders.fs @@ -5,6 +5,7 @@ open System.IO open System.Configuration open System.Collections.Generic open System.Reflection +open System.Threading.Tasks /// Abstract key/value configuration reader type IConfigurationReader = @@ -13,6 +14,19 @@ type IConfigurationReader = /// Gets value corresponding to supplied key abstract GetValue : key:string -> string | null +/// Asynchronous flavour of . Use when +/// the underlying source is genuinely async (remote config server, +/// secrets vault, etc.); a sync can +/// also be exposed through +/// when the parser already takes async readers. +type IAsyncConfigurationReader = + /// Configuration reader identifier + abstract Name : string + /// Asynchronously gets the value corresponding to the supplied key. + /// Implementations should return null for missing keys (same + /// contract as ). + abstract GetValueAsync : key:string -> Task + /// Configuration reader that never returns a value type NullConfigurationReader() = interface IConfigurationReader with @@ -109,3 +123,31 @@ type ConfigurationReader = |> invalidArg assembly.FullName AppSettingsConfigurationFileReader.Create(path + ".config") :> IConfigurationReader + + /// + /// Wraps a synchronous as an + /// . GetValueAsync + /// returns a completed Task; useful for adapting existing readers + /// into an async pipeline. + /// + static member AsAsync(reader : IConfigurationReader) : IAsyncConfigurationReader = + { new IAsyncConfigurationReader with + member _.Name = reader.Name + member _.GetValueAsync(key : string) = Task.FromResult(reader.GetValue key) } + + /// + /// Create an from an + /// F# async function. The function returns None for missing keys. + /// + static member FromAsyncFunction(reader : string -> Async, ?name : string) : IAsyncConfigurationReader = + let name = defaultArg name "Async function configuration reader." + { new IAsyncConfigurationReader with + member _.Name = name + member _.GetValueAsync(key : string) = + task { + let! v = reader key + return + match v with + | None -> null + | Some v -> v + } } From c2fc2ad98a91b0169536cd191347e8b56ad89fb3 Mon Sep 17 00:00:00 2001 From: dimension-zero Date: Sat, 23 May 2026 18:56:26 +0100 Subject: [PATCH 05/20] test(ParseAsync): Coverage for IAsyncConfigurationReader + ParseAsync 5 new tests: * ConfigurationReader.AsAsync wraps a sync reader; the wrapped Task resolves to the same value and preserves Name. * ParseAsync via AsAsync(sync reader) yields the same parse results as the original sync Parse against that reader. * ParseAsync pre-fetches each schema-declared AppSettings key exactly once (Interlocked-counted via a custom IAsyncConfigurationReader), guarding the implementation's contract. * ConfigurationReader.FromAsyncFunction adapts an F# Async to IAsyncConfigurationReader and parses round-trip. * ParseAsync forwards ignoreUnrecognized to the underlying sync Parse (CLI tokens not in the schema land in UnrecognizedCliParams). Net suite size on this branch: 112 -> 117. --- tests/Argu.Tests/Argu.Tests.fsproj | 1 + tests/Argu.Tests/ParseAsyncTests.fs | 87 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/Argu.Tests/ParseAsyncTests.fs diff --git a/tests/Argu.Tests/Argu.Tests.fsproj b/tests/Argu.Tests/Argu.Tests.fsproj index 343246e8..ff0e5298 100644 --- a/tests/Argu.Tests/Argu.Tests.fsproj +++ b/tests/Argu.Tests/Argu.Tests.fsproj @@ -7,6 +7,7 @@ + diff --git a/tests/Argu.Tests/ParseAsyncTests.fs b/tests/Argu.Tests/ParseAsyncTests.fs new file mode 100644 index 00000000..030e9f85 --- /dev/null +++ b/tests/Argu.Tests/ParseAsyncTests.fs @@ -0,0 +1,87 @@ +namespace Argu.Tests + +open System.Collections.Generic +open System.Threading +open System.Threading.Tasks +open Xunit +open Swensen.Unquote + +open Argu + +/// Tests for IAsyncConfigurationReader + ParseAsync (PR 29). +module ``Argu Tests ParseAsync`` = + + type Args = + | TagKey of string + | [] PortKey of int + interface IArgParserTemplate with member this.Usage = "x" + + [] + let ``ConfigurationReader.AsAsync wraps a sync reader`` () = + let dict = Dictionary() + dict["x"] <- "y" + let sync = ConfigurationReader.FromDictionary dict + let async = ConfigurationReader.AsAsync sync + let t = async.GetValueAsync("x") + t.Wait() + test <@ t.Result = "y" @> + test <@ async.Name = sync.Name @> + + [] + let ``ParseAsync via AsAsync(sync reader) matches sync Parse`` () = + let parser = ArgumentParser.Create(programName = "app") + let dict = Dictionary() + dict["tagkey"] <- "release" + dict["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 pre-fetches each schema key exactly once`` () = + let parser = ArgumentParser.Create(programName = "app") + let mutable lookups = 0 + let reader = + { new IAsyncConfigurationReader with + member _.Name = "counted-async-reader" + member _.GetValueAsync(key) = + Interlocked.Increment(&lookups) |> ignore + let v = if key = "tagkey" then "alpha" else null + Task.FromResult v } + let r = parser.ParseAsync(inputs = [||], configurationReader = reader).Result + test <@ r.GetResult(TagKey) = "alpha" @> + // Schema has two AppSettings keys (tagkey, port-key); each should be + // fetched once. + test <@ lookups = 2 @> + + [] + let ``FromAsyncFunction adapts an F# async function`` () = + let parser = ArgumentParser.Create(programName = "app") + let asyncReader = + ConfigurationReader.FromAsyncFunction( + fun key -> + async { + 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 ``ParseAsync passes ignoreUnrecognized through`` () = + let parser = ArgumentParser.Create(programName = "app") + 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" @> From 3acc4256161191a2b4d418340ad959c96a70a6eb Mon Sep 17 00:00:00 2001 From: dimension-zero Date: Thu, 28 May 2026 21:25:52 +0100 Subject: [PATCH 06/20] chore: expand .gitignore with build and IDE artifacts risk: LOW (score: 0.0, no analysable symbols) --- .gitignore | 339 +++++++++++++++++- .recode/get-recap/recap.md | 66 ++++ .../8cd4a4a8-3f71-4106-992a-cf0d07354f54.json | 26 ++ .../8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json | 26 ++ 4 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 .recode/get-recap/recap.md create mode 100644 .recode/get-recap/sessions/8cd4a4a8-3f71-4106-992a-cf0d07354f54.json create mode 100644 .recode/get-recap/sessions/8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json diff --git a/.gitignore b/.gitignore index e7716108..05d88a1a 100644 --- a/.gitignore +++ b/.gitignore @@ -202,4 +202,341 @@ launchSettings.json /tools/ # Ionide -.ionide/ \ No newline at end of file +.ionide/ + +# bash +.fuse_hidden* +.directory +.Trash-* +.nfs* +nohup.out + +# csharp +*.rsuser +*.userosscache +*.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 + +# fsharp +*.rsuser +*.userosscache +*.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/.recode/get-recap/recap.md b/.recode/get-recap/recap.md new file mode 100644 index 00000000..b43bed47 --- /dev/null +++ b/.recode/get-recap/recap.md @@ -0,0 +1,66 @@ +# Project Recap +Generated: 2026-05-28 21:25:51 +Path: C:\Shared\source\repos\OSS\Argu + +--- +## Activity: 2026-05-28 21:25 + +### Git Commits (659 new) + +- `657524d` Merge branch 'master' into pr/29-async-config-reader (dimension-zero) +- `fddacf7` docs: Add XML summary on previously-undocumented public members (#304) (dimension-zero) +- `536f1f0` docs: Document order-sensitive equality on ParseResults (#295) (dimension-zero) +- `bee9966` test: Add coverage tests for error text, AppSettings, deep subcommands (#312) (dimension-zero) +- `4c57d94` docs: Document last-wins attribute pickup in TryGetAttribute (#291) (dimension-zero) +- `cdd7d77` refactor: Remove reimplemented stdlib helpers from Utils.fs (#290) (dimension-zero) +- `65c81bd` test(ParseAsync): Coverage for IAsyncConfigurationReader + ParseAsync (dimension-zero) +- `e3fb3e9` feat(Async): Add IAsyncConfigurationReader + ParseAsync overload (dimension-zero) +- `eefaa65` chore: Remove branch restriction on build.yml (#280) (Andres G. Aragoneses) +- `cd01076` chore(Deps): Update SDK to v10 (#279) (webwarrior-ws) +- `54517ea` Fix https://github.com/fsprojects/Argu/issues/277 (#278) (John Wostenberg) +- `8cf83db` chore(Deps): Drop Min FSharp.Core to 6.0.0 (#264) (Ruben Bartelink) +- `7ed0472` Bump xunit from 2.7.0 to 2.9.2 (#259) (dependabot[bot]) +- `6e1e278` Bump fake-cli from 6.1.1 to 6.1.3 (#260) (dependabot[bot]) +- `a9837e4` Bump DotNet.ReproducibleBuilds from 1.1.1 to 1.2.25 (#256) (dependabot[bot]) +- `a58623e` Bump Microsoft.NET.Test.Sdk from 17.11.0 to 17.11.1 (#255) (dependabot[bot]) +- `c2ca56e` Bump fake-cli from 5.23.1 to 6.1.1 (#254) (dependabot[bot]) +- `f269850` Bump Microsoft.NET.Test.Sdk from 17.10.0 to 17.11.0 (#253) (dependabot[bot]) +- `ff83fd3` Bump fsdocs-tool from 20.0.0 to 20.0.1 (#247) (dependabot[bot]) +- `c5b6ab3` Bump Microsoft.NET.Test.Sdk from 17.9.0 to 17.10.0 (#244) (dependabot[bot]) +- `ea16d85` fixes #242: add missing attribute targets (#243) (Daniel Lidström) +- `e3bc012` fix(Mandatory args error): Show missing cases (#236) (Florent Pellet) +- `5fb339f` fix(ArgumentParser): Correct programName when run via wrapper EXE (#233) (Ruben Bartelink) +- `40e0e26` chore(Tests): Add learning/doc test re AltCommandLine (#232) (Ruben Bartelink) +- `6b60199` fix(ParseResults.ProgramName): Make it public (#231) (Ruben Bartelink) +- `1dc67c9` feat(ParseResults): Add PostProcess aliases+overloads (#230) (Ruben Bartelink) +- `292b98f` feat(ArgumentParser): Expose ProgramName (#229) (Ruben Bartelink) +- `ddb3254` Bump xunit.runner.visualstudio from 2.5.6 to 2.5.7 (#227) (dependabot[bot]) +- `71625ae` Bump xunit from 2.6.6 to 2.7.0 (#228) (dependabot[bot]) +- `10ec279` Bump fsdocs-tool from 20.0.0-beta-002 to 20.0.0 (#226) (dependabot[bot]) +- `952075d` Bump fsdocs-tool from 20.0.0-beta-001 to 20.0.0-beta-002 (#225) (dependabot[bot]) +- `42b30f6` Bump Microsoft.NET.Test.Sdk from 17.8.0 to 17.9.0 (#224) (dependabot[bot]) +- `389e2bc` Bump fsdocs-tool from 20.0.0-alpha-019 to 20.0.0-beta-001 (#223) (dependabot[bot]) +- `4c87985` Bump fsdocs-tool from 20.0.0-alpha-018 to 20.0.0-alpha-019 (#222) (dependabot[bot]) +- `e0f5c5d` fix: mandatory parameters handling (#221) (Florent Pellet) +- `6ab9eef` refactor: tweak tests (#206) (Ruben Bartelink) +- `d30ef78` Bump xunit from 2.6.3 to 2.6.6 (#219) (dependabot[bot]) +- `cc5cc10` Bump fsdocs-tool from 20.0.0-alpha-016 to 20.0.0-alpha-018 (#218) (dependabot[bot]) +- `feca25a` Bump actions/deploy-pages from 3 to 4 (#212) (dependabot[bot]) +- `eaa5f44` Bump actions/upload-pages-artifact from 2 to 3 (#213) (dependabot[bot]) +- `5a95215` Bump xunit.runner.visualstudio from 2.5.5 to 2.5.6 (#215) (dependabot[bot]) +- `ddf5e8e` Bump Microsoft.NET.Test.Sdk from 17.3.2 to 17.8.0 (#210) (dependabot[bot]) +- `6bf96ac` Bump xunit.runner.visualstudio from 2.4.5 to 2.5.5 (#209) (dependabot[bot]) +- `227154e` Bump xunit from 2.4.2 to 2.6.3 (#208) (dependabot[bot]) +- `e439ac4` Bump actions/deploy-pages from 2 to 3 (#207) (dependabot[bot]) +- `dc7dcd3` chore: Remove paket (#205) (Florian Verdonck) +- `07c1bbe` Reorg README (#204) (Ruben Bartelink) +- `4f330d6` fix: Remove incorrect Dotnet.ReproducibleBuilds ref (#202) (Ruben Bartelink) +- `92b9315` feat: Add GetResult(expr, defThunk) (#187) (Ruben Bartelink) +- `b6893ea` Use DotNet.ReproducibleBuilds (#174) (Ruben Bartelink) +- ... and 609 more + +### Claude Code Sessions (2 new) + +- Session `8cd4a4a8`: 3 messages (1U/2A), 21:25-21:25 +- Session `8f94dfb6`: 0 messages (0U/0A), unknown time + diff --git a/.recode/get-recap/sessions/8cd4a4a8-3f71-4106-992a-cf0d07354f54.json b/.recode/get-recap/sessions/8cd4a4a8-3f71-4106-992a-cf0d07354f54.json new file mode 100644 index 00000000..d5573277 --- /dev/null +++ b/.recode/get-recap/sessions/8cd4a4a8-3f71-4106-992a-cf0d07354f54.json @@ -0,0 +1,26 @@ +{ + "SchemaVersion": 1, + "SessionId": "8cd4a4a8-3f71-4106-992a-cf0d07354f54", + "Machine": "CJC-2021-TECH-1", + "Account": "mathew.burkitt", + "Cwd": null, + "GitBranch": null, + "ParentSessionId": null, + "MessageCount": 3, + "UserMessages": 1, + "AssistantMessages": 2, + "FirstTimestamp": "2026-05-28T21:25:44.693+01:00", + "LastTimestamp": "2026-05-28T21:25:49.073+01:00", + "FirstUserPrompt": "generate a git commit message.\r\n\r\nbased on git-status and git-diff (in TOON format for token efficiency), you are a technical analyst helping to write good informative git commit messages.\r\nBe extremely concise. Output ONLY the commit message, nothing else.\r\n\r\nStatus (TOON format - code,meaning,count):\r\nstatus[2]{code,meaning,count}:\r\n!,Unknown,16\r\nM,Modified,1\r\n\r\n\r\nDiff (TOON format - file,\u002B,-):\r\nfiles[1]{file,\u002B,-}:\r\n.gitignore,338,1\r\n\n--- Diff Content ---\r\ndiff --git a/.gitignore b/.gitignore\r", + "LastPrompt": "generate a git commit message.\r \r based on git-status and git-diff (in TOON format for token efficiency), you are a technical analyst helping to write good informative git commit messages.\r Be extreme\u2026", + "AiTitle": "Expand gitignore with C# and F# rules", + "CustomTitle": null, + "Summary": null, + "AiSummary": null, + "AiSummaryGeneratedAt": null, + "Tokens": { + "Input": 18, + "Output": 716, + "CacheRead": 67204 + } +} \ No newline at end of file diff --git a/.recode/get-recap/sessions/8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json b/.recode/get-recap/sessions/8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json new file mode 100644 index 00000000..3a9f37e8 --- /dev/null +++ b/.recode/get-recap/sessions/8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json @@ -0,0 +1,26 @@ +{ + "SchemaVersion": 1, + "SessionId": "8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c", + "Machine": "CJC-2021-TECH-1", + "Account": "mathew.burkitt", + "Cwd": null, + "GitBranch": null, + "ParentSessionId": null, + "MessageCount": 0, + "UserMessages": 0, + "AssistantMessages": 0, + "FirstTimestamp": null, + "LastTimestamp": null, + "FirstUserPrompt": null, + "LastPrompt": null, + "AiTitle": null, + "CustomTitle": null, + "Summary": null, + "AiSummary": null, + "AiSummaryGeneratedAt": null, + "Tokens": { + "Input": 0, + "Output": 0, + "CacheRead": 0 + } +} \ No newline at end of file From c2fc8dbb26b92d438231e878134e5d949946f59d Mon Sep 17 00:00:00 2001 From: Mathew Burkitt Date: Mon, 1 Jun 2026 07:56:01 +0100 Subject: [PATCH 07/20] chore: remove .recode tooling artifacts and ignore the directory Generated session files containing local machine/account metadata are not project source. Add .recode/ to .gitignore so they are never accidentally committed again. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 + .recode/get-recap/recap.md | 66 ------------------- .../8cd4a4a8-3f71-4106-992a-cf0d07354f54.json | 26 -------- .../8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json | 26 -------- 4 files changed, 3 insertions(+), 118 deletions(-) delete mode 100644 .recode/get-recap/recap.md delete mode 100644 .recode/get-recap/sessions/8cd4a4a8-3f71-4106-992a-cf0d07354f54.json delete mode 100644 .recode/get-recap/sessions/8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json diff --git a/.gitignore b/.gitignore index 05d88a1a..725450c1 100644 --- a/.gitignore +++ b/.gitignore @@ -540,3 +540,6 @@ FodyWeavers.xsd *.swp *.swo +# Recode tooling +.recode/ + diff --git a/.recode/get-recap/recap.md b/.recode/get-recap/recap.md deleted file mode 100644 index b43bed47..00000000 --- a/.recode/get-recap/recap.md +++ /dev/null @@ -1,66 +0,0 @@ -# Project Recap -Generated: 2026-05-28 21:25:51 -Path: C:\Shared\source\repos\OSS\Argu - ---- -## Activity: 2026-05-28 21:25 - -### Git Commits (659 new) - -- `657524d` Merge branch 'master' into pr/29-async-config-reader (dimension-zero) -- `fddacf7` docs: Add XML summary on previously-undocumented public members (#304) (dimension-zero) -- `536f1f0` docs: Document order-sensitive equality on ParseResults (#295) (dimension-zero) -- `bee9966` test: Add coverage tests for error text, AppSettings, deep subcommands (#312) (dimension-zero) -- `4c57d94` docs: Document last-wins attribute pickup in TryGetAttribute (#291) (dimension-zero) -- `cdd7d77` refactor: Remove reimplemented stdlib helpers from Utils.fs (#290) (dimension-zero) -- `65c81bd` test(ParseAsync): Coverage for IAsyncConfigurationReader + ParseAsync (dimension-zero) -- `e3fb3e9` feat(Async): Add IAsyncConfigurationReader + ParseAsync overload (dimension-zero) -- `eefaa65` chore: Remove branch restriction on build.yml (#280) (Andres G. Aragoneses) -- `cd01076` chore(Deps): Update SDK to v10 (#279) (webwarrior-ws) -- `54517ea` Fix https://github.com/fsprojects/Argu/issues/277 (#278) (John Wostenberg) -- `8cf83db` chore(Deps): Drop Min FSharp.Core to 6.0.0 (#264) (Ruben Bartelink) -- `7ed0472` Bump xunit from 2.7.0 to 2.9.2 (#259) (dependabot[bot]) -- `6e1e278` Bump fake-cli from 6.1.1 to 6.1.3 (#260) (dependabot[bot]) -- `a9837e4` Bump DotNet.ReproducibleBuilds from 1.1.1 to 1.2.25 (#256) (dependabot[bot]) -- `a58623e` Bump Microsoft.NET.Test.Sdk from 17.11.0 to 17.11.1 (#255) (dependabot[bot]) -- `c2ca56e` Bump fake-cli from 5.23.1 to 6.1.1 (#254) (dependabot[bot]) -- `f269850` Bump Microsoft.NET.Test.Sdk from 17.10.0 to 17.11.0 (#253) (dependabot[bot]) -- `ff83fd3` Bump fsdocs-tool from 20.0.0 to 20.0.1 (#247) (dependabot[bot]) -- `c5b6ab3` Bump Microsoft.NET.Test.Sdk from 17.9.0 to 17.10.0 (#244) (dependabot[bot]) -- `ea16d85` fixes #242: add missing attribute targets (#243) (Daniel Lidström) -- `e3bc012` fix(Mandatory args error): Show missing cases (#236) (Florent Pellet) -- `5fb339f` fix(ArgumentParser): Correct programName when run via wrapper EXE (#233) (Ruben Bartelink) -- `40e0e26` chore(Tests): Add learning/doc test re AltCommandLine (#232) (Ruben Bartelink) -- `6b60199` fix(ParseResults.ProgramName): Make it public (#231) (Ruben Bartelink) -- `1dc67c9` feat(ParseResults): Add PostProcess aliases+overloads (#230) (Ruben Bartelink) -- `292b98f` feat(ArgumentParser): Expose ProgramName (#229) (Ruben Bartelink) -- `ddb3254` Bump xunit.runner.visualstudio from 2.5.6 to 2.5.7 (#227) (dependabot[bot]) -- `71625ae` Bump xunit from 2.6.6 to 2.7.0 (#228) (dependabot[bot]) -- `10ec279` Bump fsdocs-tool from 20.0.0-beta-002 to 20.0.0 (#226) (dependabot[bot]) -- `952075d` Bump fsdocs-tool from 20.0.0-beta-001 to 20.0.0-beta-002 (#225) (dependabot[bot]) -- `42b30f6` Bump Microsoft.NET.Test.Sdk from 17.8.0 to 17.9.0 (#224) (dependabot[bot]) -- `389e2bc` Bump fsdocs-tool from 20.0.0-alpha-019 to 20.0.0-beta-001 (#223) (dependabot[bot]) -- `4c87985` Bump fsdocs-tool from 20.0.0-alpha-018 to 20.0.0-alpha-019 (#222) (dependabot[bot]) -- `e0f5c5d` fix: mandatory parameters handling (#221) (Florent Pellet) -- `6ab9eef` refactor: tweak tests (#206) (Ruben Bartelink) -- `d30ef78` Bump xunit from 2.6.3 to 2.6.6 (#219) (dependabot[bot]) -- `cc5cc10` Bump fsdocs-tool from 20.0.0-alpha-016 to 20.0.0-alpha-018 (#218) (dependabot[bot]) -- `feca25a` Bump actions/deploy-pages from 3 to 4 (#212) (dependabot[bot]) -- `eaa5f44` Bump actions/upload-pages-artifact from 2 to 3 (#213) (dependabot[bot]) -- `5a95215` Bump xunit.runner.visualstudio from 2.5.5 to 2.5.6 (#215) (dependabot[bot]) -- `ddf5e8e` Bump Microsoft.NET.Test.Sdk from 17.3.2 to 17.8.0 (#210) (dependabot[bot]) -- `6bf96ac` Bump xunit.runner.visualstudio from 2.4.5 to 2.5.5 (#209) (dependabot[bot]) -- `227154e` Bump xunit from 2.4.2 to 2.6.3 (#208) (dependabot[bot]) -- `e439ac4` Bump actions/deploy-pages from 2 to 3 (#207) (dependabot[bot]) -- `dc7dcd3` chore: Remove paket (#205) (Florian Verdonck) -- `07c1bbe` Reorg README (#204) (Ruben Bartelink) -- `4f330d6` fix: Remove incorrect Dotnet.ReproducibleBuilds ref (#202) (Ruben Bartelink) -- `92b9315` feat: Add GetResult(expr, defThunk) (#187) (Ruben Bartelink) -- `b6893ea` Use DotNet.ReproducibleBuilds (#174) (Ruben Bartelink) -- ... and 609 more - -### Claude Code Sessions (2 new) - -- Session `8cd4a4a8`: 3 messages (1U/2A), 21:25-21:25 -- Session `8f94dfb6`: 0 messages (0U/0A), unknown time - diff --git a/.recode/get-recap/sessions/8cd4a4a8-3f71-4106-992a-cf0d07354f54.json b/.recode/get-recap/sessions/8cd4a4a8-3f71-4106-992a-cf0d07354f54.json deleted file mode 100644 index d5573277..00000000 --- a/.recode/get-recap/sessions/8cd4a4a8-3f71-4106-992a-cf0d07354f54.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "SchemaVersion": 1, - "SessionId": "8cd4a4a8-3f71-4106-992a-cf0d07354f54", - "Machine": "CJC-2021-TECH-1", - "Account": "mathew.burkitt", - "Cwd": null, - "GitBranch": null, - "ParentSessionId": null, - "MessageCount": 3, - "UserMessages": 1, - "AssistantMessages": 2, - "FirstTimestamp": "2026-05-28T21:25:44.693+01:00", - "LastTimestamp": "2026-05-28T21:25:49.073+01:00", - "FirstUserPrompt": "generate a git commit message.\r\n\r\nbased on git-status and git-diff (in TOON format for token efficiency), you are a technical analyst helping to write good informative git commit messages.\r\nBe extremely concise. Output ONLY the commit message, nothing else.\r\n\r\nStatus (TOON format - code,meaning,count):\r\nstatus[2]{code,meaning,count}:\r\n!,Unknown,16\r\nM,Modified,1\r\n\r\n\r\nDiff (TOON format - file,\u002B,-):\r\nfiles[1]{file,\u002B,-}:\r\n.gitignore,338,1\r\n\n--- Diff Content ---\r\ndiff --git a/.gitignore b/.gitignore\r", - "LastPrompt": "generate a git commit message.\r \r based on git-status and git-diff (in TOON format for token efficiency), you are a technical analyst helping to write good informative git commit messages.\r Be extreme\u2026", - "AiTitle": "Expand gitignore with C# and F# rules", - "CustomTitle": null, - "Summary": null, - "AiSummary": null, - "AiSummaryGeneratedAt": null, - "Tokens": { - "Input": 18, - "Output": 716, - "CacheRead": 67204 - } -} \ No newline at end of file diff --git a/.recode/get-recap/sessions/8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json b/.recode/get-recap/sessions/8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json deleted file mode 100644 index 3a9f37e8..00000000 --- a/.recode/get-recap/sessions/8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "SchemaVersion": 1, - "SessionId": "8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c", - "Machine": "CJC-2021-TECH-1", - "Account": "mathew.burkitt", - "Cwd": null, - "GitBranch": null, - "ParentSessionId": null, - "MessageCount": 0, - "UserMessages": 0, - "AssistantMessages": 0, - "FirstTimestamp": null, - "LastTimestamp": null, - "FirstUserPrompt": null, - "LastPrompt": null, - "AiTitle": null, - "CustomTitle": null, - "Summary": null, - "AiSummary": null, - "AiSummaryGeneratedAt": null, - "Tokens": { - "Input": 0, - "Output": 0, - "CacheRead": 0 - } -} \ No newline at end of file From df5b6d8c8ae7375d603215ebd4ebbd4b69638876 Mon Sep 17 00:00:00 2001 From: Mathew Burkitt Date: Mon, 1 Jun 2026 09:20:03 +0100 Subject: [PATCH 08/20] fix(ParseAsync): treat faulted GetValueAsync as missing, matching sync behaviour parseKeyValuePartial guards GetValue with `try ... with _ -> null`. ParseAsync had no equivalent guard so a throwing async reader would fail the entire call. Wrap GetValueAsync in the same pattern. Co-Authored-By: Claude Sonnet 4.6 --- src/Argu/ArgumentParser.fs | 2 +- tests/Argu.Tests/ParseAsyncTests.fs | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Argu/ArgumentParser.fs b/src/Argu/ArgumentParser.fs index 6da1c84a..406b55c6 100644 --- a/src/Argu/ArgumentParser.fs +++ b/src/Argu/ArgumentParser.fs @@ -209,7 +209,7 @@ and [] for case in argInfo.Cases.Value do match case.AppSettingsName.Value with | Some key when not (prefetched.ContainsKey key) -> - let! v = reader.GetValueAsync key + let! v = task { try return! reader.GetValueAsync key with _ -> return null } prefetched[key] <- v | _ -> () let syncReader = diff --git a/tests/Argu.Tests/ParseAsyncTests.fs b/tests/Argu.Tests/ParseAsyncTests.fs index 030e9f85..9456a549 100644 --- a/tests/Argu.Tests/ParseAsyncTests.fs +++ b/tests/Argu.Tests/ParseAsyncTests.fs @@ -74,6 +74,18 @@ module ``Argu Tests ParseAsync`` = let r = parser.ParseAsync(inputs = [||], configurationReader = asyncReader).Result test <@ r.GetResult(TagKey) = "from-async" @> + [] + let ``ParseAsync treats faulted GetValueAsync as missing`` () = + let parser = ArgumentParser.Create(programName = "app") + let reader = + { new IAsyncConfigurationReader with + member _.Name = "faulting-reader" + member _.GetValueAsync(_key) = + Task.FromException(System.Exception "vault unavailable") } + // Faulted reader must not throw; CLI args still parsed normally + let r = parser.ParseAsync(inputs = [| "--tagkey"; "v1" |], configurationReader = reader).Result + test <@ r.GetResult(TagKey) = "v1" @> + [] let ``ParseAsync passes ignoreUnrecognized through`` () = let parser = ArgumentParser.Create(programName = "app") From 4027d0ff67f92677fc32240c653bebb0f6500121 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 3 Jun 2026 11:53:51 +0100 Subject: [PATCH 09/20] Revert "chore: expand .gitignore with build and IDE artifacts" This reverts commit 3acc4256161191a2b4d418340ad959c96a70a6eb. --- .gitignore | 342 +---------------------------------------------------- 1 file changed, 1 insertion(+), 341 deletions(-) diff --git a/.gitignore b/.gitignore index 725450c1..e7716108 100644 --- a/.gitignore +++ b/.gitignore @@ -202,344 +202,4 @@ launchSettings.json /tools/ # Ionide -.ionide/ - -# bash -.fuse_hidden* -.directory -.Trash-* -.nfs* -nohup.out - -# csharp -*.rsuser -*.userosscache -*.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 - -# fsharp -*.rsuser -*.userosscache -*.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 - -# Recode tooling -.recode/ - +.ionide/ \ No newline at end of file From c838e15855e897d33e86ca697779e83c9eff0063 Mon Sep 17 00:00:00 2001 From: dimension-zero Date: Fri, 5 Jun 2026 22:06:19 +0100 Subject: [PATCH 10/20] chore: add fsharp build state to gitignore risk: LOW (score: 0.0, no analysable symbols) --- .gitignore | 4 ++ .recode/get-recap/recap.md | 53 +++++++++++++++++++ .../120cda44-afbb-4123-bec8-9dbd71204d49.json | 26 +++++++++ .../45799e25-8dee-408f-81a3-73e87dff4e8e.json | 26 +++++++++ .../67f36ac0-df54-4797-b066-67db23498994.json | 26 +++++++++ .../aa5eef0c-a9a6-4937-86e3-0a5c621a48cd.json | 26 +++++++++ 6 files changed, 161 insertions(+) create mode 100644 .recode/get-recap/sessions/120cda44-afbb-4123-bec8-9dbd71204d49.json create mode 100644 .recode/get-recap/sessions/45799e25-8dee-408f-81a3-73e87dff4e8e.json create mode 100644 .recode/get-recap/sessions/67f36ac0-df54-4797-b066-67db23498994.json create mode 100644 .recode/get-recap/sessions/aa5eef0c-a9a6-4937-86e3-0a5c621a48cd.json diff --git a/.gitignore b/.gitignore index 05d88a1a..a76682a9 100644 --- a/.gitignore +++ b/.gitignore @@ -540,3 +540,7 @@ FodyWeavers.xsd *.swp *.swo + +# fsharp +*.sln.docstates + diff --git a/.recode/get-recap/recap.md b/.recode/get-recap/recap.md index b43bed47..92434b22 100644 --- a/.recode/get-recap/recap.md +++ b/.recode/get-recap/recap.md @@ -64,3 +64,56 @@ Path: C:\Shared\source\repos\OSS\Argu - Session `8cd4a4a8`: 3 messages (1U/2A), 21:25-21:25 - Session `8f94dfb6`: 0 messages (0U/0A), unknown time + +--- + +## Activity: 2026-06-05 22:06 + +### Git Commits (38 new) + +- `35786cd` Merge remote-tracking branch 'origin/master' into pr/29-async-config-reader (Ruben Bartelink) +- `198f4d5` perf(PrefixDictionary): Back with a char-keyed trie (#316) (dimension-zero) +- `e2b4d47` chore: Ship Obsolete markers on PostProcess deprecated overloads (#296) (dimension-zero) +- `b267114` Fix usage rendering when param description empty #173 (#323) (Dominik Leko) +- `a6b4c43` feat(Attributes): Add unified SeparatorAttribute; obsolete 6 subsumed (#315) (dimension-zero) +- `65a6d5b` feat(UsageStrings): Add localisable labels record for usage message (#303) (dimension-zero) +- `66d67cd` Merge: resolved conflicts via git-sync (dimension-zero) +- `1c92258` feat(ParseConfig): Add record + Parse(config) overload (#307) (dimension-zero) +- `76a1d45` fix(usage): Narrow Console.WindowWidth exception catch; floor wrap width (#302) (dimension-zero) +- `2d59e8f` refactor(Cli): Split parseCommandLinePartial into named helpers (#309) (dimension-zero) +- `95201c3` perf(UnionCaseArgInfo): Flatten Name / CommandLineNames / AppSettingsName (#310) (dimension-zero) +- `65b7c24` perf(ParseResults.getAllResults): Drop Seq pipeline for direct buffer (#319) (dimension-zero) +- `4027d0f` Revert "chore: expand .gitignore with build and IDE artifacts" (Ruben Bartelink) +- `3acc425` chore: expand .gitignore with build and IDE artifacts (dimension-zero) +- `df5b6d8` fix(ParseAsync): treat faulted GetValueAsync as missing, matching sync behaviour (Mathew Burkitt) +- `c2fc8db` chore: remove .recode tooling artifacts and ignore the directory (Mathew Burkitt) +- `c2fc2ad` test(ParseAsync): Coverage for IAsyncConfigurationReader + ParseAsync (dimension-zero) +- `a796266` feat(Async): Add IAsyncConfigurationReader + ParseAsync overload (dimension-zero) +- `fe7bff5` test: Split Tests.fs into Shared / Tests / PrimitiveTests (#300) (dimension-zero) +- `26b360a` chore: Enable F# nullable reference types on Argu.fsproj (#305) (dimension-zero) +- `c1842e0` refactor(UnParsers): Use let mutable for closure state; fix RELEASE_NOTES URLs (#301) (dimension-zero) +- `d1e4e93` refactor(UnionCaseArgInfo): Remove egregious laziness (#299) (dimension-zero) +- `5bc5d26` samples: Add Argu.Samples.Introspect demonstrating the introspection API (#298) (dimension-zero) +- `30aa16c` fix: Report all missing-mandatory groups in a single error message (#297) (dimension-zero) +- `f344384` refactor: Tidy parent backrefs re #294 (Ruben Bartelink) +- `f0d9c48` refactor: Replace ref-cell late-binding with let mutable (dimension-zero) +- `72a6c20` fix(Build): Build workaround for SDK 10 + MTP v2 (Ruben Bartelink) +- `9eff9db` chore(Build): Migrate solution to .slnx format (Ruben Bartelink) +- `0c9152f` chore(Tests): Migrate to xunit v3 with Microsoft Testing Platform v2 (Ruben Bartelink) +- `aeef586` Migrate to xunit v3 with Microsoft Testing Platform v2 (Ruben Bartelink) +- `7ad4c60` feat(expr2Uci): Clarify exn message #293 (Ruben Bartelink) +- `f70d366` chore(Deps): Update DotNet.ReproducibleBuilds to 2.0.2 (Ruben Bartelink) +- `ee95f1f` chore: Scoped nowarn #289 (Ruben Bartelink) +- `29343f4` Correct changelog (Ruben Bartelink) +- `e9a4f5b` Merge branch 'pr/08-null-guard-entry-assembly' (Ruben Bartelink) +- `de5fb87` Top & tail (Ruben Bartelink) +- `05aef5c` Bump fsdocs-tool from 20.0.1 to 22.1.0 (#321) (dependabot[bot]) +- `5b64bd1` fix: Null-guard Assembly.GetEntryAssembly() when deriving program name (dimension-zero) + +### Claude Code Sessions (4 new) + +- Session `120cda44`: 3 messages (1U/2A), 15:52-15:52 +- Session `45799e25`: 3 messages (1U/2A), 15:50-15:51 +- Session `67f36ac0`: 2 messages (1U/1A), 15:52-15:52 +- Session `aa5eef0c`: 3 messages (1U/2A), 22:06-22:06 + diff --git a/.recode/get-recap/sessions/120cda44-afbb-4123-bec8-9dbd71204d49.json b/.recode/get-recap/sessions/120cda44-afbb-4123-bec8-9dbd71204d49.json new file mode 100644 index 00000000..fc6b1ba2 --- /dev/null +++ b/.recode/get-recap/sessions/120cda44-afbb-4123-bec8-9dbd71204d49.json @@ -0,0 +1,26 @@ +{ + "SchemaVersion": 1, + "SessionId": "120cda44-afbb-4123-bec8-9dbd71204d49", + "Machine": "CJC-2021-TECH-1", + "Account": "mathew.burkitt", + "Cwd": null, + "GitBranch": null, + "ParentSessionId": null, + "MessageCount": 3, + "UserMessages": 1, + "AssistantMessages": 2, + "FirstTimestamp": "2026-06-04T15:52:01.76+01:00", + "LastTimestamp": "2026-06-04T15:52:28.384+01:00", + "FirstUserPrompt": "-\nYou are resolving a git merge conflict. Analyze the three versions and produce a clean merged result.\r\n\r\nCRITICAL RULES:\r\n1. Output ONLY the resolved file content - no explanations, no markdown code fences, no comments\r\n2. Do NOT include any conflict markers (\u003C\u003C\u003C\u003C\u003C\u003C, ======, \u003E\u003E\u003E\u003E\u003E\u003E)\r\n3. Preserve the intent of both changes where possible\r\n4. If changes are truly incompatible, prefer the more complete or recent change\r\n5. Maintain correct syntax, formatting, and indentation\r\n6. Your response sho", + "LastPrompt": "- You are resolving a git merge conflict. Analyze the three versions and produce a clean merged result.\r \r CRITICAL RULES:\r 1. Output ONLY the resolved file content - no explanations, no markdown code\u2026", + "AiTitle": "Resolve git merge conflict in ConfigReaders.fs", + "CustomTitle": null, + "Summary": null, + "AiSummary": null, + "AiSummaryGeneratedAt": null, + "Tokens": { + "Input": 5006, + "Output": 5406, + "CacheRead": 40266 + } +} \ No newline at end of file diff --git a/.recode/get-recap/sessions/45799e25-8dee-408f-81a3-73e87dff4e8e.json b/.recode/get-recap/sessions/45799e25-8dee-408f-81a3-73e87dff4e8e.json new file mode 100644 index 00000000..98ec3323 --- /dev/null +++ b/.recode/get-recap/sessions/45799e25-8dee-408f-81a3-73e87dff4e8e.json @@ -0,0 +1,26 @@ +{ + "SchemaVersion": 1, + "SessionId": "45799e25-8dee-408f-81a3-73e87dff4e8e", + "Machine": "CJC-2021-TECH-1", + "Account": "mathew.burkitt", + "Cwd": null, + "GitBranch": null, + "ParentSessionId": null, + "MessageCount": 3, + "UserMessages": 1, + "AssistantMessages": 2, + "FirstTimestamp": "2026-06-04T15:50:53.368+01:00", + "LastTimestamp": "2026-06-04T15:51:59.903+01:00", + "FirstUserPrompt": "-\nYou are resolving a git merge conflict. Analyze the three versions and produce a clean merged result.\r\n\r\nCRITICAL RULES:\r\n1. Output ONLY the resolved file content - no explanations, no markdown code fences, no comments\r\n2. Do NOT include any conflict markers (\u003C\u003C\u003C\u003C\u003C\u003C, ======, \u003E\u003E\u003E\u003E\u003E\u003E)\r\n3. Preserve the intent of both changes where possible\r\n4. If changes are truly incompatible, prefer the more complete or recent change\r\n5. Maintain correct syntax, formatting, and indentation\r\n6. Your response sho", + "LastPrompt": "- You are resolving a git merge conflict. Analyze the three versions and produce a clean merged result.\r \r CRITICAL RULES:\r 1. Output ONLY the resolved file content - no explanations, no markdown code\u2026", + "AiTitle": "Resolve merge conflict in ArgumentParser async config", + "CustomTitle": null, + "Summary": null, + "AiSummary": null, + "AiSummaryGeneratedAt": null, + "Tokens": { + "Input": 5006, + "Output": 15728, + "CacheRead": 0 + } +} \ No newline at end of file diff --git a/.recode/get-recap/sessions/67f36ac0-df54-4797-b066-67db23498994.json b/.recode/get-recap/sessions/67f36ac0-df54-4797-b066-67db23498994.json new file mode 100644 index 00000000..6c8375ca --- /dev/null +++ b/.recode/get-recap/sessions/67f36ac0-df54-4797-b066-67db23498994.json @@ -0,0 +1,26 @@ +{ + "SchemaVersion": 1, + "SessionId": "67f36ac0-df54-4797-b066-67db23498994", + "Machine": "CJC-2021-TECH-1", + "Account": "mathew.burkitt", + "Cwd": null, + "GitBranch": null, + "ParentSessionId": null, + "MessageCount": 2, + "UserMessages": 1, + "AssistantMessages": 1, + "FirstTimestamp": "2026-06-04T15:52:30.004+01:00", + "LastTimestamp": "2026-06-04T15:52:47.136+01:00", + "FirstUserPrompt": "-\nYou are resolving a git merge conflict. Analyze the three versions and produce a clean merged result.\r\n\r\nCRITICAL RULES:\r\n1. Output ONLY the resolved file content - no explanations, no markdown code fences, no comments\r\n2. Do NOT include any conflict markers (\u003C\u003C\u003C\u003C\u003C\u003C, ======, \u003E\u003E\u003E\u003E\u003E\u003E)\r\n3. Preserve the intent of both changes where possible\r\n4. If changes are truly incompatible, prefer the more complete or recent change\r\n5. Maintain correct syntax, formatting, and indentation\r\n6. Your response sho", + "LastPrompt": "- You are resolving a git merge conflict. Analyze the three versions and produce a clean merged result.\r \r CRITICAL RULES:\r 1. Output ONLY the resolved file content - no explanations, no markdown code\u2026", + "AiTitle": "Resolve git merge conflict in ParseAsync tests", + "CustomTitle": null, + "Summary": null, + "AiSummary": null, + "AiSummaryGeneratedAt": null, + "Tokens": { + "Input": 2503, + "Output": 1629, + "CacheRead": 20133 + } +} \ No newline at end of file diff --git a/.recode/get-recap/sessions/aa5eef0c-a9a6-4937-86e3-0a5c621a48cd.json b/.recode/get-recap/sessions/aa5eef0c-a9a6-4937-86e3-0a5c621a48cd.json new file mode 100644 index 00000000..7dae8c1b --- /dev/null +++ b/.recode/get-recap/sessions/aa5eef0c-a9a6-4937-86e3-0a5c621a48cd.json @@ -0,0 +1,26 @@ +{ + "SchemaVersion": 1, + "SessionId": "aa5eef0c-a9a6-4937-86e3-0a5c621a48cd", + "Machine": "CJC-2021-TECH-1", + "Account": "mathew.burkitt", + "Cwd": null, + "GitBranch": null, + "ParentSessionId": null, + "MessageCount": 3, + "UserMessages": 1, + "AssistantMessages": 2, + "FirstTimestamp": "2026-06-05T22:06:12.246+01:00", + "LastTimestamp": "2026-06-05T22:06:15.997+01:00", + "FirstUserPrompt": "generate a git commit message.\r\n\r\nbased on git-status and git-diff (in TOON format for token efficiency), you are a technical analyst helping to write good informative git commit messages.\r\nBe extremely concise. Output ONLY the commit message, nothing else.\r\n\r\nStatus (TOON format - code,meaning,count):\r\nstatus[2]{code,meaning,count}:\r\n!,Unknown,16\r\nM,Modified,1\r\n\r\n\r\nDiff (TOON format - file,\u002B,-):\r\nfiles[1]{file,\u002B,-}:\r\n.gitignore,4,0\r\n\n--- Diff Content ---\r\ndiff --git a/.gitignore b/.gitignore\r\ni", + "LastPrompt": "generate a git commit message.\r \r based on git-status and git-diff (in TOON format for token efficiency), you are a technical analyst helping to write good informative git commit messages.\r Be extreme\u2026", + "AiTitle": "Generate git commit message", + "CustomTitle": null, + "Summary": null, + "AiSummary": null, + "AiSummaryGeneratedAt": null, + "Tokens": { + "Input": 18, + "Output": 590, + "CacheRead": 44862 + } +} \ No newline at end of file From eba2d5e159654d0054881137a781a9b793085134 Mon Sep 17 00:00:00 2001 From: dimension-zero <127850950+dimension-zero@users.noreply.github.com> Date: Sun, 7 Jun 2026 08:06:10 +0100 Subject: [PATCH 11/20] chore: Remove .recode tooling artifacts and ignore the directory Per Copilot's review on #317, the .recode/get-recap/*.json session files contain local machine and account identifiers from per-developer tooling and don't belong in source control. Removed the seven existing artifacts and added .recode/ to .gitignore so future captures stop leaking into the repo. --- .gitignore | 3 + .recode/get-recap/recap.md | 119 ------------------ .../120cda44-afbb-4123-bec8-9dbd71204d49.json | 26 ---- .../45799e25-8dee-408f-81a3-73e87dff4e8e.json | 26 ---- .../67f36ac0-df54-4797-b066-67db23498994.json | 26 ---- .../8cd4a4a8-3f71-4106-992a-cf0d07354f54.json | 26 ---- .../8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json | 26 ---- .../aa5eef0c-a9a6-4937-86e3-0a5c621a48cd.json | 26 ---- 8 files changed, 3 insertions(+), 275 deletions(-) delete mode 100644 .recode/get-recap/recap.md delete mode 100644 .recode/get-recap/sessions/120cda44-afbb-4123-bec8-9dbd71204d49.json delete mode 100644 .recode/get-recap/sessions/45799e25-8dee-408f-81a3-73e87dff4e8e.json delete mode 100644 .recode/get-recap/sessions/67f36ac0-df54-4797-b066-67db23498994.json delete mode 100644 .recode/get-recap/sessions/8cd4a4a8-3f71-4106-992a-cf0d07354f54.json delete mode 100644 .recode/get-recap/sessions/8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json delete mode 100644 .recode/get-recap/sessions/aa5eef0c-a9a6-4937-86e3-0a5c621a48cd.json diff --git a/.gitignore b/.gitignore index a76682a9..349a4c32 100644 --- a/.gitignore +++ b/.gitignore @@ -540,6 +540,9 @@ FodyWeavers.xsd *.swp *.swo +# Local Recode tooling artifacts (per-machine session data) +.recode/ + # fsharp *.sln.docstates diff --git a/.recode/get-recap/recap.md b/.recode/get-recap/recap.md deleted file mode 100644 index 92434b22..00000000 --- a/.recode/get-recap/recap.md +++ /dev/null @@ -1,119 +0,0 @@ -# Project Recap -Generated: 2026-05-28 21:25:51 -Path: C:\Shared\source\repos\OSS\Argu - ---- -## Activity: 2026-05-28 21:25 - -### Git Commits (659 new) - -- `657524d` Merge branch 'master' into pr/29-async-config-reader (dimension-zero) -- `fddacf7` docs: Add XML summary on previously-undocumented public members (#304) (dimension-zero) -- `536f1f0` docs: Document order-sensitive equality on ParseResults (#295) (dimension-zero) -- `bee9966` test: Add coverage tests for error text, AppSettings, deep subcommands (#312) (dimension-zero) -- `4c57d94` docs: Document last-wins attribute pickup in TryGetAttribute (#291) (dimension-zero) -- `cdd7d77` refactor: Remove reimplemented stdlib helpers from Utils.fs (#290) (dimension-zero) -- `65c81bd` test(ParseAsync): Coverage for IAsyncConfigurationReader + ParseAsync (dimension-zero) -- `e3fb3e9` feat(Async): Add IAsyncConfigurationReader + ParseAsync overload (dimension-zero) -- `eefaa65` chore: Remove branch restriction on build.yml (#280) (Andres G. Aragoneses) -- `cd01076` chore(Deps): Update SDK to v10 (#279) (webwarrior-ws) -- `54517ea` Fix https://github.com/fsprojects/Argu/issues/277 (#278) (John Wostenberg) -- `8cf83db` chore(Deps): Drop Min FSharp.Core to 6.0.0 (#264) (Ruben Bartelink) -- `7ed0472` Bump xunit from 2.7.0 to 2.9.2 (#259) (dependabot[bot]) -- `6e1e278` Bump fake-cli from 6.1.1 to 6.1.3 (#260) (dependabot[bot]) -- `a9837e4` Bump DotNet.ReproducibleBuilds from 1.1.1 to 1.2.25 (#256) (dependabot[bot]) -- `a58623e` Bump Microsoft.NET.Test.Sdk from 17.11.0 to 17.11.1 (#255) (dependabot[bot]) -- `c2ca56e` Bump fake-cli from 5.23.1 to 6.1.1 (#254) (dependabot[bot]) -- `f269850` Bump Microsoft.NET.Test.Sdk from 17.10.0 to 17.11.0 (#253) (dependabot[bot]) -- `ff83fd3` Bump fsdocs-tool from 20.0.0 to 20.0.1 (#247) (dependabot[bot]) -- `c5b6ab3` Bump Microsoft.NET.Test.Sdk from 17.9.0 to 17.10.0 (#244) (dependabot[bot]) -- `ea16d85` fixes #242: add missing attribute targets (#243) (Daniel Lidström) -- `e3bc012` fix(Mandatory args error): Show missing cases (#236) (Florent Pellet) -- `5fb339f` fix(ArgumentParser): Correct programName when run via wrapper EXE (#233) (Ruben Bartelink) -- `40e0e26` chore(Tests): Add learning/doc test re AltCommandLine (#232) (Ruben Bartelink) -- `6b60199` fix(ParseResults.ProgramName): Make it public (#231) (Ruben Bartelink) -- `1dc67c9` feat(ParseResults): Add PostProcess aliases+overloads (#230) (Ruben Bartelink) -- `292b98f` feat(ArgumentParser): Expose ProgramName (#229) (Ruben Bartelink) -- `ddb3254` Bump xunit.runner.visualstudio from 2.5.6 to 2.5.7 (#227) (dependabot[bot]) -- `71625ae` Bump xunit from 2.6.6 to 2.7.0 (#228) (dependabot[bot]) -- `10ec279` Bump fsdocs-tool from 20.0.0-beta-002 to 20.0.0 (#226) (dependabot[bot]) -- `952075d` Bump fsdocs-tool from 20.0.0-beta-001 to 20.0.0-beta-002 (#225) (dependabot[bot]) -- `42b30f6` Bump Microsoft.NET.Test.Sdk from 17.8.0 to 17.9.0 (#224) (dependabot[bot]) -- `389e2bc` Bump fsdocs-tool from 20.0.0-alpha-019 to 20.0.0-beta-001 (#223) (dependabot[bot]) -- `4c87985` Bump fsdocs-tool from 20.0.0-alpha-018 to 20.0.0-alpha-019 (#222) (dependabot[bot]) -- `e0f5c5d` fix: mandatory parameters handling (#221) (Florent Pellet) -- `6ab9eef` refactor: tweak tests (#206) (Ruben Bartelink) -- `d30ef78` Bump xunit from 2.6.3 to 2.6.6 (#219) (dependabot[bot]) -- `cc5cc10` Bump fsdocs-tool from 20.0.0-alpha-016 to 20.0.0-alpha-018 (#218) (dependabot[bot]) -- `feca25a` Bump actions/deploy-pages from 3 to 4 (#212) (dependabot[bot]) -- `eaa5f44` Bump actions/upload-pages-artifact from 2 to 3 (#213) (dependabot[bot]) -- `5a95215` Bump xunit.runner.visualstudio from 2.5.5 to 2.5.6 (#215) (dependabot[bot]) -- `ddf5e8e` Bump Microsoft.NET.Test.Sdk from 17.3.2 to 17.8.0 (#210) (dependabot[bot]) -- `6bf96ac` Bump xunit.runner.visualstudio from 2.4.5 to 2.5.5 (#209) (dependabot[bot]) -- `227154e` Bump xunit from 2.4.2 to 2.6.3 (#208) (dependabot[bot]) -- `e439ac4` Bump actions/deploy-pages from 2 to 3 (#207) (dependabot[bot]) -- `dc7dcd3` chore: Remove paket (#205) (Florian Verdonck) -- `07c1bbe` Reorg README (#204) (Ruben Bartelink) -- `4f330d6` fix: Remove incorrect Dotnet.ReproducibleBuilds ref (#202) (Ruben Bartelink) -- `92b9315` feat: Add GetResult(expr, defThunk) (#187) (Ruben Bartelink) -- `b6893ea` Use DotNet.ReproducibleBuilds (#174) (Ruben Bartelink) -- ... and 609 more - -### Claude Code Sessions (2 new) - -- Session `8cd4a4a8`: 3 messages (1U/2A), 21:25-21:25 -- Session `8f94dfb6`: 0 messages (0U/0A), unknown time - - ---- - -## Activity: 2026-06-05 22:06 - -### Git Commits (38 new) - -- `35786cd` Merge remote-tracking branch 'origin/master' into pr/29-async-config-reader (Ruben Bartelink) -- `198f4d5` perf(PrefixDictionary): Back with a char-keyed trie (#316) (dimension-zero) -- `e2b4d47` chore: Ship Obsolete markers on PostProcess deprecated overloads (#296) (dimension-zero) -- `b267114` Fix usage rendering when param description empty #173 (#323) (Dominik Leko) -- `a6b4c43` feat(Attributes): Add unified SeparatorAttribute; obsolete 6 subsumed (#315) (dimension-zero) -- `65a6d5b` feat(UsageStrings): Add localisable labels record for usage message (#303) (dimension-zero) -- `66d67cd` Merge: resolved conflicts via git-sync (dimension-zero) -- `1c92258` feat(ParseConfig): Add record + Parse(config) overload (#307) (dimension-zero) -- `76a1d45` fix(usage): Narrow Console.WindowWidth exception catch; floor wrap width (#302) (dimension-zero) -- `2d59e8f` refactor(Cli): Split parseCommandLinePartial into named helpers (#309) (dimension-zero) -- `95201c3` perf(UnionCaseArgInfo): Flatten Name / CommandLineNames / AppSettingsName (#310) (dimension-zero) -- `65b7c24` perf(ParseResults.getAllResults): Drop Seq pipeline for direct buffer (#319) (dimension-zero) -- `4027d0f` Revert "chore: expand .gitignore with build and IDE artifacts" (Ruben Bartelink) -- `3acc425` chore: expand .gitignore with build and IDE artifacts (dimension-zero) -- `df5b6d8` fix(ParseAsync): treat faulted GetValueAsync as missing, matching sync behaviour (Mathew Burkitt) -- `c2fc8db` chore: remove .recode tooling artifacts and ignore the directory (Mathew Burkitt) -- `c2fc2ad` test(ParseAsync): Coverage for IAsyncConfigurationReader + ParseAsync (dimension-zero) -- `a796266` feat(Async): Add IAsyncConfigurationReader + ParseAsync overload (dimension-zero) -- `fe7bff5` test: Split Tests.fs into Shared / Tests / PrimitiveTests (#300) (dimension-zero) -- `26b360a` chore: Enable F# nullable reference types on Argu.fsproj (#305) (dimension-zero) -- `c1842e0` refactor(UnParsers): Use let mutable for closure state; fix RELEASE_NOTES URLs (#301) (dimension-zero) -- `d1e4e93` refactor(UnionCaseArgInfo): Remove egregious laziness (#299) (dimension-zero) -- `5bc5d26` samples: Add Argu.Samples.Introspect demonstrating the introspection API (#298) (dimension-zero) -- `30aa16c` fix: Report all missing-mandatory groups in a single error message (#297) (dimension-zero) -- `f344384` refactor: Tidy parent backrefs re #294 (Ruben Bartelink) -- `f0d9c48` refactor: Replace ref-cell late-binding with let mutable (dimension-zero) -- `72a6c20` fix(Build): Build workaround for SDK 10 + MTP v2 (Ruben Bartelink) -- `9eff9db` chore(Build): Migrate solution to .slnx format (Ruben Bartelink) -- `0c9152f` chore(Tests): Migrate to xunit v3 with Microsoft Testing Platform v2 (Ruben Bartelink) -- `aeef586` Migrate to xunit v3 with Microsoft Testing Platform v2 (Ruben Bartelink) -- `7ad4c60` feat(expr2Uci): Clarify exn message #293 (Ruben Bartelink) -- `f70d366` chore(Deps): Update DotNet.ReproducibleBuilds to 2.0.2 (Ruben Bartelink) -- `ee95f1f` chore: Scoped nowarn #289 (Ruben Bartelink) -- `29343f4` Correct changelog (Ruben Bartelink) -- `e9a4f5b` Merge branch 'pr/08-null-guard-entry-assembly' (Ruben Bartelink) -- `de5fb87` Top & tail (Ruben Bartelink) -- `05aef5c` Bump fsdocs-tool from 20.0.1 to 22.1.0 (#321) (dependabot[bot]) -- `5b64bd1` fix: Null-guard Assembly.GetEntryAssembly() when deriving program name (dimension-zero) - -### Claude Code Sessions (4 new) - -- Session `120cda44`: 3 messages (1U/2A), 15:52-15:52 -- Session `45799e25`: 3 messages (1U/2A), 15:50-15:51 -- Session `67f36ac0`: 2 messages (1U/1A), 15:52-15:52 -- Session `aa5eef0c`: 3 messages (1U/2A), 22:06-22:06 - diff --git a/.recode/get-recap/sessions/120cda44-afbb-4123-bec8-9dbd71204d49.json b/.recode/get-recap/sessions/120cda44-afbb-4123-bec8-9dbd71204d49.json deleted file mode 100644 index fc6b1ba2..00000000 --- a/.recode/get-recap/sessions/120cda44-afbb-4123-bec8-9dbd71204d49.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "SchemaVersion": 1, - "SessionId": "120cda44-afbb-4123-bec8-9dbd71204d49", - "Machine": "CJC-2021-TECH-1", - "Account": "mathew.burkitt", - "Cwd": null, - "GitBranch": null, - "ParentSessionId": null, - "MessageCount": 3, - "UserMessages": 1, - "AssistantMessages": 2, - "FirstTimestamp": "2026-06-04T15:52:01.76+01:00", - "LastTimestamp": "2026-06-04T15:52:28.384+01:00", - "FirstUserPrompt": "-\nYou are resolving a git merge conflict. Analyze the three versions and produce a clean merged result.\r\n\r\nCRITICAL RULES:\r\n1. Output ONLY the resolved file content - no explanations, no markdown code fences, no comments\r\n2. Do NOT include any conflict markers (\u003C\u003C\u003C\u003C\u003C\u003C, ======, \u003E\u003E\u003E\u003E\u003E\u003E)\r\n3. Preserve the intent of both changes where possible\r\n4. If changes are truly incompatible, prefer the more complete or recent change\r\n5. Maintain correct syntax, formatting, and indentation\r\n6. Your response sho", - "LastPrompt": "- You are resolving a git merge conflict. Analyze the three versions and produce a clean merged result.\r \r CRITICAL RULES:\r 1. Output ONLY the resolved file content - no explanations, no markdown code\u2026", - "AiTitle": "Resolve git merge conflict in ConfigReaders.fs", - "CustomTitle": null, - "Summary": null, - "AiSummary": null, - "AiSummaryGeneratedAt": null, - "Tokens": { - "Input": 5006, - "Output": 5406, - "CacheRead": 40266 - } -} \ No newline at end of file diff --git a/.recode/get-recap/sessions/45799e25-8dee-408f-81a3-73e87dff4e8e.json b/.recode/get-recap/sessions/45799e25-8dee-408f-81a3-73e87dff4e8e.json deleted file mode 100644 index 98ec3323..00000000 --- a/.recode/get-recap/sessions/45799e25-8dee-408f-81a3-73e87dff4e8e.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "SchemaVersion": 1, - "SessionId": "45799e25-8dee-408f-81a3-73e87dff4e8e", - "Machine": "CJC-2021-TECH-1", - "Account": "mathew.burkitt", - "Cwd": null, - "GitBranch": null, - "ParentSessionId": null, - "MessageCount": 3, - "UserMessages": 1, - "AssistantMessages": 2, - "FirstTimestamp": "2026-06-04T15:50:53.368+01:00", - "LastTimestamp": "2026-06-04T15:51:59.903+01:00", - "FirstUserPrompt": "-\nYou are resolving a git merge conflict. Analyze the three versions and produce a clean merged result.\r\n\r\nCRITICAL RULES:\r\n1. Output ONLY the resolved file content - no explanations, no markdown code fences, no comments\r\n2. Do NOT include any conflict markers (\u003C\u003C\u003C\u003C\u003C\u003C, ======, \u003E\u003E\u003E\u003E\u003E\u003E)\r\n3. Preserve the intent of both changes where possible\r\n4. If changes are truly incompatible, prefer the more complete or recent change\r\n5. Maintain correct syntax, formatting, and indentation\r\n6. Your response sho", - "LastPrompt": "- You are resolving a git merge conflict. Analyze the three versions and produce a clean merged result.\r \r CRITICAL RULES:\r 1. Output ONLY the resolved file content - no explanations, no markdown code\u2026", - "AiTitle": "Resolve merge conflict in ArgumentParser async config", - "CustomTitle": null, - "Summary": null, - "AiSummary": null, - "AiSummaryGeneratedAt": null, - "Tokens": { - "Input": 5006, - "Output": 15728, - "CacheRead": 0 - } -} \ No newline at end of file diff --git a/.recode/get-recap/sessions/67f36ac0-df54-4797-b066-67db23498994.json b/.recode/get-recap/sessions/67f36ac0-df54-4797-b066-67db23498994.json deleted file mode 100644 index 6c8375ca..00000000 --- a/.recode/get-recap/sessions/67f36ac0-df54-4797-b066-67db23498994.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "SchemaVersion": 1, - "SessionId": "67f36ac0-df54-4797-b066-67db23498994", - "Machine": "CJC-2021-TECH-1", - "Account": "mathew.burkitt", - "Cwd": null, - "GitBranch": null, - "ParentSessionId": null, - "MessageCount": 2, - "UserMessages": 1, - "AssistantMessages": 1, - "FirstTimestamp": "2026-06-04T15:52:30.004+01:00", - "LastTimestamp": "2026-06-04T15:52:47.136+01:00", - "FirstUserPrompt": "-\nYou are resolving a git merge conflict. Analyze the three versions and produce a clean merged result.\r\n\r\nCRITICAL RULES:\r\n1. Output ONLY the resolved file content - no explanations, no markdown code fences, no comments\r\n2. Do NOT include any conflict markers (\u003C\u003C\u003C\u003C\u003C\u003C, ======, \u003E\u003E\u003E\u003E\u003E\u003E)\r\n3. Preserve the intent of both changes where possible\r\n4. If changes are truly incompatible, prefer the more complete or recent change\r\n5. Maintain correct syntax, formatting, and indentation\r\n6. Your response sho", - "LastPrompt": "- You are resolving a git merge conflict. Analyze the three versions and produce a clean merged result.\r \r CRITICAL RULES:\r 1. Output ONLY the resolved file content - no explanations, no markdown code\u2026", - "AiTitle": "Resolve git merge conflict in ParseAsync tests", - "CustomTitle": null, - "Summary": null, - "AiSummary": null, - "AiSummaryGeneratedAt": null, - "Tokens": { - "Input": 2503, - "Output": 1629, - "CacheRead": 20133 - } -} \ No newline at end of file diff --git a/.recode/get-recap/sessions/8cd4a4a8-3f71-4106-992a-cf0d07354f54.json b/.recode/get-recap/sessions/8cd4a4a8-3f71-4106-992a-cf0d07354f54.json deleted file mode 100644 index d5573277..00000000 --- a/.recode/get-recap/sessions/8cd4a4a8-3f71-4106-992a-cf0d07354f54.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "SchemaVersion": 1, - "SessionId": "8cd4a4a8-3f71-4106-992a-cf0d07354f54", - "Machine": "CJC-2021-TECH-1", - "Account": "mathew.burkitt", - "Cwd": null, - "GitBranch": null, - "ParentSessionId": null, - "MessageCount": 3, - "UserMessages": 1, - "AssistantMessages": 2, - "FirstTimestamp": "2026-05-28T21:25:44.693+01:00", - "LastTimestamp": "2026-05-28T21:25:49.073+01:00", - "FirstUserPrompt": "generate a git commit message.\r\n\r\nbased on git-status and git-diff (in TOON format for token efficiency), you are a technical analyst helping to write good informative git commit messages.\r\nBe extremely concise. Output ONLY the commit message, nothing else.\r\n\r\nStatus (TOON format - code,meaning,count):\r\nstatus[2]{code,meaning,count}:\r\n!,Unknown,16\r\nM,Modified,1\r\n\r\n\r\nDiff (TOON format - file,\u002B,-):\r\nfiles[1]{file,\u002B,-}:\r\n.gitignore,338,1\r\n\n--- Diff Content ---\r\ndiff --git a/.gitignore b/.gitignore\r", - "LastPrompt": "generate a git commit message.\r \r based on git-status and git-diff (in TOON format for token efficiency), you are a technical analyst helping to write good informative git commit messages.\r Be extreme\u2026", - "AiTitle": "Expand gitignore with C# and F# rules", - "CustomTitle": null, - "Summary": null, - "AiSummary": null, - "AiSummaryGeneratedAt": null, - "Tokens": { - "Input": 18, - "Output": 716, - "CacheRead": 67204 - } -} \ No newline at end of file diff --git a/.recode/get-recap/sessions/8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json b/.recode/get-recap/sessions/8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json deleted file mode 100644 index 3a9f37e8..00000000 --- a/.recode/get-recap/sessions/8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "SchemaVersion": 1, - "SessionId": "8f94dfb6-dfa9-45ed-8c51-ac91b61ab30c", - "Machine": "CJC-2021-TECH-1", - "Account": "mathew.burkitt", - "Cwd": null, - "GitBranch": null, - "ParentSessionId": null, - "MessageCount": 0, - "UserMessages": 0, - "AssistantMessages": 0, - "FirstTimestamp": null, - "LastTimestamp": null, - "FirstUserPrompt": null, - "LastPrompt": null, - "AiTitle": null, - "CustomTitle": null, - "Summary": null, - "AiSummary": null, - "AiSummaryGeneratedAt": null, - "Tokens": { - "Input": 0, - "Output": 0, - "CacheRead": 0 - } -} \ No newline at end of file diff --git a/.recode/get-recap/sessions/aa5eef0c-a9a6-4937-86e3-0a5c621a48cd.json b/.recode/get-recap/sessions/aa5eef0c-a9a6-4937-86e3-0a5c621a48cd.json deleted file mode 100644 index 7dae8c1b..00000000 --- a/.recode/get-recap/sessions/aa5eef0c-a9a6-4937-86e3-0a5c621a48cd.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "SchemaVersion": 1, - "SessionId": "aa5eef0c-a9a6-4937-86e3-0a5c621a48cd", - "Machine": "CJC-2021-TECH-1", - "Account": "mathew.burkitt", - "Cwd": null, - "GitBranch": null, - "ParentSessionId": null, - "MessageCount": 3, - "UserMessages": 1, - "AssistantMessages": 2, - "FirstTimestamp": "2026-06-05T22:06:12.246+01:00", - "LastTimestamp": "2026-06-05T22:06:15.997+01:00", - "FirstUserPrompt": "generate a git commit message.\r\n\r\nbased on git-status and git-diff (in TOON format for token efficiency), you are a technical analyst helping to write good informative git commit messages.\r\nBe extremely concise. Output ONLY the commit message, nothing else.\r\n\r\nStatus (TOON format - code,meaning,count):\r\nstatus[2]{code,meaning,count}:\r\n!,Unknown,16\r\nM,Modified,1\r\n\r\n\r\nDiff (TOON format - file,\u002B,-):\r\nfiles[1]{file,\u002B,-}:\r\n.gitignore,4,0\r\n\n--- Diff Content ---\r\ndiff --git a/.gitignore b/.gitignore\r\ni", - "LastPrompt": "generate a git commit message.\r \r based on git-status and git-diff (in TOON format for token efficiency), you are a technical analyst helping to write good informative git commit messages.\r Be extreme\u2026", - "AiTitle": "Generate git commit message", - "CustomTitle": null, - "Summary": null, - "AiSummary": null, - "AiSummaryGeneratedAt": null, - "Tokens": { - "Input": 18, - "Output": 590, - "CacheRead": 44862 - } -} \ No newline at end of file From 0e83042fe97255b4c3f44958f513ed870cfa8de7 Mon Sep 17 00:00:00 2001 From: dimension-zero <127850950+dimension-zero@users.noreply.github.com> Date: Sun, 7 Jun 2026 08:12:49 +0100 Subject: [PATCH 12/20] feat(Async): Redesign IAsyncConfigurationReader around batched reads Per @bartelink's review on #317: the per-key GetValueAsync interface forces N round-trips for an N-key schema even when the underlying source (key/value store, secrets vault, remote config server) supports batch retrieval natively. Collapsed the contract to a single batched call. * IAsyncConfigurationReader.GetValueAsync -> GetValuesAsync - Takes IReadOnlyCollection - Returns Task> - Missing keys are absent from the dictionary (matches the per-key null contract of IConfigurationReader.GetValue) * ConfigurationReader.AsAsync now satisfies the batched contract by looping per key against the wrapped sync reader. * ConfigurationReader.FromAsyncFunction bridges from per-key async sources (HTTP GET ?key=); doc string flags that batching natively collapses the N round-trips. * New ConfigurationReader.WithFallbackToNull: wraps any async reader so transport faults degrade to 'all keys missing' rather than failing the parse, with a configurable onFault hook (defaults to a single stderr line). Addresses @bartelink's 'best-effort when the store is unavailable' concern explicitly. * ArgumentParser.ParseAsync issues a single batched GetValuesAsync for every AppSettings-mapped top-level case in the schema; ParseAsync XML doc documents the failure modes (faulted Task = fatal startup error unless wrapped in WithFallbackToNull). Tests updated to the batched contract: - AsAsync wraps + missing-key behaviour - ParseAsync matches sync Parse against the same dictionary - ParseAsync issues exactly one batched call observing all schema keys - FromAsyncFunction adapts an F# per-key async function - Bare faulted reader propagates the exception out of ParseAsync - WithFallbackToNull downgrades the fault and CLI args still satisfy - ignoreUnrecognized passes through 150 tests pass. --- src/Argu/ArgumentParser.fs | 40 +++++++++---- src/Argu/ConfigReaders.fs | 93 ++++++++++++++++++++++------- tests/Argu.Tests/ParseAsyncTests.fs | 67 +++++++++++++++------ 3 files changed, 150 insertions(+), 50 deletions(-) diff --git a/src/Argu/ArgumentParser.fs b/src/Argu/ArgumentParser.fs index 13f39b40..072e03aa 100644 --- a/src/Argu/ArgumentParser.fs +++ b/src/Argu/ArgumentParser.fs @@ -229,12 +229,26 @@ and [] with ParserExn (errorCode, msg) -> errorHandler.Exit (msg, errorCode) - /// Parse both command line args and an async configuration reader. - /// The async reader is fully drained before the synchronous parse runs: - /// the parser pre-fetches a value for every AppSettings-mapped union - /// case in the schema, awaits each, then runs the regular sync parse - /// against a snapshot. This keeps the parser purely synchronous below - /// this method, at the cost of one round-trip per AppSettings key. + /// + /// Parse both command line args and 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 + /// for a + /// best-effort policy that treats source unavailability as + /// "all keys missing". + /// - Keys absent from the returned dictionary are treated as + /// missing values, matching the per-key null contract of + /// . + /// /// The command line input. Taken from System.Environment if not specified. /// Async configuration reader. If not supplied, the synchronous default AppSettings reader is used. /// Ignore errors caused by the Mandatory attribute. Defaults to false. @@ -246,16 +260,16 @@ and [] match configurationReader with | Some r -> r | None -> ConfigurationReader.AsAsync(ConfigurationReader.FromAppSettings()) - // Pre-fetch every AppSettings key the top-level schema references. - // The sync parser walks subcommand-local schemas separately, so only - // top-level keys are reached on this pass. - let prefetched = Dictionary() + // 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 = ResizeArray() + let seen = HashSet() for case in argInfo.Cases.Value do match case.AppSettingsName with - | Some key when not (prefetched.ContainsKey key) -> - let! v = task { try return! reader.GetValueAsync key with _ -> return null } - prefetched[key] <- v + | Some key when seen.Add key -> keys.Add key | _ -> () + let! prefetched = reader.GetValuesAsync(keys :> IReadOnlyCollection) let syncReader = { new IConfigurationReader with member _.Name = reader.Name diff --git a/src/Argu/ConfigReaders.fs b/src/Argu/ConfigReaders.fs index 1f2d99b0..101d3051 100644 --- a/src/Argu/ConfigReaders.fs +++ b/src/Argu/ConfigReaders.fs @@ -14,18 +14,34 @@ type IConfigurationReader = /// Gets value corresponding to supplied key abstract GetValue : key:string -> string | null -/// Asynchronous flavour of . Use when -/// the underlying source is genuinely async (remote config server, -/// secrets vault, etc.); a sync can -/// also be exposed through -/// when the parser already takes async readers. +/// +/// 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 gets the value corresponding to the supplied key. - /// Implementations should return null for missing keys (same - /// contract as ). - abstract GetValueAsync : key:string -> Task + /// + /// 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() = @@ -126,28 +142,65 @@ type ConfigurationReader = /// /// Wraps a synchronous as an - /// . GetValueAsync - /// returns a completed Task; useful for adapting existing readers - /// into an async pipeline. + /// . 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 _.GetValueAsync(key : string) = Task.FromResult(reader.GetValue key) } + 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 :> IReadOnlyDictionary) } /// - /// Create an from an - /// F# async function. The function returns None for missing keys. + /// 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 FromAsyncFunction(reader : string -> Async, ?name : string) : IAsyncConfigurationReader = let name = defaultArg name "Async function configuration reader." { new IAsyncConfigurationReader with member _.Name = name - member _.GetValueAsync(key : string) = + member _.GetValuesAsync(keys : IReadOnlyCollection) = task { - let! v = reader key - return + let dict = Dictionary(keys.Count) + for k in keys do + let! v = reader k match v with - | None -> null - | Some v -> v + | None -> () + | Some v -> dict[k] <- v + return dict :> IReadOnlyDictionary + } } + + /// + /// Wraps an so transport + /// errors (faulted ) are downgraded to "all keys + /// missing" rather than failing the parse. The optional + /// hook lets callers log to stderr or + /// telemetry; the default writes a single line to stderr. + /// Use when configuration is genuinely optional and the program + /// should still start with CLI defaults if the source is + /// unreachable. + /// + static member WithFallbackToNull(reader : IAsyncConfigurationReader, ?onFault : exn -> unit) : IAsyncConfigurationReader = + let onFault = defaultArg onFault (fun ex -> eprintfn "[%s] unavailable: %s" reader.Name ex.Message) + let empty = Dictionary() :> IReadOnlyDictionary + { new IAsyncConfigurationReader with + member _.Name = reader.Name + " (with null fallback)" + member _.GetValuesAsync(keys : IReadOnlyCollection) = + task { + try + return! reader.GetValuesAsync keys + with ex -> + onFault ex + return empty } } \ No newline at end of file diff --git a/tests/Argu.Tests/ParseAsyncTests.fs b/tests/Argu.Tests/ParseAsyncTests.fs index d54bc1f2..8100052d 100644 --- a/tests/Argu.Tests/ParseAsyncTests.fs +++ b/tests/Argu.Tests/ParseAsyncTests.fs @@ -16,15 +16,20 @@ module ``Argu Tests ParseAsync`` = | [] PortKey of int interface IArgParserTemplate with member this.Usage = "x" + let private toReadOnly (d : Dictionary) = + d :> IReadOnlyDictionary + [] let ``ConfigurationReader.AsAsync wraps a sync reader`` () = let dict = Dictionary() dict["x"] <- "y" let sync = ConfigurationReader.FromDictionary dict let async = ConfigurationReader.AsAsync sync - let t = async.GetValueAsync("x") + let keys : IReadOnlyCollection = [| "x"; "missing" |] :> _ + let t = async.GetValuesAsync(keys) t.Wait() - test <@ t.Result = "y" @> + test <@ t.Result["x"] = "y" @> + test <@ not (t.Result.ContainsKey "missing") @> test <@ async.Name = sync.Name @> [] @@ -44,21 +49,25 @@ module ``Argu Tests ParseAsync`` = test <@ syncResults.GetResult(PortKey) = asyncResults.GetResult(PortKey) @> [] - let ``ParseAsync pre-fetches each schema key exactly once`` () = + let ``ParseAsync issues exactly one batched call covering all schema keys`` () = let parser = ArgumentParser.Create(programName = "app") - let mutable lookups = 0 + let mutable calls = 0 + let mutable observedKeys : string [] = [||] let reader = { new IAsyncConfigurationReader with - member _.Name = "counted-async-reader" - member _.GetValueAsync(key) = - Interlocked.Increment(&lookups) |> ignore - let v = if key = "tagkey" then "alpha" else null - Task.FromResult v } + member _.Name = "counted-batched-reader" + member _.GetValuesAsync(keys) = + Interlocked.Increment(&calls) |> ignore + observedKeys <- keys |> Seq.toArray + let dict = Dictionary() + dict["tagkey"] <- "alpha" + Task.FromResult(toReadOnly dict) } let r = parser.ParseAsync(inputs = [||], configurationReader = reader).Result test <@ r.GetResult(TagKey) = "alpha" @> - // Schema has two AppSettings keys (tagkey, port-key); each should be - // fetched once. - test <@ lookups = 2 @> + // Single round-trip regardless of key count. + test <@ calls = 1 @> + // Both schema-derived AppSettings keys land in the one batch. + test <@ observedKeys |> Array.sort = [| "port-key"; "tagkey" |] @> [] let ``FromAsyncFunction adapts an F# async function`` () = @@ -75,16 +84,40 @@ module ``Argu Tests ParseAsync`` = test <@ r.GetResult(TagKey) = "from-async" @> [] - let ``ParseAsync treats faulted GetValueAsync as missing`` () = + let ``Bare faulted reader propagates exception out of ParseAsync`` () = let parser = ArgumentParser.Create(programName = "app") let reader = { new IAsyncConfigurationReader with member _.Name = "faulting-reader" - member _.GetValueAsync(_key) = - Task.FromException(System.Exception "vault unavailable") } - // Faulted reader must not throw; CLI args still parsed normally + member _.GetValuesAsync(_keys) = + Task.FromException>( + System.Exception "vault unavailable") } + // No fallback wrapper - the fault is fatal, matching the documented + // contract. + let agg = + Assert.Throws(fun () -> + parser.ParseAsync(inputs = [||], configurationReader = reader).Result |> ignore) + test <@ agg.InnerException.Message = "vault unavailable" @> + + [] + let ``WithFallbackToNull downgrades a faulted batch to all keys missing`` () = + let parser = ArgumentParser.Create(programName = "app") + 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 = + ConfigurationReader.WithFallbackToNull( + inner, + onFault = fun ex -> seenFault <- Some ex) + // Fault is swallowed; CLI args still satisfy the parse. let r = parser.ParseAsync(inputs = [| "--tagkey"; "v1" |], configurationReader = reader).Result test <@ r.GetResult(TagKey) = "v1" @> + test <@ seenFault.IsSome @> + test <@ seenFault.Value.Message = "vault unavailable" @> [] let ``ParseAsync passes ignoreUnrecognized through`` () = @@ -96,4 +129,4 @@ module ``Argu Tests ParseAsync`` = configurationReader = reader, ignoreUnrecognized = true, raiseOnUsage = false).Result - test <@ r.UnrecognizedCliParams |> List.contains "--bogus" @> \ No newline at end of file + test <@ r.UnrecognizedCliParams |> List.contains "--bogus" @> From c6eda31695211a358aaad31ed9d6252e260e88bf Mon Sep 17 00:00:00 2001 From: dimension-zero <127850950+dimension-zero@users.noreply.github.com> Date: Sun, 7 Jun 2026 08:17:26 +0100 Subject: [PATCH 13/20] docs(Async): Add Azure Key Vault sample, tutorial section, failure mode docs Addresses @bartelink's three remaining concerns on #317: * samples/Argu.Samples.AsyncConfig/ - end-to-end CLI demonstrating ParseAsync against a real source (Azure Key Vault via the Azure.Security.KeyVault.Secrets SDK). - Real reader uses DefaultAzureCredential and Task.WhenAll over the requested keys to collapse N per-secret calls into one logical round-trip; 404s on individual secrets are treated as missing. - --simulate true (default) runs against an in-process fake reader so the sample is runnable without Azure credentials; --simulate false hits the real vault using the --vault-url CLI arg. - Entry point explicitly demonstrates the three failure modes: ArguException at Create (programmer error), faulted Task from GetValuesAsync (deploy error - wrapped via WithFallbackToNull), and ArguParseException from the sync parse phase (user error). * docs/tutorial.fsx - new 'Async Configuration Sources' section covering: - When to reach for ParseAsync vs. M.E.C. / Argu.Extensions.Configuration vs. host-level resolution. - The batched contract (one round-trip when the source supports it). - The three-flavour failure model and WithFallbackToNull policy. * Argu.slnx - registers the new sample under the existing samples folder. * Directory.Packages.props - centrally pins Azure.Security.KeyVault.Secrets 4.7.0 and Azure.Identity 1.13.1 (sample-only; not referenced from the core package). --- Argu.slnx | 1 + Directory.Packages.props | 4 + docs/tutorial.fsx | 82 ++++++++ .../Argu.Samples.AsyncConfig.fsproj | 18 ++ samples/Argu.Samples.AsyncConfig/Program.fs | 186 ++++++++++++++++++ 5 files changed, 291 insertions(+) create mode 100644 samples/Argu.Samples.AsyncConfig/Argu.Samples.AsyncConfig.fsproj create mode 100644 samples/Argu.Samples.AsyncConfig/Program.fs diff --git a/Argu.slnx b/Argu.slnx index 8bad91fe..018617ea 100644 --- a/Argu.slnx +++ b/Argu.slnx @@ -40,6 +40,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index c35f8723..c2ff15ce 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,5 +13,9 @@ + + + + \ No newline at end of file diff --git a/docs/tutorial.fsx b/docs/tutorial.fsx index d81db7c2..61613bd6 100644 --- a/docs/tutorial.fsx +++ b/docs/tutorial.fsx @@ -450,6 +450,88 @@ 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. `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(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`. Right when the host already has an + async startup phase the CLI parse can hook into. + +`ParseAsync` is least redundant 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 runtime, should catch it. + 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.WithFallbackToNull` for a best-effort policy + that downgrades the fault to "all keys missing" and logs to stderr + (or a custom hook): + + [lang=fsharp] + let reader = + ConfigurationReader.WithFallbackToNull( + inner = myVaultReader, + onFault = fun ex -> log.Warn("vault unavailable: {0}", ex)) + 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 flavours 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..b723c0d3 --- /dev/null +++ b/samples/Argu.Samples.AsyncConfig/Program.fs @@ -0,0 +1,186 @@ +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 WithFallbackToNull 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 : IReadOnlyCollection) = + task { + let tasks = + keys + |> Seq.map (fun k -> + task { + try + let! resp = client.GetSecretAsync(k) + return Some (k, resp.Value.Value) + with :? RequestFailedException as ex when ex.Status = 404 -> + return None + }) + |> Seq.toArray + let! all = Task.WhenAll tasks + let dict = Dictionary(all.Length) + for kv in all do + match kv with + | Some (k, v) -> dict[k] <- v + | None -> () + return dict :> IReadOnlyDictionary + } } + + +// ----------------------------------------------------------------------------- +// 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 = + dict [ + "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. +// 2. Faulted Task from reader -> source unavailable; fatal unless wrapped +// with WithFallbackToNull. +// 3. ArguParseException at Parse -> user input invalid; print usage + exit. +// ----------------------------------------------------------------------------- + +[] +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 1 + + let useSimulated = cli.GetResult(Simulate, defaultValue = true) + + let baseReader = + 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 WithFallbackToNull so vault + // unavailability degrades to "all keys missing" plus a stderr line. + // Without this wrapper, the fault would propagate and abort startup, + // which is also a legitimate choice for hard-required secrets. + let reader = + ConfigurationReader.WithFallbackToNull( + baseReader, + onFault = fun ex -> + eprintfn "warning: %s unavailable; CLI defaults only (%s)" + baseReader.Name + ex.Message) + + let results = + try + parser.ParseAsync(argv, configurationReader = reader).Result + with :? AggregateException as agg when (agg.InnerException :? ArguParseException) -> + eprintfn "%s" agg.InnerException.Message + exit 1 + + printfn "Resolved configuration:" + printfn " source = %s" reader.Name + printfn " db-host = %s" (results.GetResult(Db_Host, defaultValue = "")) + printfn " port = %s" (results.TryGetResult(Port) |> Option.map string |> Option.defaultValue "") + printfn " feature-flag = %s" (results.GetResult(Feature_Flag, defaultValue = "")) + + 0 From cbfb71bfeb5f9e5c6a6fd34414c25cd0a7d85038 Mon Sep 17 00:00:00 2001 From: dimension-zero <127850950+dimension-zero@users.noreply.github.com> Date: Mon, 8 Jun 2026 08:28:30 +0100 Subject: [PATCH 14/20] chore: Trim .gitignore to repo-specific entries Addresses review feedback that the .gitignore had accumulated flotsam. The branch had appended ~340 lines: a generic VS/dotnet template duplicated verbatim under both "# csharp" and "# fsharp" headers, plus bash/OS-IDE blocks and a stray re-add of *.sln.docstates that contradicted the *.slnx.docstates entry already present (the repo is slnx-only now). Master's existing rules already cover bin/, obj/, *.nupkg, .vs/, /.idea, packages/ and the rest, so the dump was pure redundancy. Removed it, keeping only the two genuinely repo-specific changes: - *.sln.docstates -> *.slnx.docstates (slnx modernisation) - .recode/ (local Recode tooling artifacts) --- .gitignore | 341 ----------------------------------------------------- 1 file changed, 341 deletions(-) diff --git a/.gitignore b/.gitignore index 349a4c32..80646948 100644 --- a/.gitignore +++ b/.gitignore @@ -204,346 +204,5 @@ launchSettings.json # Ionide .ionide/ -# bash -.fuse_hidden* -.directory -.Trash-* -.nfs* -nohup.out - -# csharp -*.rsuser -*.userosscache -*.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 - -# fsharp -*.rsuser -*.userosscache -*.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 - # Local Recode tooling artifacts (per-machine session data) .recode/ - - -# fsharp -*.sln.docstates - From c901626fa59e6b5689e51731af12cc1b96805c60 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Mon, 8 Jun 2026 13:09:23 +0100 Subject: [PATCH 15/20] tidy --- src/Argu/ConfigReaders.fs | 55 +++---- tests/Argu.Tests/ParseAsyncTests.fs | 218 +++++++++++++--------------- 2 files changed, 126 insertions(+), 147 deletions(-) diff --git a/src/Argu/ConfigReaders.fs b/src/Argu/ConfigReaders.fs index 101d3051..3325be75 100644 --- a/src/Argu/ConfigReaders.fs +++ b/src/Argu/ConfigReaders.fs @@ -68,7 +68,7 @@ type EnvironmentVariableConfigurationReader() = targets |> Array.fold folder null /// Configuration reader dictionary proxy -type DictionaryConfigurationReader (keyValueDictionary : IDictionary, ?name : string) = +type DictionaryConfigurationReader (keyValueDictionary : IReadOnlyDictionary, ?name : string) = let name = defaultArg name "Dictionary configuration reader." interface IConfigurationReader with member _.Name = name @@ -116,7 +116,7 @@ type ConfigurationReader = static member NullReader = NullConfigurationReader() :> IConfigurationReader /// Create a configuration reader instance using an IDictionary instance - static member FromDictionary(keyValueDictionary : IDictionary, ?name : string) = + static member FromDictionary(keyValueDictionary : IReadOnlyDictionary, ?name : string) = DictionaryConfigurationReader(keyValueDictionary, ?name = name) :> IConfigurationReader /// Create a configuration reader instance using an F# function @@ -141,11 +141,9 @@ type ConfigurationReader = AppSettingsConfigurationFileReader.Create(path + ".config") :> IConfigurationReader /// - /// 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 . + /// 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 @@ -156,30 +154,26 @@ type ConfigurationReader = match reader.GetValue k with | null -> () | v -> dict[k] <- v - Task.FromResult(dict :> IReadOnlyDictionary) } + 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. + /// 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 FromAsyncFunction(reader : string -> Async, ?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 - let! v = reader k - match v with - | None -> () - | Some v -> dict[k] <- v - return dict :> IReadOnlyDictionary - } } + 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 transport @@ -196,11 +190,8 @@ type ConfigurationReader = let empty = Dictionary() :> IReadOnlyDictionary { new IAsyncConfigurationReader with member _.Name = reader.Name + " (with null fallback)" - member _.GetValuesAsync(keys : IReadOnlyCollection) = - task { - try - return! reader.GetValuesAsync keys - with ex -> - onFault ex - return empty - } } \ No newline at end of file + member _.GetValuesAsync(keys : IReadOnlyCollection) = task { + try return! reader.GetValuesAsync keys + with ex -> + onFault ex + return empty } } diff --git a/tests/Argu.Tests/ParseAsyncTests.fs b/tests/Argu.Tests/ParseAsyncTests.fs index 8100052d..d38616c8 100644 --- a/tests/Argu.Tests/ParseAsyncTests.fs +++ b/tests/Argu.Tests/ParseAsyncTests.fs @@ -1,132 +1,120 @@ -namespace Argu.Tests +module Argu.Tests.ParseAsync +open Swensen.Unquote open System.Collections.Generic open System.Threading open System.Threading.Tasks open Xunit -open Swensen.Unquote open Argu -/// Tests for IAsyncConfigurationReader + ParseAsync (PR 29). -module ``Argu Tests ParseAsync`` = - - type Args = - | TagKey of string - | [] PortKey of int - interface IArgParserTemplate with member this.Usage = "x" - - let private toReadOnly (d : Dictionary) = - d :> IReadOnlyDictionary +type Args = + | TagKey of string + | [] PortKey of int + interface IArgParserTemplate with member this.Usage = "x" - [] - let ``ConfigurationReader.AsAsync wraps a sync reader`` () = - let dict = Dictionary() - dict["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 ``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(programName = "app") - let dict = Dictionary() - dict["tagkey"] <- "release" - dict["port-key"] <- "9090" - let syncReader = ConfigurationReader.FromDictionary dict - let asyncReader = ConfigurationReader.AsAsync syncReader +[] +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 + 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) @> + 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(programName = "app") - 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 - let dict = Dictionary() - dict["tagkey"] <- "alpha" - Task.FromResult(toReadOnly dict) } - 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 <@ observedKeys |> Array.sort = [| "port-key"; "tagkey" |] @> +[] +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(programName = "app") - let asyncReader = - ConfigurationReader.FromAsyncFunction( - fun key -> - async { - 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 ``FromAsyncFunction adapts an F# async function`` () = + let parser = ArgumentParser.Create() + let asyncReader = + ConfigurationReader.FromAsyncFunction( + fun key -> + async { + 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`` () = - let parser = ArgumentParser.Create(programName = "app") - let reader = - { new IAsyncConfigurationReader with - member _.Name = "faulting-reader" - member _.GetValuesAsync(_keys) = - Task.FromException>( - System.Exception "vault unavailable") } - // No fallback wrapper - the fault is fatal, matching the documented - // contract. - let agg = - Assert.Throws(fun () -> - parser.ParseAsync(inputs = [||], configurationReader = reader).Result |> ignore) - test <@ agg.InnerException.Message = "vault unavailable" @> +[] +let ``Bare faulted reader propagates exception out of ParseAsync`` () = + let parser = ArgumentParser.Create() + let reader = + { new IAsyncConfigurationReader with + member _.Name = "faulting-reader" + member _.GetValuesAsync(_keys) = + Task.FromException>( + System.Exception "vault unavailable") } + // No fallback wrapper - the fault is fatal, matching the documented + // contract. + let agg = + Assert.Throws(fun () -> + parser.ParseAsync(inputs = [||], configurationReader = reader).Result |> ignore) + test <@ agg.InnerException.Message = "vault unavailable" @> - [] - let ``WithFallbackToNull downgrades a faulted batch to all keys missing`` () = - let parser = ArgumentParser.Create(programName = "app") - 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 = - ConfigurationReader.WithFallbackToNull( - inner, - onFault = fun ex -> seenFault <- Some ex) - // Fault is swallowed; CLI args still satisfy the parse. - let r = parser.ParseAsync(inputs = [| "--tagkey"; "v1" |], configurationReader = reader).Result - test <@ r.GetResult(TagKey) = "v1" @> - test <@ seenFault.IsSome @> - test <@ seenFault.Value.Message = "vault unavailable" @> +[] +let ``WithFallbackToNull downgrades a faulted batch to all keys missing`` () = + 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 = + ConfigurationReader.WithFallbackToNull( + inner, + onFault = fun ex -> seenFault <- Some ex) + // Fault is swallowed; CLI args still satisfy the parse. + let r = parser.ParseAsync(inputs = [| "--tagkey"; "v1" |], configurationReader = reader).Result + test <@ r.GetResult(TagKey) = "v1" @> + test <@ seenFault.IsSome @> + test <@ seenFault.Value.Message = "vault unavailable" @> - [] - let ``ParseAsync passes ignoreUnrecognized through`` () = - let parser = ArgumentParser.Create(programName = "app") - 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" @> +[] +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" @> From fb30fb01cf2a7c9a063daa6ff9ecd6158ffea059 Mon Sep 17 00:00:00 2001 From: dimension-zero <127850950+dimension-zero@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:49:20 +0100 Subject: [PATCH 16/20] feat(ConfigurationReader): FromEnvironmentVariables(prefix), FromMicrosoftConfiguration (#308) * feat(Config): Add env-var prefix factory + Argu.MSConfig companion * ConfigReaders.fs: New static member ConfigurationReader.FromEnvironmentVariables(prefix : string) that reads env vars with a fixed prefix prepended to the key. Composed from the existing EnvironmentVariableConfigurationReader via the existing FunctionConfigurationReader, so no new types in core. Existing FromEnvironmentVariables() is untouched. * New project src/Argu.MSConfig: thin adapter exposing any Microsoft.Extensions.Configuration.IConfiguration as an Argu IConfigurationReader. Lives in its own NuGet package so the core stays zero-dep on Microsoft.Extensions.*. * Directory.Packages.props: centrally-managed pin for Microsoft.Extensions.Configuration.Abstractions 8.0.0. * Argu.sln: register the new project under the existing F# tooling configuration. * test(EnvVarPrefix): Add coverage for FromEnvironmentVariables(prefix) 4 new tests: * FromEnvironmentVariables(prefix) prepends the prefix to the requested key, so reader.GetValue('HOST') reads env var MYAPP_HOST. * Missing keys come back as null (Argu's standard contract). * Round-trip through ArgumentParser.ParseConfiguration: a schema using CustomAppSettings keys is populated correctly when only the prefixed env vars are set. * No-arg FromEnvironmentVariables() is still functional (the new overload doesn't shadow the legacy one). Net suite size on this branch: 112 -> 116. * refactor: Rename Argu.MSConfig -> Argu.Extensions.Configuration Per review on #308, the M.E.C-style name matches the rest of the Microsoft.Extensions.* ecosystem and avoids the two-letter capitalised 'MSConfig' segment. * src/Argu.MSConfig/ -> src/Argu.Extensions.Configuration/ * Argu.MSConfig.fsproj -> Argu.Extensions.Configuration.fsproj (AssemblyName / RootNamespace / PackageId all default to the project file name) * Namespace Argu.MSConfig -> Argu.Extensions.Configuration in ConfigurationReader.fs * Solution registration updated in Argu.slnx * test(EnvVarPrefix): Scope env overrides via use _ = envOverride (IDisposable) Per review feedback: replace the withEnv wrapper-function helper with an IDisposable-returning envOverride, so each test reads `use _ = envOverride key value` and the prior value is restored in Dispose(). Removes the nested-lambda indentation in the multi-var round-trip test. --------- Co-authored-by: Ruben Bartelink # Conflicts: # src/Argu/ConfigReaders.fs --- Argu.slnx | 1 + Directory.Packages.props | 3 + RELEASE_NOTES.md | 4 +- .../Argu.Extensions.Configuration.fsproj | 19 +++++ .../ConfigurationReader.fs | 26 +++++++ src/Argu/ConfigReaders.fs | 75 +++++++++++-------- tests/Argu.Tests/Argu.Tests.fsproj | 2 + tests/Argu.Tests/CoverageTests.fs | 19 ++--- tests/Argu.Tests/EnvVarTests.fs | 50 +++++++++++++ 9 files changed, 154 insertions(+), 45 deletions(-) create mode 100644 src/Argu.Extensions.Configuration/Argu.Extensions.Configuration.fsproj create mode 100644 src/Argu.Extensions.Configuration/ConfigurationReader.fs create mode 100644 tests/Argu.Tests/EnvVarTests.fs diff --git a/Argu.slnx b/Argu.slnx index 4c0db3e8..998efbc9 100644 --- a/Argu.slnx +++ b/Argu.slnx @@ -40,6 +40,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 234ecdac..3d3a6475 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,9 @@ + + + diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 58eaa7ca..9e561015 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -3,13 +3,15 @@ * Fix Clarify exception in expr2Uci [#293](https://github.com/fsprojects/Argu/pull/293) [@dimension-zero](https://github.com/dimension-zero) * Fix Report all missing args in error message, not just first level [#297](https://github.com/fsprojects/Argu/pull/297) [@dimension-zero](https://github.com/dimension-zero) * Fix Limit min wordwrap column to 20 [#302](https://github.com/fsprojects/Argu/pull/302) [@dimension-zero](https://github.com/dimension-zero) -* Fix [misrendering of usage when help blank](https://github.com/fsprojects/Argu/issues/173) [#323](https://github.com/fsprojects/Argu/pull/323) [@DominikL1999](https://github.com/DominikL1999) +* Fix [misrendering of usage when parameter description blank](https://github.com/fsprojects/Argu/issues/173) [#323](https://github.com/fsprojects/Argu/pull/323) [@DominikL1999](https://github.com/DominikL1999) * Add `Separator("=", orSpace = true)` and `Separator "="` attribute syntax to replace `Obsolete`d `EqualsAssignment`, `ColonAssignment`, `CustomAssignment`, `EqualsAssignmentOrSpaced`, `ColonAssignmentOrSpaced` and `CustomAssignmentOrSpaced` [#315](https://github.com/fsprojects/Argu/pull/315) [@dimension-zero](https://github.com/dimension-zero) * Add `Argu.Samples.Introspect` sample [#298](https://github.com/fsprojects/Argu/pull/298) [@dimension-zero](https://github.com/dimension-zero) * Add `ArgumentParser.Parse(ParseConfig)` [#307](https://github.com/fsprojects/Argu/pull/307) [@dimension-zero](https://github.com/dimension-zero) * Add `ArgumentParser.PrintUsage(..., ?UsageStrings)` for localization support [#303](https://github.com/fsprojects/Argu/pull/303) [@dimension-zero](https://github.com/dimension-zero) * Add AOT annotations [#314](https://github.com/fsprojects/Argu/pull/314) [@dimension-zero](https://github.com/dimension-zero) * Add `SourceGenerator.ArguGenerate` marker [#318](https://github.com/fsprojects/Argu/pull/318) [@dimension-zero](https://github.com/dimension-zero) +* Add `ConfigurationReader.FromEnvironmentVariables(prefix : String)` [#308](https://github.com/fsprojects/Argu/pull/308) [@dimension-zero](https://github.com/dimension-zero) +* Add `ConfigurationReader.FromMicrosoftConfiguration(Microsoft.Extensions.Configuration.IConfiguration)` [#308](https://github.com/fsprojects/Argu/pull/308) [@dimension-zero](https://github.com/dimension-zero) * Obsolete `EqualsAssignmentAttribute`, `ColonAssignmentAttribute`, `CustomAssignmentAttribute`, `EqualsAssignmentOrSpacedAttribute`, `ColonAssignmentOrSpacedAttribute` and `CustomAssignmentOrSpacedAttribute` [#315](https://github.com/fsprojects/Argu/pull/315) [@dimension-zero](https://github.com/dimension-zero) * Obsolete `PostProcessResult`, `PostProcessResults`, `TryPostProcessResult` [#296](https://github.com/fsprojects/Argu/pull/296) [@dimension-zero](https://github.com/dimension-zero) diff --git a/src/Argu.Extensions.Configuration/Argu.Extensions.Configuration.fsproj b/src/Argu.Extensions.Configuration/Argu.Extensions.Configuration.fsproj new file mode 100644 index 00000000..dddb9de9 --- /dev/null +++ b/src/Argu.Extensions.Configuration/Argu.Extensions.Configuration.fsproj @@ -0,0 +1,19 @@ + + + netstandard2.0 + true + true + Microsoft.Extensions.Configuration adapter for Argu — exposes any IConfiguration source as an Argu IConfigurationReader. + F#, argument, commandline, parser, configuration + + + + + + + + + + + + diff --git a/src/Argu.Extensions.Configuration/ConfigurationReader.fs b/src/Argu.Extensions.Configuration/ConfigurationReader.fs new file mode 100644 index 00000000..9f845bdc --- /dev/null +++ b/src/Argu.Extensions.Configuration/ConfigurationReader.fs @@ -0,0 +1,26 @@ +namespace Argu.Extensions.Configuration + +open Microsoft.Extensions.Configuration + +open Argu + +/// +/// Argu backed by a +/// . +/// The reader returns null when a key is missing, matching the +/// contract of every other Argu configuration reader. +/// +type MicrosoftExtensionsConfigurationReader(configuration : IConfiguration, ?name : string) = + let name = defaultArg name "Microsoft.Extensions.Configuration reader" + interface IConfigurationReader with + member _.Name = name + member _.GetValue(key : string) = + // IConfiguration[key] returns null for missing keys, which matches Argu's expectation. + configuration[key] + +/// Factory helpers mirroring the shape of . +[] +type ConfigurationReader = + /// Create an Argu over an . + static member FromMicrosoftConfiguration(configuration : IConfiguration, ?name : string) : IConfigurationReader = + MicrosoftExtensionsConfigurationReader(configuration, ?name = name) :> _ diff --git a/src/Argu/ConfigReaders.fs b/src/Argu/ConfigReaders.fs index 3325be75..f95620b3 100644 --- a/src/Argu/ConfigReaders.fs +++ b/src/Argu/ConfigReaders.fs @@ -1,9 +1,9 @@ namespace Argu open System -open System.IO open System.Configuration open System.Collections.Generic +open System.IO open System.Reflection open System.Threading.Tasks @@ -12,7 +12,7 @@ type IConfigurationReader = /// Configuration reader identifier abstract Name : string /// Gets value corresponding to supplied key - abstract GetValue : key:string -> string | null + abstract GetValue : key : string -> string | null /// /// Asynchronous, batched flavour of . @@ -44,13 +44,13 @@ type IAsyncConfigurationReader = abstract GetValuesAsync : keys:IReadOnlyCollection -> Task> /// Configuration reader that never returns a value -type NullConfigurationReader() = +type NullConfigurationReader () = interface IConfigurationReader with - member x.Name = "Null Configuration Reader" - member x.GetValue _ = null + member _.Name = "Null Configuration Reader" + member _.GetValue _ = null /// Environment variable-based configuration reader -type EnvironmentVariableConfigurationReader() = +type EnvironmentVariableConfigurationReader () = // order of environment variable target lookup let targets = [| EnvironmentVariableTarget.Process @@ -58,21 +58,22 @@ type EnvironmentVariableConfigurationReader() = EnvironmentVariableTarget.Machine |] interface IConfigurationReader with - member x.Name = "Environment Variables Configuration Reader" - member x.GetValue(key:string) = + member _.Name = "Environment Variables Configuration Reader" + member _.GetValue(key : string) = + // NOTE this logic varies from a targetless Get in that it will pick up changes that a given process tree + // has yet to propagate. A fresh shell should include machine and user level vars in the process env from the off let folder curr (target : EnvironmentVariableTarget) = match curr with | null -> Environment.GetEnvironmentVariable(key, target) | value -> value - - targets |> Array.fold folder null + (null, targets) ||> Array.fold folder /// Configuration reader dictionary proxy type DictionaryConfigurationReader (keyValueDictionary : IReadOnlyDictionary, ?name : string) = let name = defaultArg name "Dictionary configuration reader." interface IConfigurationReader with member _.Name = name - member _.GetValue(key:string) = + member _.GetValue(key : string) = let ok,value = keyValueDictionary.TryGetValue key if ok then value else null @@ -81,23 +82,20 @@ type FunctionConfigurationReader (configFunc : string -> string option, ?name : let name = defaultArg name "Function configuration reader." interface IConfigurationReader with member _.Name = name - member _.GetValue(key:string) = - match configFunc key with - | None -> null - | Some v -> v + member _.GetValue(key : string) = configFunc key |> Option.toObj /// AppSettings XML configuration reader type AppSettingsConfigurationReader () = interface IConfigurationReader with member _.Name = "AppSettings configuration reader" - member _.GetValue(key:string) = ConfigurationManager.AppSettings[key] + member _.GetValue(key : string) = ConfigurationManager.AppSettings[key] /// AppSettings XML configuration reader type AppSettingsConfigurationFileReader private (xmlPath : string, kv : KeyValueConfigurationCollection) = member _.Path = xmlPath interface IConfigurationReader with - member _.Name = sprintf "App.config configuration reader: %s" xmlPath - member _.GetValue(key:string) = + member _.Name = $"App.config configuration reader: %s{xmlPath}" + member _.GetValue(key : string) = match kv[key] with | null -> null | entry -> entry.Value @@ -105,39 +103,52 @@ type AppSettingsConfigurationFileReader private (xmlPath : string, kv : KeyValue /// Create used supplied XML file path static member Create(path : string) = if not <| File.Exists path then raise <| FileNotFoundException(path) - let fileMap = ExeConfigurationFileMap() - fileMap.ExeConfigFilename <- path + let fileMap = ExeConfigurationFileMap(ExeConfigFilename = path) let config = ConfigurationManager.OpenMappedExeConfiguration(fileMap, ConfigurationUserLevel.None) AppSettingsConfigurationFileReader(path, config.AppSettings.Settings) /// Configuration reader implementations +[] type ConfigurationReader = + /// Create a configuration reader that always returns null - static member NullReader = NullConfigurationReader() :> IConfigurationReader + static member NullReader : IConfigurationReader = NullConfigurationReader() /// Create a configuration reader instance using an IDictionary instance static member FromDictionary(keyValueDictionary : IReadOnlyDictionary, ?name : string) = DictionaryConfigurationReader(keyValueDictionary, ?name = name) :> IConfigurationReader /// Create a configuration reader instance using an F# function - static member FromFunction(reader : string -> string option, ?name : string) = - FunctionConfigurationReader(reader, ?name = name) :> IConfigurationReader + static member FromFunction(reader : string -> string option, ?name : string) : IConfigurationReader = + FunctionConfigurationReader(reader, ?name = name) + + /// Create a configuration reader instance using environment variables. + /// Reads Process variables, falling back to latest User or latest Machine targets if not found. + static member FromEnvironmentVariables() : IConfigurationReader = EnvironmentVariableConfigurationReader() - /// Create a configuration reader instance using environment variables - static member FromEnvironmentVariables() = - EnvironmentVariableConfigurationReader() :> IConfigurationReader + /// + /// Create a configuration reader that reads environment variables with a fixed prefix prepended to the requested key.
+ /// Maps --foo-bar arguments onto MYAPP_FOO_BAR environment variables.
+ /// As per FromEnvironmentVariables(), reads Process variables, falling back to latest User or latest Machine targets if not found. + ///
+ /// String prepended to each requested key (e.g. "MYAPP_"). + static member FromEnvironmentVariables(prefix : string) : IConfigurationReader = + if isNull prefix then nullArg (nameof prefix) + let inner = EnvironmentVariableConfigurationReader() :> IConfigurationReader + let read (key : string) = inner.GetValue(prefix + key) |> Option.ofObj + FunctionConfigurationReader(read, name = $"Environment Variables (prefix=%s{prefix})") /// Create a configuration reader instance using the application's resident AppSettings configuration - static member FromAppSettings() = AppSettingsConfigurationReader() :> IConfigurationReader + static member FromAppSettings() : IConfigurationReader = AppSettingsConfigurationReader() + /// Create a configuration reader instance using a local xml App.Config file - static member FromAppSettingsFile(path : string) = AppSettingsConfigurationFileReader.Create(path) :> IConfigurationReader + static member FromAppSettingsFile(path : string) : IConfigurationReader = AppSettingsConfigurationFileReader.Create path + /// Create a configuration reader instance using the location of an assembly file - static member FromAppSettings(assembly : Assembly) = + static member FromAppSettings(assembly : Assembly) : IConfigurationReader = let path = assembly.Location if String.IsNullOrEmpty path then - sprintf "Assembly location for '%O' is null or empty." assembly.Location - |> invalidArg assembly.FullName - + invalidArg assembly.FullName $"Assembly location for '{assembly.Location}' is null or empty." AppSettingsConfigurationFileReader.Create(path + ".config") :> IConfigurationReader /// diff --git a/tests/Argu.Tests/Argu.Tests.fsproj b/tests/Argu.Tests/Argu.Tests.fsproj index 047c797c..9aeb5ad3 100644 --- a/tests/Argu.Tests/Argu.Tests.fsproj +++ b/tests/Argu.Tests/Argu.Tests.fsproj @@ -2,10 +2,12 @@ net10.0 Exe + enable + diff --git a/tests/Argu.Tests/CoverageTests.fs b/tests/Argu.Tests/CoverageTests.fs index 19dd6686..521b4087 100644 --- a/tests/Argu.Tests/CoverageTests.fs +++ b/tests/Argu.Tests/CoverageTests.fs @@ -2,9 +2,9 @@ /// These are behavior-locking tests, not exhaustive functional tests. module Argu.Tests.CoverageTests +open Swensen.Unquote open System open System.Collections.Generic -open Swensen.Unquote open Xunit open Argu @@ -153,20 +153,15 @@ let ``AppSettings: ignoreMissing=true skips mandatory check`` () = test <@ results.TryGetResult RequiredKey = None @> -// === Env-var (covers Argu's existing EnvironmentVariableConfigurationReader) === - type EnvArgs = | [] Val of string interface IArgParserTemplate with member this.Usage = "value" [] -let ``EnvironmentVariableConfigurationReader: reads process env`` () = - let key = "ARGU_TEST_VAL" - let prior = Environment.GetEnvironmentVariable key - try Environment.SetEnvironmentVariable(key, "from-env") - let parser = ArgumentParser.Create() - let reader = ConfigurationReader.FromEnvironmentVariables() - let results = parser.ParseConfiguration(reader, ignoreMissing = true) - test <@ results.GetResult Val = "from-env" @> - finally Environment.SetEnvironmentVariable(key, prior) +let ``ParseConfiguration fallback to EnvironmentVariableConfigurationReader picks up process env`` () = + use _ = EnvVarTests.envOverride "ARGU_TEST_VAL" "from-env" + let parser = ArgumentParser.Create() + let reader = ConfigurationReader.FromEnvironmentVariables() + let results = parser.ParseConfiguration reader + test <@ results.GetResult Val = "from-env" @> diff --git a/tests/Argu.Tests/EnvVarTests.fs b/tests/Argu.Tests/EnvVarTests.fs new file mode 100644 index 00000000..55764aa2 --- /dev/null +++ b/tests/Argu.Tests/EnvVarTests.fs @@ -0,0 +1,50 @@ +module Argu.Tests.EnvVarTests + +open Swensen.Unquote +open System +open Xunit + +open Argu + +type Args = + | [] HostName of string + | [] Port of int + interface IArgParserTemplate with member this.Usage = "x" + +/// Sets an environment variable and restores its prior value on Dispose, +/// so a test can scope the override with `use _ = envOverride key value`. +let envOverride (key : string) (value : string) : IDisposable = + let prior = Environment.GetEnvironmentVariable key + Environment.SetEnvironmentVariable(key, value) + { new IDisposable with member _.Dispose() = Environment.SetEnvironmentVariable(key, prior) } + +[] +let ``No-arg FromEnvironmentVariables works without prefix`` () = + use _ = envOverride "ARGU_TEST_PLAIN" "value" + let reader = ConfigurationReader.FromEnvironmentVariables() + test <@ reader.GetValue("ARGU_TEST_PLAIN") = "value" @> + +module Prefixed = + + [] + let ``FromEnvironmentVariables(prefix) prepends prefix to key`` () = + let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "MYAPP_") + use _ = envOverride "MYAPP_HOST" "example.com" + test <@ reader.GetValue("HOST") = "example.com" @> + + [] + let ``FromEnvironmentVariables(prefix) returns null for missing keys`` () = + let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "ARGU_TEST_MISSING_") + // Ensure no stray env var; SetEnvironmentVariable(null) clears it. + Environment.SetEnvironmentVariable("ARGU_TEST_MISSING_NOPE", null) + test <@ reader.GetValue("NOPE") = null @> + + [] + let ``FromEnvironmentVariables(prefix) parses round-trip through ArgumentParser`` () = + use _ = envOverride "DEMO_HOST" "host.local" + use _ = envOverride "DEMO_PORT" "443" + let parser = ArgumentParser.Create(programName = "demo") + let reader = ConfigurationReader.FromEnvironmentVariables(prefix = "DEMO_") + let results = parser.ParseConfiguration(reader, ignoreMissing = true) + test <@ results.GetResult(HostName) = "host.local" @> + test <@ results.GetResult(Port) = 443 @> From 989073795af0a425b436ddc5648e6771f67435d3 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Mon, 8 Jun 2026 14:42:42 +0100 Subject: [PATCH 17/20] # This is a combination of 2 commits. # This is the 1st commit message: Review # The commit message #2 will be skipped: # f --- Argu.slnx | 1 + RELEASE_NOTES.md | 2 + docs/tutorial.fsx | 28 +++--- samples/Argu.Samples.AsyncConfig/Program.fs | 103 +++++++++----------- src/Argu/ArgumentParser.fs | 83 +++++++--------- src/Argu/ConfigReaders.fs | 41 +++----- tests/Argu.Tests/CoverageTests.fs | 10 +- tests/Argu.Tests/ParseAsyncTests.fs | 42 ++++---- 8 files changed, 136 insertions(+), 174 deletions(-) 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/RELEASE_NOTES.md b/RELEASE_NOTES.md index 9e561015..208d0273 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -12,6 +12,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 with fallback to 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 61613bd6..1e74cb7a 100644 --- a/docs/tutorial.fsx +++ b/docs/tutorial.fsx @@ -454,8 +454,10 @@ which would yield the following: 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. `ParseAsync` -takes an `IAsyncConfigurationReader` instead: +`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 = @@ -466,7 +468,7 @@ takes an `IAsyncConfigurationReader` instead: let parser = ArgumentParser.Create() // myVaultReader implements IAsyncConfigurationReader - let! results = parser.ParseAsync(configurationReader = myVaultReader) + 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 @@ -492,10 +494,10 @@ Alternatives that may fit better: 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`. Right when the host already has an + `DictionaryConfigurationReader`. Appropriate when the host already has an async startup phase the CLI parse can hook into. -`ParseAsync` is least redundant when the config source is genuinely +`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 @@ -505,20 +507,20 @@ per-app and the host has no other async startup phase to leverage. 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 runtime, should catch it. + 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.WithFallbackToNull` for a best-effort policy - that downgrades the fault to "all keys missing" and logs to stderr - (or a custom hook): + `ConfigurationReader.WithFallback` for a fallback policy + that substitutes fallback values and/or logs to stderr etc: [lang=fsharp] let reader = - ConfigurationReader.WithFallbackToNull( - inner = myVaultReader, - onFault = fun ex -> log.Warn("vault unavailable: {0}", ex)) + 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 @@ -527,7 +529,7 @@ per-app and the host has no other async startup phase to leverage. after the batch resolves. `ArguParseException` carries a friendly error message; the default error handler prints usage and exits. -Treat the three flavours separately when wiring host-level error +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. diff --git a/samples/Argu.Samples.AsyncConfig/Program.fs b/samples/Argu.Samples.AsyncConfig/Program.fs index b723c0d3..239fd684 100644 --- a/samples/Argu.Samples.AsyncConfig/Program.fs +++ b/samples/Argu.Samples.AsyncConfig/Program.fs @@ -48,6 +48,7 @@ type Args = // ----------------------------------------------------------------------------- module KeyVault = + open Azure open Azure.Identity open Azure.Security.KeyVault.Secrets @@ -62,7 +63,7 @@ module KeyVault = /// Failure semantics: /// - Vault unreachable / auth failure -> the underlying Task faults /// with RequestFailedException; the fault propagates out of - /// GetValuesAsync. Callers wrap with WithFallbackToNull if best-effort + /// 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 @@ -70,27 +71,18 @@ module KeyVault = let asReader (client : SecretClient) : IAsyncConfigurationReader = { new IAsyncConfigurationReader with member _.Name = sprintf "Azure Key Vault @ %O" client.VaultUri - member _.GetValuesAsync(keys : IReadOnlyCollection) = - task { - let tasks = - keys - |> Seq.map (fun k -> - task { - try - let! resp = client.GetSecretAsync(k) - return Some (k, resp.Value.Value) - with :? RequestFailedException as ex when ex.Status = 404 -> - return None - }) - |> Seq.toArray - let! all = Task.WhenAll tasks - let dict = Dictionary(all.Length) - for kv in all do - match kv with - | Some (k, v) -> dict[k] <- v - | None -> () - return dict :> IReadOnlyDictionary - } } + 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 } } // ----------------------------------------------------------------------------- @@ -103,33 +95,31 @@ module Simulated = /// and proves the call is genuinely async. let reader : IAsyncConfigurationReader = let seed = - dict [ + 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 - } } + 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. +// 1. ArguException at Create -> schema is broken; fatal. exit code 2 // 2. Faulted Task from reader -> source unavailable; fatal unless wrapped -// with WithFallbackToNull. -// 3. ArguParseException at Parse -> user input invalid; print usage + exit. +// with WithFallback. +// 3. ArguParseException at Parse -> user input invalid; print usage + exit 1. // ----------------------------------------------------------------------------- [] @@ -144,43 +134,40 @@ let main argv = 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 1 + // (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 baseReader = + 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 WithFallbackToNull so vault - // unavailability degrades to "all keys missing" plus a stderr line. + // (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 = - ConfigurationReader.WithFallbackToNull( - baseReader, - onFault = fun ex -> - eprintfn "warning: %s unavailable; CLI defaults only (%s)" - baseReader.Name - ex.Message) + 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).Result - with :? AggregateException as agg when (agg.InnerException :? ArguParseException) -> - eprintfn "%s" agg.InnerException.Message + 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:" - printfn " source = %s" reader.Name - printfn " db-host = %s" (results.GetResult(Db_Host, defaultValue = "")) - printfn " port = %s" (results.TryGetResult(Port) |> Option.map string |> Option.defaultValue "") - printfn " feature-flag = %s" (results.GetResult(Feature_Flag, defaultValue = "")) + 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 0f42738f..b301c22d 100644 --- a/src/Argu/ArgumentParser.fs +++ b/src/Argu/ArgumentParser.fs @@ -244,59 +244,42 @@ and [] with ParserExn (errorCode, msg) -> errorHandler.Exit (msg, errorCode) /// - /// Parse both command line args and 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. + /// 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 - /// for a - /// best-effort policy that treats source unavailability as - /// "all keys missing". - /// - Keys absent from the returned dictionary are treated as - /// missing values, matching the per-key null contract of - /// . + /// 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. Taken from System.Environment if not specified. - /// Async configuration reader. If not supplied, the synchronous default AppSettings reader is used. + /// 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 ap.ParseAsync (?inputs : string [], ?configurationReader : IAsyncConfigurationReader, ?ignoreMissing, ?ignoreUnrecognized, ?raiseOnUsage) : Task> = - task { - let reader = - match configurationReader with - | Some r -> r - | None -> ConfigurationReader.AsAsync(ConfigurationReader.FromAppSettings()) - // 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 = ResizeArray() - let seen = HashSet() - for case in argInfo.Cases.Value do - match case.AppSettingsName with - | Some key when seen.Add key -> keys.Add key - | _ -> () - let! prefetched = reader.GetValuesAsync(keys :> IReadOnlyCollection) - let syncReader = - { new IConfigurationReader with - member _.Name = reader.Name - member _.GetValue key = - let ok, v = prefetched.TryGetValue key - if ok then v else null } - return ap.Parse( - ?inputs = inputs, - configurationReader = syncReader, - ?ignoreMissing = ignoreMissing, - ?ignoreUnrecognized = ignoreUnrecognized, - ?raiseOnUsage = raiseOnUsage) - } + /// 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 @@ -315,7 +298,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 f95620b3..73402de0 100644 --- a/src/Argu/ConfigReaders.fs +++ b/src/Argu/ConfigReaders.fs @@ -27,7 +27,7 @@ type IConfigurationReader = /// 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 +/// startup error; wrap with /// for a best-effort policy that treats source unavailability as /// "all keys missing". /// @@ -41,7 +41,7 @@ type IAsyncConfigurationReader = /// (equivalent to null from /// ). /// - abstract GetValuesAsync : keys:IReadOnlyCollection -> Task> + abstract GetValuesAsync : keys : IReadOnlyCollection -> Task> /// Configuration reader that never returns a value type NullConfigurationReader () = @@ -69,12 +69,12 @@ type EnvironmentVariableConfigurationReader () = (null, targets) ||> Array.fold folder /// Configuration reader dictionary proxy -type DictionaryConfigurationReader (keyValueDictionary : IReadOnlyDictionary, ?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 @@ -115,11 +115,11 @@ type ConfigurationReader = static member NullReader : IConfigurationReader = NullConfigurationReader() /// Create a configuration reader instance using an IDictionary instance - static member FromDictionary(keyValueDictionary : IReadOnlyDictionary, ?name : string) = - DictionaryConfigurationReader(keyValueDictionary, ?name = name) :> IConfigurationReader + 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 = + static member FromFunction(reader : string -> string option, ?name : string) = FunctionConfigurationReader(reader, ?name = name) /// Create a configuration reader instance using environment variables. @@ -149,7 +149,7 @@ 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 .
@@ -173,7 +173,7 @@ type ConfigurationReader = /// If the underlying source supports batched retrieval natively, /// implement directly to collapse the N round-trips into one. ///
- static member FromAsyncFunction(reader : string -> Async, ?name : string) : IAsyncConfigurationReader = + static member FromFunctionAsync(reader : string -> Task, ?name : string) : IAsyncConfigurationReader = let name = defaultArg name "Async function configuration reader." { new IAsyncConfigurationReader with member _.Name = name @@ -183,26 +183,17 @@ type ConfigurationReader = match! reader k with | None -> () | Some v -> dict[k] <- v - return dict - } } + return dict } } /// - /// Wraps an so transport - /// errors (faulted ) are downgraded to "all keys - /// missing" rather than failing the parse. The optional - /// hook lets callers log to stderr or - /// telemetry; the default writes a single line to stderr. - /// Use when configuration is genuinely optional and the program - /// should still start with CLI defaults if the source is - /// unreachable. + /// Wraps an so errors (faulted ) are substituted + /// by a substitute configuration rather than failing the parse. /// - static member WithFallbackToNull(reader : IAsyncConfigurationReader, ?onFault : exn -> unit) : IAsyncConfigurationReader = - let onFault = defaultArg onFault (fun ex -> eprintfn "[%s] unavailable: %s" reader.Name ex.Message) - let empty = Dictionary() :> IReadOnlyDictionary + /// 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 -> - onFault ex - return empty } } + with ex -> return! fallback ex } } 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 index d38616c8..f7e98f9e 100644 --- a/tests/Argu.Tests/ParseAsyncTests.fs +++ b/tests/Argu.Tests/ParseAsyncTests.fs @@ -61,34 +61,29 @@ let ``ParseAsync issues exactly one batched call covering all schema keys`` () = let ``FromAsyncFunction adapts an F# async function`` () = let parser = ArgumentParser.Create() let asyncReader = - ConfigurationReader.FromAsyncFunction( - fun key -> - async { - return - if key = "tagkey" then Some "from-async" - else None - }) + 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`` () = +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.Exception "vault unavailable") } - // No fallback wrapper - the fault is fatal, matching the documented - // contract. - let agg = - Assert.Throws(fun () -> - parser.ParseAsync(inputs = [||], configurationReader = reader).Result |> ignore) - test <@ agg.InnerException.Message = "vault unavailable" @> + 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 ``WithFallbackToNull downgrades a faulted batch to all keys missing`` () = +let ``WithFallback downgrades a faulted batch to substitute values`` () = let parser = ArgumentParser.Create() let inner = { new IAsyncConfigurationReader with @@ -98,14 +93,15 @@ let ``WithFallbackToNull downgrades a faulted batch to all keys missing`` () = System.Exception "vault unavailable") } let mutable seenFault : exn option = None let reader = - ConfigurationReader.WithFallbackToNull( - inner, - onFault = fun ex -> seenFault <- Some ex) + 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 = [| "--tagkey"; "v1" |], configurationReader = reader).Result - test <@ r.GetResult(TagKey) = "v1" @> - test <@ seenFault.IsSome @> - test <@ seenFault.Value.Message = "vault unavailable" @> + 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`` () = From e0f8b929957f081ee74fc4085ad67ba0c4bb5b8e Mon Sep 17 00:00:00 2001 From: dimension-zero <127850950+dimension-zero@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:49:20 +0100 Subject: [PATCH 18/20] feat(ConfigurationReader): FromEnvironmentVariables(prefix), FromMicrosoftConfiguration (#308) * feat(Config): Add env-var prefix factory + Argu.MSConfig companion * ConfigReaders.fs: New static member ConfigurationReader.FromEnvironmentVariables(prefix : string) that reads env vars with a fixed prefix prepended to the key. Composed from the existing EnvironmentVariableConfigurationReader via the existing FunctionConfigurationReader, so no new types in core. Existing FromEnvironmentVariables() is untouched. * New project src/Argu.MSConfig: thin adapter exposing any Microsoft.Extensions.Configuration.IConfiguration as an Argu IConfigurationReader. Lives in its own NuGet package so the core stays zero-dep on Microsoft.Extensions.*. * Directory.Packages.props: centrally-managed pin for Microsoft.Extensions.Configuration.Abstractions 8.0.0. * Argu.sln: register the new project under the existing F# tooling configuration. * test(EnvVarPrefix): Add coverage for FromEnvironmentVariables(prefix) 4 new tests: * FromEnvironmentVariables(prefix) prepends the prefix to the requested key, so reader.GetValue('HOST') reads env var MYAPP_HOST. * Missing keys come back as null (Argu's standard contract). * Round-trip through ArgumentParser.ParseConfiguration: a schema using CustomAppSettings keys is populated correctly when only the prefixed env vars are set. * No-arg FromEnvironmentVariables() is still functional (the new overload doesn't shadow the legacy one). Net suite size on this branch: 112 -> 116. * refactor: Rename Argu.MSConfig -> Argu.Extensions.Configuration Per review on #308, the M.E.C-style name matches the rest of the Microsoft.Extensions.* ecosystem and avoids the two-letter capitalised 'MSConfig' segment. * src/Argu.MSConfig/ -> src/Argu.Extensions.Configuration/ * Argu.MSConfig.fsproj -> Argu.Extensions.Configuration.fsproj (AssemblyName / RootNamespace / PackageId all default to the project file name) * Namespace Argu.MSConfig -> Argu.Extensions.Configuration in ConfigurationReader.fs * Solution registration updated in Argu.slnx * test(EnvVarPrefix): Scope env overrides via use _ = envOverride (IDisposable) Per review feedback: replace the withEnv wrapper-function helper with an IDisposable-returning envOverride, so each test reads `use _ = envOverride key value` and the prior value is restored in Dispose(). Removes the nested-lambda indentation in the multi-var round-trip test. --------- Co-authored-by: Ruben Bartelink --- src/Argu/ConfigReaders.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Argu/ConfigReaders.fs b/src/Argu/ConfigReaders.fs index 73402de0..ac6a3239 100644 --- a/src/Argu/ConfigReaders.fs +++ b/src/Argu/ConfigReaders.fs @@ -119,7 +119,7 @@ type ConfigurationReader = DictionaryConfigurationReader(values, ?name = name) /// Create a configuration reader instance using an F# function - static member FromFunction(reader : string -> string option, ?name : string) = + static member FromFunction(reader : string -> string option, ?name : string) : IConfigurationReader = FunctionConfigurationReader(reader, ?name = name) /// Create a configuration reader instance using environment variables. From 1da665b301a14a81524b7f64c0aab2a7654b3a3c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Jun 2026 08:49:22 +0100 Subject: [PATCH 19/20] Remove ParseConfig release-note reference (#329) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- RELEASE_NOTES.md | 3 +- src/Argu/ArgumentParser.fs | 42 -------------- tests/Argu.Tests/Argu.Tests.fsproj | 1 - tests/Argu.Tests/ParseConfigTests.fs | 85 ---------------------------- 4 files changed, 1 insertion(+), 130 deletions(-) delete mode 100644 tests/Argu.Tests/ParseConfigTests.fs diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 208d0273..be889d5d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,14 +6,13 @@ * Fix [misrendering of usage when parameter description blank](https://github.com/fsprojects/Argu/issues/173) [#323](https://github.com/fsprojects/Argu/pull/323) [@DominikL1999](https://github.com/DominikL1999) * Add `Separator("=", orSpace = true)` and `Separator "="` attribute syntax to replace `Obsolete`d `EqualsAssignment`, `ColonAssignment`, `CustomAssignment`, `EqualsAssignmentOrSpaced`, `ColonAssignmentOrSpaced` and `CustomAssignmentOrSpaced` [#315](https://github.com/fsprojects/Argu/pull/315) [@dimension-zero](https://github.com/dimension-zero) * Add `Argu.Samples.Introspect` sample [#298](https://github.com/fsprojects/Argu/pull/298) [@dimension-zero](https://github.com/dimension-zero) -* Add `ArgumentParser.Parse(ParseConfig)` [#307](https://github.com/fsprojects/Argu/pull/307) [@dimension-zero](https://github.com/dimension-zero) * Add `ArgumentParser.PrintUsage(..., ?UsageStrings)` for localization support [#303](https://github.com/fsprojects/Argu/pull/303) [@dimension-zero](https://github.com/dimension-zero) * Add AOT annotations [#314](https://github.com/fsprojects/Argu/pull/314) [@dimension-zero](https://github.com/dimension-zero) * Add `SourceGenerator.ArguGenerate` marker [#318](https://github.com/fsprojects/Argu/pull/318) [@dimension-zero](https://github.com/dimension-zero) * Add `ConfigurationReader.FromEnvironmentVariables(prefix : String)` [#308](https://github.com/fsprojects/Argu/pull/308) [@dimension-zero](https://github.com/dimension-zero) * 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 with fallback to an asynchronously loaded configuration (i.e. `ConfigurationReader.FromFunctionAsync`) [#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/src/Argu/ArgumentParser.fs b/src/Argu/ArgumentParser.fs index b301c22d..0f98a702 100644 --- a/src/Argu/ArgumentParser.fs +++ b/src/Argu/ArgumentParser.fs @@ -8,35 +8,6 @@ open System.Diagnostics.CodeAnalysis open Argu.UnionArgInfo -/// Configuration record for . -/// Each field carries the same meaning as the matching optional parameter on -/// the existing Parse overload. Use -/// as a starting point and override only the fields you care about. -[] -type ParseConfig = - { - /// The command line input. None takes the inputs from System.Environment. - Inputs : string [] option - /// Configuration reader used to source AppSettings-style arguments. - /// None uses the AppSettings configuration of the current process. - ConfigurationReader : IConfigurationReader option - /// Ignore errors caused by the Mandatory attribute. - IgnoreMissing : bool - /// Ignore CLI arguments that do not match the schema. - IgnoreUnrecognized : bool - /// Treat '--help' parameters as parse errors. - RaiseOnUsage : bool - } - /// Default parse configuration, matching the historical Parse(...) defaults: - /// inputs and configurationReader inherited from the environment, do not ignore - /// missing or unrecognized arguments, and raise on '--help'. - static member Default : ParseConfig = - { Inputs = None - ConfigurationReader = None - IgnoreMissing = false - IgnoreUnrecognized = false - RaiseOnUsage = true } - module internal TrimMessages = [] let aot = @@ -204,19 +175,6 @@ and [] with ParserExn (errorCode, msg) -> errorHandler.Exit (msg, errorCode) - /// Parse both command line args and supplied configuration reader, using - /// a record. Useful when callers want to construct - /// the parameter set programmatically (e.g. layering host defaults over user - /// overrides) without juggling many optional method arguments. - /// The parse configuration. See ParseConfig.Default. - member self.Parse (config : ParseConfig) : ParseResults<'Template> = - self.Parse( - ?inputs = config.Inputs, - ?configurationReader = config.ConfigurationReader, - ignoreMissing = config.IgnoreMissing, - ignoreUnrecognized = config.IgnoreUnrecognized, - raiseOnUsage = config.RaiseOnUsage) - /// Parse both command line args and supplied configuration reader. /// Results are merged with command line args overriding configuration parameters. /// The command line input. Taken from System.Environment if not specified. diff --git a/tests/Argu.Tests/Argu.Tests.fsproj b/tests/Argu.Tests/Argu.Tests.fsproj index 9aeb5ad3..749050e3 100644 --- a/tests/Argu.Tests/Argu.Tests.fsproj +++ b/tests/Argu.Tests/Argu.Tests.fsproj @@ -9,7 +9,6 @@ - diff --git a/tests/Argu.Tests/ParseConfigTests.fs b/tests/Argu.Tests/ParseConfigTests.fs deleted file mode 100644 index 68edf22e..00000000 --- a/tests/Argu.Tests/ParseConfigTests.fs +++ /dev/null @@ -1,85 +0,0 @@ -module Argu.Tests.ParseConfigTests - -open System.Collections.Generic -open Swensen.Unquote -open Xunit - -open Argu - -type Args = - | [] Port of int - | Verbose - | Tag of string - interface IArgParserTemplate with - member this.Usage = - match this with - | Port _ -> "port" - | Verbose -> "verbose" - | Tag _ -> "tag" - -let private parser () = ArgumentParser.Create() -[] -let ``ParseConfig.Default holds historical defaults`` () = - let d = ParseConfig.Default - test <@ d.Inputs = None @> - test <@ d.ConfigurationReader = None @> - test <@ d.IgnoreMissing = false @> - test <@ d.IgnoreUnrecognized = false @> - test <@ d.RaiseOnUsage = true @> - -[] -let ``Parse(config with explicit inputs) parses those inputs`` () = - let p = parser () - let argv = [| "--port"; "8080"; "--verbose" |] - let cfg = { ParseConfig.Default with Inputs = Some argv ; RaiseOnUsage = false } - let results = p.Parse(cfg) - test <@ results.GetResult(Port) = 8080 @> - test <@ results.Contains(Verbose) @> - -[] -let ``Parse(config) matches Parse(?inputs, ...) for the same parameters`` () = - let p = parser () - let argv = [| "--port"; "1234"; "--tag"; "v1" |] - let viaConfig = - let cfg = { ParseConfig.Default with Inputs = Some argv ; RaiseOnUsage = false } - p.Parse(cfg) - let viaOptional = p.Parse(inputs = argv, raiseOnUsage = false) - test <@ viaConfig.GetResult(Port) = viaOptional.GetResult(Port) @> - test <@ viaConfig.GetResult(Tag) = viaOptional.GetResult(Tag) @> - -[] -let ``Parse(config with IgnoreMissing=true) skips mandatory check`` () = - let p = parser () - let cfg = { ParseConfig.Default with Inputs = Some [||] ; IgnoreMissing = true } - let results = p.Parse(cfg) - test <@ results.TryGetResult(Port) = None @> - -[] -let ``Parse(config with IgnoreUnrecognized=true) collects unknown args`` () = - let p = parser () - let cfg = - { ParseConfig.Default with - Inputs = Some [| "--port"; "1"; "--bogus" |] - IgnoreUnrecognized = true - RaiseOnUsage = false } - let results = p.Parse(cfg) - test <@ results.UnrecognizedCliParams |> List.contains "--bogus" @> - -/// Argu's missing-mandatory check fires from the CLI even when AppSettings provides a value (pre-existing behavior), -/// so the AppSettings round-trip test uses a non-mandatory schema. -type AppSettingsArgs = - | TagKey of string - interface IArgParserTemplate with member this.Usage = "tag" - -[] -let ``Parse(config with ConfigurationReader) sources AppSettings`` () = - let p = ArgumentParser.Create() - let dict = Dictionary() - dict["tagkey"] <- "v1" - let reader = ConfigurationReader.FromDictionary dict - let cfg = - { ParseConfig.Default with - Inputs = Some [||] - ConfigurationReader = Some reader } - let results = p.Parse(cfg) - test <@ results.GetResult(TagKey) = "v1" @> From a49a45dc9fa80b0d60d4f205dfec6adb3b4ec38e Mon Sep 17 00:00:00 2001 From: dimension-zero Date: Wed, 10 Jun 2026 19:31:24 +0100 Subject: [PATCH 20/20] chore: add bash, .NET, and IDE ignore patterns risk: LOW (score: 0.0, no analysable symbols) --- .gitignore | 176 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/.gitignore b/.gitignore index 80646948..5f9ea4e2 100644 --- a/.gitignore +++ b/.gitignore @@ -206,3 +206,179 @@ launchSettings.json # 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 +