diff --git a/Argu.slnx b/Argu.slnx index 4c0db3e..998efbc 100644 --- a/Argu.slnx +++ b/Argu.slnx @@ -40,6 +40,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index b7691ac..1033116 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,9 @@ + + + diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 58eaa7c..9e56101 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 0000000..dddb9de --- /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 0000000..9f845bd --- /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 687e8d8..09f4858 100644 --- a/src/Argu/ConfigReaders.fs +++ b/src/Argu/ConfigReaders.fs @@ -1,9 +1,9 @@ namespace Argu open System -open System.IO open System.Configuration open System.Collections.Generic +open System.IO open System.Reflection /// Abstract key/value configuration reader @@ -11,16 +11,16 @@ type IConfigurationReader = /// Configuration reader identifier abstract Name : string /// Gets value corresponding to supplied key - abstract GetValue : key:string -> string | null + abstract GetValue : key : string -> string | null /// Configuration reader that never returns a value -type NullConfigurationReader() = +type NullConfigurationReader () = interface IConfigurationReader with - member x.Name = "Null Configuration Reader" - member x.GetValue _ = null + member _.Name = "Null Configuration Reader" + member _.GetValue _ = null /// Environment variable-based configuration reader -type EnvironmentVariableConfigurationReader() = +type EnvironmentVariableConfigurationReader () = // order of environment variable target lookup let targets = [| EnvironmentVariableTarget.Process @@ -28,21 +28,22 @@ type EnvironmentVariableConfigurationReader() = EnvironmentVariableTarget.Machine |] interface IConfigurationReader with - member x.Name = "Environment Variables Configuration Reader" - member x.GetValue(key:string) = + member _.Name = "Environment Variables Configuration Reader" + member _.GetValue(key : string) = + // NOTE this logic varies from a targetless Get in that it will pick up changes that a given process tree + // has yet to propagate. A fresh shell should include machine and user level vars in the process env from the off let folder curr (target : EnvironmentVariableTarget) = match curr with | null -> Environment.GetEnvironmentVariable(key, target) | value -> value - - targets |> Array.fold folder null + (null, targets) ||> Array.fold folder /// Configuration reader dictionary proxy type DictionaryConfigurationReader (keyValueDictionary : IDictionary, ?name : string) = let name = defaultArg name "Dictionary configuration reader." interface IConfigurationReader with member _.Name = name - member _.GetValue(key:string) = + member _.GetValue(key : string) = let ok,value = keyValueDictionary.TryGetValue key if ok then value else null @@ -51,23 +52,20 @@ type FunctionConfigurationReader (configFunc : string -> string option, ?name : let name = defaultArg name "Function configuration reader." interface IConfigurationReader with member _.Name = name - member _.GetValue(key:string) = - match configFunc key with - | None -> null - | Some v -> v + member _.GetValue(key : string) = configFunc key |> Option.toObj /// AppSettings XML configuration reader type AppSettingsConfigurationReader () = interface IConfigurationReader with member _.Name = "AppSettings configuration reader" - member _.GetValue(key:string) = ConfigurationManager.AppSettings[key] + member _.GetValue(key : string) = ConfigurationManager.AppSettings[key] /// AppSettings XML configuration reader type AppSettingsConfigurationFileReader private (xmlPath : string, kv : KeyValueConfigurationCollection) = member _.Path = xmlPath interface IConfigurationReader with - member _.Name = sprintf "App.config configuration reader: %s" xmlPath - member _.GetValue(key:string) = + member _.Name = $"App.config configuration reader: %s{xmlPath}" + member _.GetValue(key : string) = match kv[key] with | null -> null | entry -> entry.Value @@ -75,37 +73,50 @@ type AppSettingsConfigurationFileReader private (xmlPath : string, kv : KeyValue /// Create used supplied XML file path static member Create(path : string) = if not <| File.Exists path then raise <| FileNotFoundException(path) - let fileMap = ExeConfigurationFileMap() - fileMap.ExeConfigFilename <- path + let fileMap = ExeConfigurationFileMap(ExeConfigFilename = path) let config = ConfigurationManager.OpenMappedExeConfiguration(fileMap, ConfigurationUserLevel.None) AppSettingsConfigurationFileReader(path, config.AppSettings.Settings) /// Configuration reader implementations +[] type ConfigurationReader = + /// Create a configuration reader that always returns null - static member NullReader = NullConfigurationReader() :> IConfigurationReader + static member NullReader : IConfigurationReader = NullConfigurationReader() /// Create a configuration reader instance using an IDictionary instance - static member FromDictionary(keyValueDictionary : IDictionary, ?name : string) = - DictionaryConfigurationReader(keyValueDictionary, ?name = name) :> IConfigurationReader + static member FromDictionary(keyValueDictionary : IDictionary, ?name : string) : IConfigurationReader = + DictionaryConfigurationReader(keyValueDictionary, ?name = name) /// Create a configuration reader instance using an F# function - static member FromFunction(reader : string -> string option, ?name : string) = - FunctionConfigurationReader(reader, ?name = name) :> IConfigurationReader - - /// Create a configuration reader instance using environment variables - static member FromEnvironmentVariables() = - EnvironmentVariableConfigurationReader() :> 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 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 6090968..d0c2522 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 19dd668..521b408 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 0000000..55764aa --- /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 @>