Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Argu.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
<Project Path="samples/Argu.Samples.SourceGenerated/Argu.Samples.SourceGenerated.fsproj" />
<Project Path="src/Argu/Argu.fsproj" />
<Project Path="src/Argu.SourceGenerator/Argu.SourceGenerator.fsproj" />
<Project Path="src/Argu.Extensions.Configuration/Argu.Extensions.Configuration.fsproj" />
<Project Path="benchmarks/Argu.Benchmarks/Argu.Benchmarks.fsproj" />
<Project Path="tests/Argu.Tests/Argu.Tests.fsproj" />
</Solution>
3 changes: 3 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
<PackageVersion Include="FSharp.Core" Version="6.0.0"/>
<PackageVersion Include="System.Configuration.ConfigurationManager" Version="4.4.0"/>

<!-- Companion adapter deps -->
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0"/>

<!-- Test deps -->
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2"/>
<PackageVersion Include="Unquote" Version="7.0.1"/>
Expand Down
4 changes: 3 additions & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<Description>Microsoft.Extensions.Configuration adapter for Argu — exposes any IConfiguration source as an Argu IConfigurationReader.</Description>
<PackageTags>F#, argument, commandline, parser, configuration</PackageTags>
</PropertyGroup>
<ItemGroup>
<Compile Include="ConfigurationReader.fs"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Argu\Argu.fsproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="FSharp.Core"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions"/>
</ItemGroup>
</Project>
26 changes: 26 additions & 0 deletions src/Argu.Extensions.Configuration/ConfigurationReader.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace Argu.Extensions.Configuration

open Microsoft.Extensions.Configuration

open Argu

/// <summary>
/// Argu <see cref="IConfigurationReader"/> backed by a
/// <see cref="Microsoft.Extensions.Configuration.IConfiguration"/>.
/// The reader returns <c>null</c> when a key is missing, matching the
/// contract of every other Argu configuration reader.
/// </summary>
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 <see cref="ConfigurationReader"/>.
[<AbstractClass; Sealed>]
type ConfigurationReader =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Numpsy in #234 this should become a type augmentation and the namespace on L1 should become Argu so Autocomplete experience can be consistent?

/// Create an Argu <see cref="IConfigurationReader"/> over an <see cref="IConfiguration"/>.
static member FromMicrosoftConfiguration(configuration : IConfiguration, ?name : string) : IConfigurationReader =
MicrosoftExtensionsConfigurationReader(configuration, ?name = name) :> _
81 changes: 46 additions & 35 deletions src/Argu/ConfigReaders.fs
Original file line number Diff line number Diff line change
@@ -1,48 +1,49 @@
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
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
EnvironmentVariableTarget.User
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

@bartelink bartelink Jun 8, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nojaf @dimension-zero I feel this is a misfeature; while its useful for local testing scenarios etc, it feels to me like it introduces a suprising potential attack vector (yes I know if you can override machine level config all bets are off, but e.g. being able to effectively inject config into a running process tree by manipulating user level config seems like bad news)

Am I missing something, or does anyone feel we should log an issue and remove the behavior in V7 flagging it as a small breaking change in behavior?

cc @eiriktsarpalis who doubtless has a good reason for this!

// 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<string, string>, ?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

Expand All @@ -51,61 +52,71 @@ 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

/// 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
[<AbstractClass; Sealed>]
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<string,string>, ?name : string) =
DictionaryConfigurationReader(keyValueDictionary, ?name = name) :> IConfigurationReader
static member FromDictionary(keyValueDictionary : IDictionary<string,string>, ?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()

/// <summary>
/// Create a configuration reader that reads environment variables with a fixed prefix prepended to the requested key.<br/>
/// Maps <c>--foo-bar</c> arguments onto <c>MYAPP_FOO_BAR</c> environment variables.<br/>
/// As per <c>FromEnvironmentVariables()</c>, reads Process variables, falling back to latest User or latest Machine targets if not found.
/// </summary>
/// <param name="prefix">String prepended to each requested key (e.g. <c>"MYAPP_"</c>).</param>
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
2 changes: 2 additions & 0 deletions tests/Argu.Tests/Argu.Tests.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Compile Include="PrimitiveTests.fs" />
<Compile Include="Tests.fs"/>
<Compile Include="EnvVarTests.fs" />
<Compile Include="CoverageTests.fs"/>
<Compile Include="ParseConfigTests.fs"/>
<Compile Include="UsageStringsTests.fs"/>
Expand Down
19 changes: 7 additions & 12 deletions tests/Argu.Tests/CoverageTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
| [<CustomAppSettings("ARGU_TEST_VAL")>] Val of string
interface IArgParserTemplate with
member this.Usage = "value"

[<Fact>]
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<EnvArgs>()
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<EnvArgs>()
let reader = ConfigurationReader.FromEnvironmentVariables()
let results = parser.ParseConfiguration reader
test <@ results.GetResult Val = "from-env" @>
50 changes: 50 additions & 0 deletions tests/Argu.Tests/EnvVarTests.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
module Argu.Tests.EnvVarTests

open Swensen.Unquote
open System
open Xunit

open Argu

type Args =
| [<CustomAppSettings("HOST")>] HostName of string
| [<CustomAppSettings("PORT")>] 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) }

[<Fact>]
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 =

[<Fact>]
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" @>

[<Fact>]
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 @>

[<Fact>]
let ``FromEnvironmentVariables(prefix) parses round-trip through ArgumentParser`` () =
use _ = envOverride "DEMO_HOST" "host.local"
use _ = envOverride "DEMO_PORT" "443"
let parser = ArgumentParser.Create<Args>(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 @>
Loading