Keep Excel Report Settings in a Config File
Every report script accumulates settings: the network path to the source export, the sheet name, the four people who receive it, the threshold that decides what counts as an exception, the mapping from the source system's column names to the ones the finance team recognises. Left in the code, each of them is a reason to edit and redeploy the script for a change that is not a code change at all — and a reason a non-programmer cannot maintain a report they own.
Moving them into a file is easy. Doing it so that the scheduled run, the test run and the ad-hoc rerun can differ, without the password ending up in version control, takes a little more structure. This guide is part of Testing and Packaging Excel Automation Scripts.
Prerequisites
pip install pandas openpyxl
On Python 3.11 and newer, tomllib reads TOML from the standard library — no install. On 3.9 or 3.10, either pip install tomli or use JSON, which json handles everywhere. The loader below supports both.
Step 1: Write the file a non-programmer can edit
TOML is the better default because it survives a trailing comma, allows comments, and reads like an ini file to someone who has never seen one:
# report.toml — settings for the monthly regional report
[source]
path = "//fileserver/exports/orders.xlsx"
sheet = "Orders"
[output]
directory = "reports"
filename = "regional-{month}.xlsx"
[rules]
min_amount = 100.0
exception_pct = -5.0 # flag regions down more than 5% year on year
drop_test_rows = true
[columns]
"Order Ref" = "Order_ID"
"Sales Area" = "Region"
"Net Value" = "Amount"
[email]
recipients = ["finance@example.com", "ops@example.com"]
subject = "Regional sales — {month}"
# The password is NOT here. Set REPORT_SMTP_PASSWORD in the environment.
The [columns] block is worth noticing: a mapping from the source system's names to yours is exactly the kind of thing that changes without warning when someone upgrades the export, and exactly the kind of change that should not require a developer. The same goes for the filename template — {month} is filled in at runtime, so nobody has to touch code to change how files are named.
Step 2: Load it in layers
The loader merges four sources in a fixed order, so the more specific always wins:
# config.py
import json
import os
from pathlib import Path
try:
import tomllib # Python 3.11+
except ModuleNotFoundError: # 3.9 / 3.10
tomllib = None
DEFAULTS = {
"source": {"path": None, "sheet": "Orders"},
"output": {"directory": "reports", "filename": "report-{month}.xlsx"},
"rules": {"min_amount": 0.0, "exception_pct": -5.0, "drop_test_rows": True},
"columns": {},
"email": {"recipients": [], "subject": "Report", "password": None},
}
ENV_MAP = { # environment variable -> (section, key)
"REPORT_SOURCE_PATH": ("source", "path"),
"REPORT_SMTP_PASSWORD": ("email", "password"),
"REPORT_OUTPUT_DIR": ("output", "directory"),
}
def _deep_merge(base, extra):
"""Merge `extra` into a copy of `base`, one level of nesting deep."""
merged = {k: dict(v) if isinstance(v, dict) else v for k, v in base.items()}
for section, value in (extra or {}).items():
if isinstance(value, dict) and isinstance(merged.get(section), dict):
merged[section].update(value)
else:
merged[section] = value
return merged
def load_config(path=None, env=None, overrides=None):
"""defaults < file < environment < explicit overrides (command-line flags)."""
env = os.environ if env is None else env
config = _deep_merge(DEFAULTS, {})
if path:
path = Path(path)
if not path.is_file():
raise FileNotFoundError(f"config file not found: {path}")
if path.suffix == ".toml":
if tomllib is None:
raise RuntimeError("TOML needs Python 3.11+ or `pip install tomli`")
config = _deep_merge(config, tomllib.loads(path.read_text()))
else:
config = _deep_merge(config, json.loads(path.read_text()))
for var, (section, key) in ENV_MAP.items():
if var in env:
config[section][key] = env[var]
for (section, key), value in (overrides or {}).items():
if value is not None: # an unset flag must not erase the file
config[section][key] = value
return config
Two rules do most of the work. The environment layer sits above the file so a machine can override a path without editing a shared file, and secrets can arrive without ever being written down. The if value is not None guard stops argparse's unset defaults from wiping settings — without it, running the tool with no --output flag silently resets the output directory to None.
Step 3: Validate at startup, not at the save
A config mistake that surfaces forty seconds into a run, after the source has been read and the transform has finished, wastes the run and buries the cause. Check everything the moment the file is loaded:
def validate(config):
problems = []
source = config["source"]["path"]
if not source:
problems.append("source.path is not set")
elif not Path(source).exists():
problems.append(f"source.path does not exist: {source}")
if not config["email"]["recipients"]:
problems.append("email.recipients is empty — nobody would receive the report")
if not isinstance(config["rules"]["min_amount"], (int, float)):
problems.append("rules.min_amount must be a number")
if config["email"]["password"] is None:
problems.append("REPORT_SMTP_PASSWORD is not set in the environment")
if problems:
raise SystemExit("Configuration problems:\n - " + "\n - ".join(problems))
return config
Collecting every problem before raising is deliberate: a loader that stops at the first mistake makes someone fix a typo, rerun, and discover the next one. One message listing all four is one round trip. Raising SystemExit with a string prints the message and exits non-zero without a traceback, which is what a scheduled job's log should contain.
Step 4: Wire it to the command line
The config file and the argparse layer meet in three lines of main:
def main(argv=None):
args = parse_args(argv)
config = validate(load_config(
path=args.config or os.environ.get("REPORT_CONFIG", "report.toml"),
overrides={
("source", "path"): args.source, # None unless the flag was given
("output", "directory"): args.output_dir,
},
))
return run(config, month=args.month, dry_run=args.dry_run)
Selecting the file itself through a flag and an environment variable is what makes multiple environments workable: the scheduled production job sets REPORT_CONFIG=/etc/report/config.prod.toml once, and a developer runs --config config.dev.toml without changing anything that persists.
Step 5: Keep the secrets out of the repository
Commit an example, never the real thing:
# .gitignore
report.toml
.env
# committed instead
report.example.toml
report.example.toml carries every key with a placeholder value, so a new machine is set up by copying and editing rather than by guessing. For local development a .env file loaded by your shell is fine; on a server, set the variables in the scheduler's environment — cron reads /etc/environment and a systemd unit takes Environment= lines, both of which keep the secret out of any file the report code can accidentally print.
Common pitfalls and gotchas
| Symptom | Cause | Fix |
|---|---|---|
| A flag has no effect | Merge overwrote it with the file, or the guard is missing | Apply overrides last, skip None |
| Works locally, fails under cron | Relative config path resolved against a different working directory | Resolve with Path(__file__).parent or pass an absolute path |
| Password appears in a log | Config dict logged wholesale | Redact known secret keys before logging |
tomllib import error | Python 3.10 or older | pip install tomli, or use JSON |
| Recipients arrive as one string | JSON edited to "a@x.com, b@x.com" | Keep it a list; validate the type |
| Numbers read as text | Quoted in the file: min_amount = "100" | Drop the quotes; validate with isinstance |
| Two environments cross over | One file with an internal switch | One file per environment, selected explicitly |
Performance and scale notes
Loading and validating a config file costs a millisecond, so the only scale question is how many reports share one. Past three or four jobs, prefer one file per job over a large file with a section per report: a shared file makes every change a change to every job, and the blast radius of a typo grows with the number of readers. Where several jobs genuinely share settings — an SMTP host, a shared export directory — put those in a small common file and merge it underneath the per-job one, using the same _deep_merge in one more layer.
Conclusion
Settings that change without the logic changing do not belong in the code. Put them in a TOML file the report's owner can edit, layer environment variables above it for secrets and per-machine paths, and let command-line flags win for the values that differ run to run. Validate the whole thing at startup and report every problem at once, commit an example rather than the real file, and the report becomes something that can be maintained by whoever owns it rather than only by whoever wrote it.
Frequently asked questions
TOML, JSON or YAML for the config file?
TOML, if you are on Python 3.11 or newer — tomllib is in the standard library and the format tolerates comments and trailing commas. JSON is the fallback with no dependency on older versions; YAML needs PyYAML and its whitespace rules trip up the non-programmers who most often edit these files.
Where should the SMTP password live? In an environment variable or a secret store, never in the file. A config file gets committed, copied into a ticket and attached to an email; a variable set by the scheduler does not.
How do I keep separate settings for test and production?
One file per environment — config.dev.toml, config.prod.toml — selected by a --config flag or an environment variable. Avoid a single file with an "environment" switch inside it; the wrong branch is too easy to hit.
Should the config file live next to the script or in the user's home directory? Next to the script for a scheduled job, so the settings travel with the deployment. Use a home-directory path only for a tool people install and personalise.
Related
Up to the parent guide:
- Testing and Packaging Excel Automation Scripts — how configuration sits between the command line and packaging.
Related guides:
- Build a Command-Line Tool for Excel Reports with argparse — the layer that overrides this one.
- Package a Python Excel Script as an EXE with PyInstaller — keeping the config file outside the bundle so it stays editable.
- Send an Excel Report to Multiple Recipients in Python — the recipient list this file holds.
- Schedule Recurring Excel Reports with APScheduler — where the environment variables are set for an unattended run.