diff --git a/cliutils.nimble b/cliutils.nimble index bc600d2..11bde46 100644 --- a/cliutils.nimble +++ b/cliutils.nimble @@ -1,6 +1,6 @@ # Package -version = "0.11.1" +version = "0.12.0" author = "Jonathan Bernard" description = "Helper functions for writing command line interfaces." license = "MIT" @@ -8,4 +8,4 @@ license = "MIT" # Dependencies -requires @["nim >= 1.6.0", "docopt >= 0.6.8"] +requires @["nim >= 1.6.0", "docopt >= 0.6.8", "regex", "identcasing"] diff --git a/cliutils/config.nim b/cliutils/config.nim index 0c0a660..9f0da2b 100644 --- a/cliutils/config.nim +++ b/cliutils/config.nim @@ -1,19 +1,56 @@ -import std/[json, nre, os, sequtils, strtabs, strutils] -import docopt +import std/[json, os, sequtils, strtabs, strutils] +import docopt, identcasing, regex type CombinedConfig* = object docopt*: Table[string, Value] 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: - result = ( - "--" & key, - key.replace('-', '_').toUpper, - key.replace(re"(-\w)", proc (m: RegexMatch): string = ($m)[1..1].toUpper)) + if match(key, argStyleRegex): + let unprefixed = + if key.startsWith("--"): key[2..^1] + 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: - raise newException(KeyError, "invalid config key: '" & key & "'") + raise newException(KeyError, "invalid config key '" & key & "': " & + getCurrentExceptionMsg()) + template walkFieldDefs*(t: NimNode, body: untyped) = let tTypeImpl = t.getTypeImpl @@ -51,6 +88,7 @@ proc initCombinedConfig*( result = CombinedConfig(docopt: docopt, json: parseFile(filename)) + proc findConfigFile*(name: string, otherLocations: seq[string] = @[]): string = let cfgLocations = @[ $getEnv(name.strip(chars = {'.'}).toUpper()), @@ -61,42 +99,88 @@ proc findConfigFile*(name: string, otherLocations: seq[string] = @[]): string = if result == "" or not fileExists(result): 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) - if cfg.docopt.contains(argKey) and cfg.docopt[argKey]: return $cfg.docopt[argKey] - elif existsEnv(envKey): return getEnv(envKey) + if cfg.docopt.contains(argKey) and cfg.docopt[argKey]: + return ($cfg.docopt[argKey], ValueProvenance.arguments) + elif existsEnv(envKey): + return (getEnv(envKey), ValueProvenance.environment) elif cfg.json.hasKey(jsonKey): let node = cfg.json[jsonKey] - case node.kind - of JString: return node.getStr - of JInt: return $node.getInt - of JFloat: return $node.getFloat - of JBool: return $node.getBool - of JNull: return "" - of JObject: return $node - of JArray: return $node + let value = + case node.kind + of JString: node.getStr + of JInt: $node.getInt + of JFloat: $node.getFloat + of JBool: $node.getBool + of JNull: "" + of JObject: $node + of JArray: $node + return (value, ValueProvenance.json) 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: [].} = try: return getVal(cfg, key) except CatchableError: return default -proc getJson*( + +proc `[]`*(cfg: CombinedConfig, key: string): string = getVal(cfg, key) + + +proc getJsonValueAndProvenance*( cfg: CombinedConfig, - key: string - ): JsonNode {.raises: [KeyError, ValueError, IOError, OSError]} = + key: string): tuple[value: JsonNode, provenance: ValueProvenance] + {.raises: [KeyError, ValueError, IOError, OSError]} = let (argKey, envKey, jsonKey) = keyNames(key) try: - if cfg.docopt.contains(argKey) and cfg.docopt[argKey]: return parseJson($cfg.docopt[argKey]) - elif existsEnv(envKey): return parseJson(getEnv(envKey)) - elif cfg.json.hasKey(jsonKey): return cfg.json[jsonKey] + if cfg.docopt.contains(argKey) and cfg.docopt[argKey]: + return (parseJson($cfg.docopt[argKey]), ValueProvenance.arguments) + 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 & "\"") except Exception: 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*( cfg: CombinedConfig, key: string, @@ -106,6 +190,7 @@ proc getJson*( try: return getJson(cfg, key) except CatchableError: return default + proc hasKey*(cfg: CombinedConfig, key: string): bool = let (argKey, envKey, jsonKey) = keyNames(key) @@ -114,6 +199,10 @@ proc hasKey*(cfg: CombinedConfig, key: string): bool = existsEnv(envKey) or cfg.json.hasKey(jsonKey) + +proc contains*(cfg: CombinedConfig, key: string): bool = hasKey(cfg, key) + + proc loadEnv*(): StringTableRef = result = newStringTable()