j126: read bot settings from a .env file (default ./.env, --env-file flag, TR_ENV_FILE); file wins over shell leftovers, boot report labels (source: .env)
This commit is contained in:
@@ -22,6 +22,9 @@ __pycache__/
|
|||||||
devbox.json
|
devbox.json
|
||||||
devbox.lock
|
devbox.lock
|
||||||
|
|
||||||
|
# Local settings file (explicit path only - never a wildcard pattern)
|
||||||
|
ModularBot_garage/.env
|
||||||
|
|
||||||
# Compiled bot binaries — all builds go to *_garage/out/
|
# Compiled bot binaries — all builds go to *_garage/out/
|
||||||
*_garage/out/
|
*_garage/out/
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Copy this file to `.env` (same folder as the bot) and edit what you need.
|
||||||
|
# The bot reads `.env` automatically when you start it from its folder.
|
||||||
|
# To use a different file: ./out/ModularBot --env-file /path/to/my.env
|
||||||
|
#
|
||||||
|
# The file WINS over variables you exported in the shell. If a shell value
|
||||||
|
# differs, the bot prints a warning line so you know it was overridden.
|
||||||
|
#
|
||||||
|
# Everything is optional. Leave a line out (or commented, with #) to keep the
|
||||||
|
# built-in default. Values shown are the defaults.
|
||||||
|
|
||||||
|
# ── movement ─────────────────────────────────────────────────────────────────
|
||||||
|
# Which dodging engine to run: strafe (default), tfil, tfil_ring, surf.
|
||||||
|
TR_MOVEMENT=strafe
|
||||||
|
|
||||||
|
# ── gun rack: which guns the bot may choose (off | 1v1 | melee | both) ───────
|
||||||
|
# The shipped default is Pattern only. Turn a gun on with `both`.
|
||||||
|
TR_RACK_PATTERN=both
|
||||||
|
#TR_RACK_TMHORIZON=both
|
||||||
|
#TR_RACK_HEADON=both
|
||||||
|
|
||||||
|
# Drop whole guns by number (comma separated ids; empty = keep all).
|
||||||
|
GUN_RACK_DISABLE=
|
||||||
|
|
||||||
|
# ── power / energy policy ────────────────────────────────────────────────────
|
||||||
|
# 1 = use the energy-aware power cap (default). 0 = no cap (for experiments).
|
||||||
|
TR_POWER_POLICY=1
|
||||||
|
# Lowest cap when our own energy is low. NOTE: this is a CEILING, not a floor.
|
||||||
|
TR_POWER_ENERGY_MIN=0.5
|
||||||
|
|
||||||
|
# ── logs (set the value to 1; presence alone turns some of them on) ──────────
|
||||||
|
# Print one line per power decision.
|
||||||
|
#TR_POWER_LOG=1
|
||||||
|
# Print one line per ram start/stop and why.
|
||||||
|
#TR_RAM_LOG=1
|
||||||
|
# Print movement band/class changes.
|
||||||
|
#TR_MOVEMENT_LOG=1
|
||||||
|
# Print the per-shot thinking of the TM horizon gun.
|
||||||
|
#TR_TMHORIZON_LOG=1
|
||||||
|
# Print one line per round result.
|
||||||
|
TR_RESULT_LOG=1
|
||||||
|
|
||||||
|
# Where the per-round gun stats and the per-shot log are written.
|
||||||
|
GUN_STATS_PATH=/tmp/gun_stats.jsonl
|
||||||
|
GUN_SHOTLOG_PATH=/tmp/shot_log.jsonl
|
||||||
|
|
||||||
|
# ── measurement helpers (leave off unless you are measuring) ─────────────────
|
||||||
|
#TR_RECORD_WORLDSTATE=1
|
||||||
|
#TR_RADAR_SCANLOG=1
|
||||||
|
#TR_TRACKER_PROBE=1
|
||||||
|
|
||||||
|
# ── boot report ──────────────────────────────────────────────────────────────
|
||||||
|
# 1 = print the [env] report on startup (default). 0 = do not print it.
|
||||||
|
TR_ENV_REPORT=1
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
exec ./out/ModularBot >> /tmp/modularbot_stdout.log 2>> /tmp/modularbot_stderr.log
|
exec ./out/ModularBot "$@" >> /tmp/modularbot_stdout.log 2>> /tmp/modularbot_stderr.log
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
exec ./out/ModularBot >> /tmp/modularbot_stdout.log 2>> /tmp/modularbot_stderr.log
|
exec ./out/ModularBot "$@" >> /tmp/modularbot_stdout.log 2>> /tmp/modularbot_stderr.log
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
## Radar: RadarLockModule (1v1) / AdaptiveMeleeRadarModule (2+ enemies), auto-switched per tick.
|
## Radar: RadarLockModule (1v1) / AdaptiveMeleeRadarModule (2+ enemies), auto-switched per tick.
|
||||||
## Movement: OscillatorModule (perpendicular strafing).
|
## Movement: OscillatorModule (perpendicular strafing).
|
||||||
|
|
||||||
|
import env_boot # MUST be first: applies the .env file before any other
|
||||||
|
# module's top-level `let` reads the environment.
|
||||||
import std/[math, os, strformat, tables, sets, json, random, strutils]
|
import std/[math, os, strformat, tables, sets, json, random, strutils]
|
||||||
import robocode_tankroyale_botapi
|
import robocode_tankroyale_botapi
|
||||||
import radar_harness/radar_interface
|
import radar_harness/radar_interface
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
## Imported FIRST by ModularBot.nim, purely for the module-init side effect
|
||||||
|
## below. Nim runs an imported module's top-level code before the importer's,
|
||||||
|
## and sibling imports in the order they are listed, so:
|
||||||
|
##
|
||||||
|
## import env_boot # <-- .env applied HERE
|
||||||
|
## import robocode_tankroyale_botapi
|
||||||
|
## import movements/... # <-- their top-level `let`s now see the file
|
||||||
|
##
|
||||||
|
## That ordering is the whole point: it is what lets every existing env reader
|
||||||
|
## in every other module keep working unchanged. Do not reorder this import.
|
||||||
|
|
||||||
|
import env_dotenv
|
||||||
|
|
||||||
|
bootDotEnv()
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
## .env file support for the bot.
|
||||||
|
##
|
||||||
|
## Why: a dozen TR_* variables were exported in the shell that launched the
|
||||||
|
## server/GUI, and stale leftovers were repeatedly mistaken for live settings
|
||||||
|
## (e.g. a forgotten TR_RACK_TMHORIZON). This module lets those settings live in
|
||||||
|
## a file instead — and the FILE WINS over a leftover shell export.
|
||||||
|
##
|
||||||
|
## Values are applied with putEnv() before anything else reads the environment,
|
||||||
|
## so every existing reader (getEnv, existsEnv, the envInt/envFloat/envBool
|
||||||
|
## helpers, the PRESENCE-based knobs like TR_POWER_LOG) keeps working unchanged.
|
||||||
|
##
|
||||||
|
## Resolution order (first hit wins):
|
||||||
|
## 1. --env-file <path> (also --env-file=<path>)
|
||||||
|
## 2. TR_ENV_FILE
|
||||||
|
## 3. ./.env in the current working directory (the bot root when launched
|
||||||
|
## from the bot dir, which the launcher scripts do)
|
||||||
|
## 4. .env next to the executable
|
||||||
|
## 5. none
|
||||||
|
##
|
||||||
|
## An EXPLICIT request (1 or 2) that names a missing file is a hard error: an
|
||||||
|
## explicit request that silently does nothing is exactly the bug this fixes.
|
||||||
|
## A missing default file (3/4) is normal and silent.
|
||||||
|
##
|
||||||
|
## The boot report (env_report.nim) reads the resolved path/source and the set
|
||||||
|
## of keys that came from the file, so it can label values `(source: .env)`.
|
||||||
|
|
||||||
|
import std/[os, strutils, sets]
|
||||||
|
|
||||||
|
const EnvFileFlag* = "--env-file"
|
||||||
|
const EnvFileEnvVar* = "TR_ENV_FILE"
|
||||||
|
const DefaultEnvFile* = ".env"
|
||||||
|
|
||||||
|
type
|
||||||
|
EnvFileError* = object of CatchableError
|
||||||
|
## Raised when an explicitly requested env file cannot be used.
|
||||||
|
|
||||||
|
EnvEntry* = object
|
||||||
|
key*: string
|
||||||
|
value*: string
|
||||||
|
|
||||||
|
EnvConflict* = object
|
||||||
|
## A key present in BOTH the file and the real environment with different
|
||||||
|
## values. The file value is the one applied; `shellValue` is what it
|
||||||
|
## replaced (the stale leftover that used to win by accident).
|
||||||
|
key*: string
|
||||||
|
fileValue*: string
|
||||||
|
shellValue*: string
|
||||||
|
|
||||||
|
EnvFileChoice* = object
|
||||||
|
path*: string ## resolved path; "" means "no file"
|
||||||
|
source*: string ## "flag" | "TR_ENV_FILE" | "cwd" | "exe" | "none"
|
||||||
|
explicit*: bool ## requested via flag/env var (missing => hard error)
|
||||||
|
|
||||||
|
# ── resolved state (read by the boot report) ─────────────────────────────────
|
||||||
|
|
||||||
|
var
|
||||||
|
gEnvFilePath = ""
|
||||||
|
gEnvFileSource = "none"
|
||||||
|
gEnvFileKeys: HashSet[string]
|
||||||
|
gEnvFileLoaded = false
|
||||||
|
|
||||||
|
proc envFilePath*(): string = gEnvFilePath
|
||||||
|
proc envFileSource*(): string = gEnvFileSource
|
||||||
|
proc isFromEnvFile*(key: string): bool = key in gEnvFileKeys
|
||||||
|
|
||||||
|
# ── CLI parsing ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc flagEnvFilePath*(params: openArray[string] = commandLineParams()): string =
|
||||||
|
## The value of `--env-file` / `--env-file=`, or "" when absent. Raises when
|
||||||
|
## `--env-file` is the last argument (a request we cannot honour).
|
||||||
|
var i = 0
|
||||||
|
while i < params.len:
|
||||||
|
let p = params[i]
|
||||||
|
if p == EnvFileFlag:
|
||||||
|
if i + 1 < params.len:
|
||||||
|
return params[i + 1]
|
||||||
|
raise newException(EnvFileError, EnvFileFlag & " needs a path argument")
|
||||||
|
elif p.startsWith(EnvFileFlag & "="):
|
||||||
|
return p[EnvFileFlag.len + 1 .. ^1]
|
||||||
|
inc i
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# ── resolution ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc chooseEnvFile*(flagValue, envValue, cwdCandidate, exeCandidate: string): EnvFileChoice =
|
||||||
|
## Pure resolution: flag, then TR_ENV_FILE, then `./.env`, then the `.env`
|
||||||
|
## next to the executable, then none.
|
||||||
|
if flagValue.len > 0:
|
||||||
|
return EnvFileChoice(path: flagValue, source: "flag", explicit: true)
|
||||||
|
if envValue.len > 0:
|
||||||
|
return EnvFileChoice(path: envValue, source: EnvFileEnvVar, explicit: true)
|
||||||
|
if fileExists(cwdCandidate):
|
||||||
|
return EnvFileChoice(path: cwdCandidate, source: "cwd", explicit: false)
|
||||||
|
if fileExists(exeCandidate):
|
||||||
|
return EnvFileChoice(path: exeCandidate, source: "exe", explicit: false)
|
||||||
|
return EnvFileChoice(path: "", source: "none", explicit: false)
|
||||||
|
|
||||||
|
# ── parsing ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc parseEnvFileContent*(content, filename: string): seq[EnvEntry] =
|
||||||
|
## Parse the usual .env shape: one KEY=VALUE per line, blank lines and `#`
|
||||||
|
## comments skipped, a leading `export ` tolerated, surrounding single or
|
||||||
|
## double quotes stripped, whitespace trimmed, an empty VALUE allowed.
|
||||||
|
##
|
||||||
|
## A malformed line RAISES with the file name and line number — it is never
|
||||||
|
## silently ignored, because a typo that does nothing is the failure mode we
|
||||||
|
## are fixing.
|
||||||
|
var lineno = 0
|
||||||
|
for raw in content.splitLines():
|
||||||
|
inc lineno
|
||||||
|
var line = raw
|
||||||
|
if line.len > 0 and line[^1] == '\r': line.setLen(line.len - 1)
|
||||||
|
let stripped = line.strip()
|
||||||
|
if stripped.len == 0 or stripped[0] == '#': continue
|
||||||
|
var body = stripped
|
||||||
|
if body.startsWith("export") and (body.len == 6 or body[6] in Whitespace):
|
||||||
|
body = body[6 .. ^1].strip()
|
||||||
|
let eq = body.find('=')
|
||||||
|
if eq < 0:
|
||||||
|
raise newException(ValueError,
|
||||||
|
filename & ":" & $lineno & ": expected KEY=VALUE, got: " & stripped)
|
||||||
|
let key = body[0 ..< eq].strip()
|
||||||
|
if key.len == 0:
|
||||||
|
raise newException(ValueError,
|
||||||
|
filename & ":" & $lineno & ": empty key in: " & stripped)
|
||||||
|
var value = body[eq + 1 .. ^1].strip()
|
||||||
|
if value.len >= 2 and
|
||||||
|
((value[0] == '"' and value[^1] == '"') or
|
||||||
|
(value[0] == '\'' and value[^1] == '\'')):
|
||||||
|
value = value[1 ..< value.len - 1]
|
||||||
|
result.add EnvEntry(key: key, value: value)
|
||||||
|
|
||||||
|
# ── applying ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc applyEnvFile*(path: string): seq[EnvConflict] =
|
||||||
|
## Read `path`, apply every entry with putEnv (the FILE WINS over the real
|
||||||
|
## environment), remember the from-file keys, and return the keys whose file
|
||||||
|
## value replaced a DIFFERENT exported value. Raises on a read error or a
|
||||||
|
## malformed line.
|
||||||
|
let entries = parseEnvFileContent(readFile(path), path)
|
||||||
|
for e in entries:
|
||||||
|
if existsEnv(e.key):
|
||||||
|
let shell = getEnv(e.key)
|
||||||
|
if shell != e.value:
|
||||||
|
result.add EnvConflict(key: e.key, fileValue: e.value, shellValue: shell)
|
||||||
|
putEnv(e.key, e.value)
|
||||||
|
gEnvFileKeys.incl e.key
|
||||||
|
|
||||||
|
proc loadEnvFile*(choice: EnvFileChoice): seq[EnvConflict] =
|
||||||
|
## Apply the chosen file. A missing EXPLICIT file raises EnvFileError; a
|
||||||
|
## missing default file is a silent no-op. No file at all is a silent no-op.
|
||||||
|
if choice.path.len == 0: return @[]
|
||||||
|
if not fileExists(choice.path):
|
||||||
|
if choice.explicit:
|
||||||
|
raise newException(EnvFileError, "env file not found: " & choice.path)
|
||||||
|
return @[]
|
||||||
|
result = applyEnvFile(choice.path)
|
||||||
|
|
||||||
|
proc bootDotEnv*() =
|
||||||
|
## Resolve and apply the .env file exactly once. Called at process start by
|
||||||
|
## env_boot.nim (imported first by ModularBot.nim) so every later module-level
|
||||||
|
## read sees the file values.
|
||||||
|
if gEnvFileLoaded: return
|
||||||
|
gEnvFileLoaded = true
|
||||||
|
|
||||||
|
var flag = ""
|
||||||
|
try:
|
||||||
|
flag = flagEnvFilePath()
|
||||||
|
except EnvFileError as e:
|
||||||
|
stderr.writeLine "[dotenv] ERROR: " & e.msg
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
let envValue = if existsEnv(EnvFileEnvVar): getEnv(EnvFileEnvVar).strip() else: ""
|
||||||
|
let choice = chooseEnvFile(flag, envValue,
|
||||||
|
getCurrentDir() / DefaultEnvFile,
|
||||||
|
getAppDir() / DefaultEnvFile)
|
||||||
|
|
||||||
|
if choice.path.len == 0:
|
||||||
|
gEnvFilePath = ""
|
||||||
|
gEnvFileSource = "none"
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
let conflicts = loadEnvFile(choice)
|
||||||
|
gEnvFilePath = choice.path
|
||||||
|
gEnvFileSource = choice.source
|
||||||
|
for c in conflicts:
|
||||||
|
stderr.writeLine "[dotenv] " & c.key & " = " & c.fileValue &
|
||||||
|
" (from " & choice.path & ") overrides the exported value " & c.shellValue
|
||||||
|
except EnvFileError as e:
|
||||||
|
stderr.writeLine "[dotenv] ERROR: " & e.msg
|
||||||
|
stderr.writeLine "[dotenv] it was requested explicitly (--env-file or " &
|
||||||
|
EnvFileEnvVar & "); refusing to start with the request silently ignored."
|
||||||
|
quit(1)
|
||||||
|
except ValueError as e:
|
||||||
|
stderr.writeLine "[dotenv] ERROR: " & e.msg
|
||||||
|
quit(1)
|
||||||
|
except CatchableError as e:
|
||||||
|
stderr.writeLine "[dotenv] ERROR: cannot use env file " & choice.path &
|
||||||
|
": " & e.msg
|
||||||
|
quit(1)
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
## printed, because unrelated variables may hold secrets or machine paths.
|
## printed, because unrelated variables may hold secrets or machine paths.
|
||||||
|
|
||||||
import std/[os, strutils, algorithm, times, sets]
|
import std/[os, strutils, algorithm, times, sets]
|
||||||
|
import env_dotenv
|
||||||
import gun_harness/virtual_bullets
|
import gun_harness/virtual_bullets
|
||||||
import gun_harness/selector
|
import gun_harness/selector
|
||||||
import movements/ram_decision
|
import movements/ram_decision
|
||||||
@@ -89,13 +90,19 @@ proc tieBreakName(t: TieBreakMode): string =
|
|||||||
proc sourceOf(name: string): string =
|
proc sourceOf(name: string): string =
|
||||||
## Parsed-value knobs go through `getEnv(name, "")`; an empty value means the
|
## Parsed-value knobs go through `getEnv(name, "")`; an empty value means the
|
||||||
## shipped default, so a non-empty value is the only thing that counts as
|
## shipped default, so a non-empty value is the only thing that counts as
|
||||||
## "from the env".
|
## "from the env". A value that came from the resolved .env file is labelled
|
||||||
if getEnv(name, "").len > 0: "env" else: "default"
|
## `.env` even though the file applied it with putEnv (so `getEnv` finds it).
|
||||||
|
if isFromEnvFile(name): ".env"
|
||||||
|
elif getEnv(name, "").len > 0: "env"
|
||||||
|
else: "default"
|
||||||
|
|
||||||
proc sourceOfPresence(name: string): string =
|
proc sourceOfPresence(name: string): string =
|
||||||
## Flags read with `existsEnv` are "from the env" whenever they are present,
|
## Flags read with `existsEnv` are "from the env" whenever they are present,
|
||||||
## even if their value is "0" (presence is what the code tests).
|
## even if their value is "0" (presence is what the code tests). A value that
|
||||||
if existsEnv(name): "env" else: "default"
|
## came from the .env file is labelled `.env`.
|
||||||
|
if isFromEnvFile(name): ".env"
|
||||||
|
elif existsEnv(name): "env"
|
||||||
|
else: "default"
|
||||||
|
|
||||||
proc disabledGunsString(guns: HashSet[int]): string =
|
proc disabledGunsString(guns: HashSet[int]): string =
|
||||||
if guns.len == 0: return "none"
|
if guns.len == 0: return "none"
|
||||||
@@ -461,6 +468,8 @@ proc knownEnvNames*(): seq[string] =
|
|||||||
"TR_TRACKER_PROBE", "TR_TRACKER_PROBE_PATH", "TR_VBULLET_ADMIT_ONLY",
|
"TR_TRACKER_PROBE", "TR_TRACKER_PROBE_PATH", "TR_VBULLET_ADMIT_ONLY",
|
||||||
"TR_POWER_LOG",
|
"TR_POWER_LOG",
|
||||||
"TR_RESULT_LOG",
|
"TR_RESULT_LOG",
|
||||||
|
# the .env loader's own environment hook (TR_ENV_FILE names the file)
|
||||||
|
"TR_ENV_FILE",
|
||||||
"TR_RAM_OPPORTUNITY", "TR_RAM_OPP_DIST", "TR_RAM_OPP_MARGIN",
|
"TR_RAM_OPPORTUNITY", "TR_RAM_OPP_DIST", "TR_RAM_OPP_MARGIN",
|
||||||
"TR_RAM_ABORT_DMG", "TR_RAM_PLAN", "TR_RAM_PLAN_DIST",
|
"TR_RAM_ABORT_DMG", "TR_RAM_PLAN", "TR_RAM_PLAN_DIST",
|
||||||
"TR_RAM_PLAN_MARGIN", "TR_RAM_PLAN_HITRATE", "TR_RAM_LOG",
|
"TR_RAM_PLAN_MARGIN", "TR_RAM_PLAN_HITRATE", "TR_RAM_LOG",
|
||||||
@@ -609,6 +618,9 @@ proc printEnvReport*(ctx: EnvReportContext) =
|
|||||||
return
|
return
|
||||||
|
|
||||||
echo "[env] === ENVIRONMENT (boot report) ==="
|
echo "[env] === ENVIRONMENT (boot report) ==="
|
||||||
|
echo "[env] env file: ",
|
||||||
|
(if envFilePath().len > 0: envFilePath() & " (source: " & envFileSource() & ")"
|
||||||
|
else: "none")
|
||||||
printRawEnvironment()
|
printRawEnvironment()
|
||||||
printMistakeWarnings()
|
printMistakeWarnings()
|
||||||
printEffectiveValues(ctx)
|
printEffectiveValues(ctx)
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
## Guard test for the .env loader (the module the bot uses to read its
|
||||||
|
## settings from a `.env` file).
|
||||||
|
##
|
||||||
|
## NO battle, NO Java, NO server. Run with:
|
||||||
|
## nim c -r --path:common_libs common_libs/tests/test_env_dotenv.nim
|
||||||
|
##
|
||||||
|
## It pins the pieces that must not silently drift:
|
||||||
|
## * the parser (comments, `export `, quotes, empty value, CRLF, and a
|
||||||
|
## malformed line that MUST raise with the file+line);
|
||||||
|
## * resolution precedence (flag > TR_ENV_FILE > ./.env > .env next to the
|
||||||
|
## executable > none);
|
||||||
|
## * the FILE WINS over the real environment, and the conflict is reported;
|
||||||
|
## * an explicitly requested missing file raises (the caller then exits
|
||||||
|
## nonzero), while a missing DEFAULT file is a silent no-op.
|
||||||
|
|
||||||
|
import std/[os, strutils]
|
||||||
|
import "../../ModularBot_garage/src/env_dotenv"
|
||||||
|
|
||||||
|
var failures = 0
|
||||||
|
var checks = 0
|
||||||
|
|
||||||
|
proc check(name: string, ok: bool) =
|
||||||
|
inc checks
|
||||||
|
if ok: echo "PASS: ", name
|
||||||
|
else: echo "FAIL: ", name; inc failures
|
||||||
|
|
||||||
|
let tmpDir = getTempDir() / "test_env_dotenv_guard"
|
||||||
|
createDir(tmpDir)
|
||||||
|
|
||||||
|
proc tmpPath(name: string): string = tmpDir / name
|
||||||
|
|
||||||
|
proc writeTmp(name, content: string): string =
|
||||||
|
result = tmpPath(name)
|
||||||
|
writeFile(result, content)
|
||||||
|
|
||||||
|
# ── 1. the parser ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc testParser() =
|
||||||
|
let entries = parseEnvFileContent("""# a comment, ignored
|
||||||
|
|
||||||
|
TR_MOVEMENT=tfil
|
||||||
|
export TR_RACK_TMHORIZON=both
|
||||||
|
QUOTED="hello world"
|
||||||
|
SINGLE='x y'
|
||||||
|
EMPTY=
|
||||||
|
SPACED = trimmed
|
||||||
|
""", "sample.env")
|
||||||
|
check "comment + blank lines are skipped (6 entries)", entries.len == 6
|
||||||
|
if entries.len == 6:
|
||||||
|
check "plain KEY=VALUE", entries[0].key == "TR_MOVEMENT" and entries[0].value == "tfil"
|
||||||
|
check "leading `export ` is tolerated",
|
||||||
|
entries[1].key == "TR_RACK_TMHORIZON" and entries[1].value == "both"
|
||||||
|
check "double quotes are stripped",
|
||||||
|
entries[2].key == "QUOTED" and entries[2].value == "hello world"
|
||||||
|
check "single quotes are stripped",
|
||||||
|
entries[3].key == "SINGLE" and entries[3].value == "x y"
|
||||||
|
check "an empty value is allowed",
|
||||||
|
entries[4].key == "EMPTY" and entries[4].value == ""
|
||||||
|
check "whitespace around key and value is trimmed",
|
||||||
|
entries[5].key == "SPACED" and entries[5].value == "trimmed"
|
||||||
|
|
||||||
|
let crlf = parseEnvFileContent("A=1\r\nB=two\r\n", "crlf.env")
|
||||||
|
check "CRLF line endings are stripped from the value",
|
||||||
|
crlf.len == 2 and crlf[0].value == "1" and crlf[1].value == "two"
|
||||||
|
|
||||||
|
var raised = false
|
||||||
|
try:
|
||||||
|
discard parseEnvFileContent("this line has no equals sign\n", "bad.env")
|
||||||
|
except ValueError as e:
|
||||||
|
raised = true
|
||||||
|
check "malformed line error names the file", "bad.env" in e.msg
|
||||||
|
check "malformed line error names the line number", ":1:" in e.msg
|
||||||
|
check "malformed line RAISES (never silently ignored)", raised
|
||||||
|
|
||||||
|
var emptyKeyRaised = false
|
||||||
|
try:
|
||||||
|
discard parseEnvFileContent("=value\n", "bad2.env")
|
||||||
|
except ValueError:
|
||||||
|
emptyKeyRaised = true
|
||||||
|
check "an empty key raises", emptyKeyRaised
|
||||||
|
|
||||||
|
# ── 2. resolution precedence ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc testResolution() =
|
||||||
|
let flagChoice = chooseEnvFile("/x/flag.env", "/x/tr.env",
|
||||||
|
"/cwd/.env", "/exe/.env")
|
||||||
|
check "flag beats TR_ENV_FILE and the defaults",
|
||||||
|
flagChoice.source == "flag" and flagChoice.path == "/x/flag.env" and
|
||||||
|
flagChoice.explicit
|
||||||
|
|
||||||
|
let envChoice = chooseEnvFile("", "/x/tr.env", "/cwd/.env", "/exe/.env")
|
||||||
|
check "TR_ENV_FILE beats the default paths",
|
||||||
|
envChoice.source == EnvFileEnvVar and envChoice.path == "/x/tr.env" and
|
||||||
|
envChoice.explicit
|
||||||
|
|
||||||
|
let cwdFile = writeTmp("cwd.env", "A=1\n")
|
||||||
|
let exeFile = writeTmp("exe.env", "A=2\n")
|
||||||
|
let cwdChoice = chooseEnvFile("", "", cwdFile, exeFile)
|
||||||
|
check "./.env (cwd) is chosen when it exists and is not explicit",
|
||||||
|
cwdChoice.source == "cwd" and cwdChoice.path == cwdFile and
|
||||||
|
not cwdChoice.explicit
|
||||||
|
|
||||||
|
let exeChoice = chooseEnvFile("", "", tmpPath("missing_cwd.env"), exeFile)
|
||||||
|
check ".env next to the executable is the fallback",
|
||||||
|
exeChoice.source == "exe" and exeChoice.path == exeFile
|
||||||
|
|
||||||
|
let noneChoice = chooseEnvFile("", "", tmpPath("missing_cwd.env"),
|
||||||
|
tmpPath("missing_exe.env"))
|
||||||
|
check "no candidate exists -> none (silent)",
|
||||||
|
noneChoice.path == "" and noneChoice.source == "none" and
|
||||||
|
not noneChoice.explicit
|
||||||
|
|
||||||
|
check "--env-file <path> is parsed",
|
||||||
|
flagEnvFilePath(@["--server", "x", "--env-file", "/a/b.env"]) == "/a/b.env"
|
||||||
|
check "--env-file=<path> is parsed",
|
||||||
|
flagEnvFilePath(@["--env-file=/a/b.env"]) == "/a/b.env"
|
||||||
|
check "no --env-file yields empty", flagEnvFilePath(@["--server", "x"]) == ""
|
||||||
|
var raised = false
|
||||||
|
try:
|
||||||
|
discard flagEnvFilePath(@["--env-file"])
|
||||||
|
except EnvFileError:
|
||||||
|
raised = true
|
||||||
|
check "--env-file with no argument raises", raised
|
||||||
|
|
||||||
|
# ── 3. the file wins over the real environment ───────────────────────────────
|
||||||
|
|
||||||
|
proc testFileWins() =
|
||||||
|
let f = writeTmp("wins.env", "TR_TEST_J126=from-file\nTR_TEST_SAME_J126=same\n")
|
||||||
|
putEnv("TR_TEST_J126", "from-shell")
|
||||||
|
putEnv("TR_TEST_SAME_J126", "same")
|
||||||
|
let conflicts = applyEnvFile(f)
|
||||||
|
check "the file value WINS over the exported value",
|
||||||
|
getEnv("TR_TEST_J126") == "from-file"
|
||||||
|
check "the conflict is reported with key, file value and shell value",
|
||||||
|
conflicts.len == 1 and conflicts[0].key == "TR_TEST_J126" and
|
||||||
|
conflicts[0].fileValue == "from-file" and
|
||||||
|
conflicts[0].shellValue == "from-shell"
|
||||||
|
check "an identical value is NOT a conflict",
|
||||||
|
conflicts.len == 1 # TR_TEST_SAME_J126 is absent from conflicts
|
||||||
|
check "keys from the file are tracked for the boot report",
|
||||||
|
isFromEnvFile("TR_TEST_J126") and isFromEnvFile("TR_TEST_SAME_J126")
|
||||||
|
delEnv("TR_TEST_J126")
|
||||||
|
delEnv("TR_TEST_SAME_J126")
|
||||||
|
|
||||||
|
# ── 4. missing explicit file vs missing default file ─────────────────────────
|
||||||
|
|
||||||
|
proc testMissingFiles() =
|
||||||
|
var raised = false
|
||||||
|
try:
|
||||||
|
discard loadEnvFile(EnvFileChoice(path: tmpPath("nope.env"),
|
||||||
|
source: "flag", explicit: true))
|
||||||
|
except EnvFileError as e:
|
||||||
|
raised = true
|
||||||
|
check "missing explicit file error names the path", "nope.env" in e.msg
|
||||||
|
check "missing explicit file RAISES (caller exits nonzero)", raised
|
||||||
|
|
||||||
|
var quiet = true
|
||||||
|
var n = 0
|
||||||
|
try:
|
||||||
|
n = loadEnvFile(EnvFileChoice(path: tmpPath("missing-default.env"),
|
||||||
|
source: "cwd", explicit: false)).len
|
||||||
|
except CatchableError:
|
||||||
|
quiet = false
|
||||||
|
check "missing default file is a silent no-op", quiet and n == 0
|
||||||
|
|
||||||
|
check "no file at all is a silent no-op",
|
||||||
|
loadEnvFile(EnvFileChoice(path: "", source: "none", explicit: false)).len == 0
|
||||||
|
|
||||||
|
# ── driver ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
testParser()
|
||||||
|
testResolution()
|
||||||
|
testFileWins()
|
||||||
|
testMissingFiles()
|
||||||
|
|
||||||
|
echo "\n", checks, " checks, ", failures, " failure(s)"
|
||||||
|
if failures > 0:
|
||||||
|
echo checks, " check(s) run, ", failures, " FAILED"
|
||||||
|
quit(1)
|
||||||
|
echo "All env-dotenv checks passed."
|
||||||
@@ -1,5 +1,29 @@
|
|||||||
# Environment variable reference
|
# Environment variable reference
|
||||||
|
|
||||||
|
## Putting your settings in a .env file
|
||||||
|
|
||||||
|
Instead of typing `export ...` in a shell, put your settings in a file. The bot
|
||||||
|
reads them for you. This is easier to keep tidy, and no stale shell variable can
|
||||||
|
surprise you.
|
||||||
|
|
||||||
|
1. In the bot folder, copy `.env.example` to `.env`.
|
||||||
|
2. Open `.env` and set the knobs you want. One `KEY=VALUE` per line. Lines
|
||||||
|
starting with `#` are comments.
|
||||||
|
3. Start the bot from the bot folder (`./ModularBot`). It picks up `.env`
|
||||||
|
automatically.
|
||||||
|
4. To use a different file, pass it on the command line:
|
||||||
|
`./out/ModularBot --env-file /path/to/my.env`.
|
||||||
|
5. If you ask for a file that does not exist, the bot stops with an error. A
|
||||||
|
missing default `.env` is fine and its absence is silent.
|
||||||
|
|
||||||
|
**The file wins over the shell.** If the same name is set in both places with a
|
||||||
|
different value, the file value is used, and the bot prints one line telling you
|
||||||
|
which shell value was overridden. Start the report check with:
|
||||||
|
`grep '^\[env\]' /tmp/modularbot_stdout.log`. Values that came from the file are
|
||||||
|
labelled `(source: .env)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
Every knob the bot reads. **Most are read once per process, at module init; a few
|
Every knob the bot reads. **Most are read once per process, at module init; a few
|
||||||
are read lazily on first use** (`TR_TMHORIZON_SHIFT`/`_BIG_MULT`/`_LOG`/
|
are read lazily on first use** (`TR_TMHORIZON_SHIFT`/`_BIG_MULT`/`_LOG`/
|
||||||
`_RESET_ON_TARGET` in `ensureConfig`, `TR_PATTERN_RAD_*` inside `predict`). Either
|
`_RESET_ON_TARGET` in `ensureConfig`, `TR_PATTERN_RAD_*` inside `predict`). Either
|
||||||
|
|||||||
Reference in New Issue
Block a user