Add the ability to trace the provenance of config values.

This commit is contained in:
2026-04-13 10:46:43 -05:00
parent 8e381d15d0
commit fccaf54dc5
2 changed files with 116 additions and 27 deletions

View File

@@ -1,6 +1,6 @@
# Package # Package
version = "0.11.1" version = "0.12.0"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Helper functions for writing command line interfaces." description = "Helper functions for writing command line interfaces."
license = "MIT" license = "MIT"
@@ -8,4 +8,4 @@ license = "MIT"
# Dependencies # Dependencies
requires @["nim >= 1.6.0", "docopt >= 0.6.8"] requires @["nim >= 1.6.0", "docopt >= 0.6.8", "regex", "identcasing"]

View File

@@ -1,19 +1,56 @@
import std/[json, nre, os, sequtils, strtabs, strutils] import std/[json, os, sequtils, strtabs, strutils]
import docopt import docopt, identcasing, regex
type type
CombinedConfig* = object CombinedConfig* = object
docopt*: Table[string, Value] docopt*: Table[string, Value]
json*: JsonNode json*: JsonNode
proc keyNames(key: string): tuple[arg, env, json: string] {.raises: [KeyError].} = ValueProvenance* {.pure.} = enum arguments, environment, json, default
const argStyleRegex = re2"(--)?[[:lower:][:digit:]][[:lower:][:digit:]\-]+"
const envStyleRegex = re2"[[:upper:][:digit:]_]+"
const jsonStyleRegex = re2"[[:lower:]][[:lower:][:upper:][:digit:]]+"
proc keyNames*(key: string): tuple[arg, env, json: string]
{.raises: [KeyError].} =
## CombinedConfig tries to be idiomatic in its usage of key names. For
## environment variables it uses `UPPER_SNAKE_CASE`-style naming. For
## arguments it uses `lower-kebab-case`, and for JSON it uses
## `lowerCamelCase`.
##
## *keyNames* takes a key in any one of the three styles and returns all
## three. This is primarily a helper for other parsing functions, but is
## exposed as it is sometimes useful.
try: try:
result = ( if match(key, argStyleRegex):
"--" & key, let unprefixed =
key.replace('-', '_').toUpper, if key.startsWith("--"): key[2..^1]
key.replace(re"(-\w)", proc (m: RegexMatch): string = ($m)[1..1].toUpper)) else: key
return (
"--" & unprefixed,
convertCase(unprefixed, lowerKebabCase, upperSnakeCase),
convertCase(unprefixed, lowerKebabCase, lowerCamelCase))
elif match(key, envStyleRegex):
return (
"--" & convertCase(key, upperSnakeCase, lowerKebabCase),
key,
convertCase(key, upperSnakeCase, lowerCamelCase))
elif match(key, jsonStyleRegex):
return (
"--" & convertCase(key, lowerCamelCase, lowerKebabCase),
convertCase(key, lowerCamelCase, upperSnakeCase),
key)
else: raise newException(KeyError,
"key style must be --argument-style, ENV_STYLE, or jsonStyle")
except Exception: except Exception:
raise newException(KeyError, "invalid config key: '" & key & "'") raise newException(KeyError, "invalid config key '" & key & "': " &
getCurrentExceptionMsg())
template walkFieldDefs*(t: NimNode, body: untyped) = template walkFieldDefs*(t: NimNode, body: untyped) =
let tTypeImpl = t.getTypeImpl let tTypeImpl = t.getTypeImpl
@@ -51,6 +88,7 @@ proc initCombinedConfig*(
result = CombinedConfig(docopt: docopt, json: parseFile(filename)) result = CombinedConfig(docopt: docopt, json: parseFile(filename))
proc findConfigFile*(name: string, otherLocations: seq[string] = @[]): string = proc findConfigFile*(name: string, otherLocations: seq[string] = @[]): string =
let cfgLocations = @[ let cfgLocations = @[
$getEnv(name.strip(chars = {'.'}).toUpper()), $getEnv(name.strip(chars = {'.'}).toUpper()),
@@ -61,42 +99,88 @@ proc findConfigFile*(name: string, otherLocations: seq[string] = @[]): string =
if result == "" or not fileExists(result): if result == "" or not fileExists(result):
raise newException(ValueError, "could not find configuration file") raise newException(ValueError, "could not find configuration file")
proc getVal*(cfg: CombinedConfig, key: string): string =
proc getValueAndProvenance*(
cfg: CombinedConfig,
key: string): tuple[value: string, provenance: ValueProvenance]
{. raises: [ValueError] .} =
let (argKey, envKey, jsonKey) = keyNames(key) let (argKey, envKey, jsonKey) = keyNames(key)
if cfg.docopt.contains(argKey) and cfg.docopt[argKey]: return $cfg.docopt[argKey] if cfg.docopt.contains(argKey) and cfg.docopt[argKey]:
elif existsEnv(envKey): return getEnv(envKey) return ($cfg.docopt[argKey], ValueProvenance.arguments)
elif existsEnv(envKey):
return (getEnv(envKey), ValueProvenance.environment)
elif cfg.json.hasKey(jsonKey): elif cfg.json.hasKey(jsonKey):
let node = cfg.json[jsonKey] let node = cfg.json[jsonKey]
case node.kind let value =
of JString: return node.getStr case node.kind
of JInt: return $node.getInt of JString: node.getStr
of JFloat: return $node.getFloat of JInt: $node.getInt
of JBool: return $node.getBool of JFloat: $node.getFloat
of JNull: return "" of JBool: $node.getBool
of JObject: return $node of JNull: ""
of JArray: return $node of JObject: $node
of JArray: $node
return (value, ValueProvenance.json)
else: raise newException(ValueError, "cannot find a configuration value for \"" & key & "\"") else: raise newException(ValueError, "cannot find a configuration value for \"" & key & "\"")
proc getValueAndProvenance*(
cfg: CombinedConfig,
key: string,
default: string): tuple[value: string, provenance: ValueProvenance]
{.raises: [].} =
try: return getValueAndProvenance(cfg, key)
except CatchableError: return (default, ValueProvenance.default)
proc getVal*(cfg: CombinedConfig, key: string): string =
getValueAndProvenance(cfg, key).value
proc getVal*(cfg: CombinedConfig, key, default: string): string {.raises: [].} = proc getVal*(cfg: CombinedConfig, key, default: string): string {.raises: [].} =
try: return getVal(cfg, key) try: return getVal(cfg, key)
except CatchableError: return default except CatchableError: return default
proc getJson*(
proc `[]`*(cfg: CombinedConfig, key: string): string = getVal(cfg, key)
proc getJsonValueAndProvenance*(
cfg: CombinedConfig, cfg: CombinedConfig,
key: string key: string): tuple[value: JsonNode, provenance: ValueProvenance]
): JsonNode {.raises: [KeyError, ValueError, IOError, OSError]} = {.raises: [KeyError, ValueError, IOError, OSError]} =
let (argKey, envKey, jsonKey) = keyNames(key) let (argKey, envKey, jsonKey) = keyNames(key)
try: try:
if cfg.docopt.contains(argKey) and cfg.docopt[argKey]: return parseJson($cfg.docopt[argKey]) if cfg.docopt.contains(argKey) and cfg.docopt[argKey]:
elif existsEnv(envKey): return parseJson(getEnv(envKey)) return (parseJson($cfg.docopt[argKey]), ValueProvenance.arguments)
elif cfg.json.hasKey(jsonKey): return cfg.json[jsonKey] elif existsEnv(envKey):
return (parseJson(getEnv(envKey)), ValueProvenance.environment)
elif cfg.json.hasKey(jsonKey):
return (cfg.json[jsonKey], ValueProvenance.json)
else: raise newException(ValueError, "cannot find a configuration value for \"" & key & "\"") else: raise newException(ValueError, "cannot find a configuration value for \"" & key & "\"")
except Exception: except Exception:
raise newException(ValueError, "cannot parse [" & getVal(cfg, key) & "] as JSON:" & getCurrentExceptionMsg()) raise newException(ValueError, "cannot parse [" & getVal(cfg, key) & "] as JSON:" & getCurrentExceptionMsg())
proc getJsonValueAndProvenance*(
cfg: CombinedConfig,
key: string,
default: JsonNode): tuple[value: JsonNode, provenance: ValueProvenance]
{.raises: [].} =
try: return getJsonValueAndProvenance(cfg, key)
except CatchableError: return (default, ValueProvenance.default)
proc getJson*(
cfg: CombinedConfig,
key: string
): JsonNode {.raises: [KeyError, ValueError, IOError, OSError]} =
getJsonValueAndProvenance(cfg, key).value
proc getJson*( proc getJson*(
cfg: CombinedConfig, cfg: CombinedConfig,
key: string, key: string,
@@ -106,6 +190,7 @@ proc getJson*(
try: return getJson(cfg, key) try: return getJson(cfg, key)
except CatchableError: return default except CatchableError: return default
proc hasKey*(cfg: CombinedConfig, key: string): bool = proc hasKey*(cfg: CombinedConfig, key: string): bool =
let (argKey, envKey, jsonKey) = keyNames(key) let (argKey, envKey, jsonKey) = keyNames(key)
@@ -114,6 +199,10 @@ proc hasKey*(cfg: CombinedConfig, key: string): bool =
existsEnv(envKey) or existsEnv(envKey) or
cfg.json.hasKey(jsonKey) cfg.json.hasKey(jsonKey)
proc contains*(cfg: CombinedConfig, key: string): bool = hasKey(cfg, key)
proc loadEnv*(): StringTableRef = proc loadEnv*(): StringTableRef =
result = newStringTable() result = newStringTable()