Write a config linter that exits non-zero on errors
Problem statement
Every service in a repo ships a small JSON config, and a CI step should reject bad ones before they reach a deploy. Write lint_config.py FILE [FILE ...] [--strict] that checks each file and prints one line per problem: FILE: LEVEL KEY: message.
Errors
- The file is missing, is not valid UTF-8, or is not valid JSON (report the line and column).
- The top level is not an object.
name,portorenvis missing.nameis not lowercase letters, digits and-, starting with a letter (max 63 characters).portis not an integer from 1 to 65535. Booleans do not count as integers.envis not one ofdev,staging,prod.replicas(optional, default 1) is not an integer>= 1.healthcheck(optional) is not an object, or itspathdoes not start with/.
Warnings
envisprodwithreplicasbelow 2.- Any key not listed above (usually a typo).
Report every problem in a file, not just the first. Print FILE: ok for a clean file and a summary line at the end. Exit 1 if there were any errors (or any warnings with --strict), otherwise 0.
good.json
{ "name": "payments-api", "port": 8443, "env": "prod", "replicas": 3, "healthcheck": {"path": "/healthz", "interval_s": 10}}bad.json
{ "name": "Payments_API", "port": true, "env": "production", "replicas": 0, "healthcheck": {"path": "healthz"}, "replica": 2}broken.json
{ "name": "search", "port": 9200,}Examples
Example 1
Input: python lint_config.py good.json bad.json broken.json missing.json; echo "exit: $?"
Output: good.json: ok
bad.json: ERROR name: must be lowercase letters, digits and '-', got 'Payments_API'
bad.json: ERROR port: must be an integer 1-65535, got true
bad.json: ERROR env: must be one of dev, prod, staging, got 'production'
bad.json: ERROR replicas: must be an integer >= 1, got 0
bad.json: ERROR healthcheck.path: must start with '/', got 'healthz'
bad.json: WARN replica: unknown key (typo?)
broken.json: ERROR <file>: invalid JSON at line 3 col 15: Illegal trailing comma before end of object
missing.json: ERROR <file>: file not found
7 error(s), 1 warning(s) in 4 file(s)
exit: 1
Explanation: "port": true would pass an isinstance(port, int) check, because bool is a subclass of int in Python. The trailing comma after 9200 in broken.json is caught by the JSON parser, which points at the comma itself.
Example 2
Input: python lint_config.py good.json; echo "exit: $?"
Output: good.json: ok
0 error(s), 0 warning(s) in 1 file(s)
exit: 0
Hints
Approach
Optimal
Separate the three concerns: reading a file, checking a parsed config, and reporting.
lint_fileturns every way reading can fail into a normal problem entry: missing file, bad encoding, andJSONDecodeErrorwith itslineno/colno. A linter that crashes with a traceback on the one file that is actually broken is useless in CI.linttakes a parsed value and returns a list of(level, key, message)tuples. It checks the root type first, then required keys, then each field only if it is present, so a missingportgives one clear error, not two. Every rule appends and continues, so one run reports everything wrong with a file.- Type traps.
is_intrejects booleans. The messages print the offending value withjson.dumpsorrepr, sotrue,"8080"and8080are distinguishable in the output.replicasuses a default of 1, so it is optional but still validated if given. The prod warning only runs whenreplicasitself is valid. - Unknown keys are the set difference between the file's keys and the known set, sorted for a stable order. They are warnings because they usually mean a typo (
replicaforreplicas) that silently leaves the real setting at its default. - Exit code. 1 if any error, or any warning under
--strict; otherwise 0. This is what lets the CI step fail the build.
Because lint is a pure function over a dict, it is easy to unit-test each rule without touching the filesystem.
O(total size of the files)Space O(size of one file)import argparseimport jsonimport reimport sys NAME_RE = re.compile(r"^[a-z][a-z0-9-]{0,62}$")ENVS = {"dev", "staging", "prod"}KNOWN = {"name", "port", "env", "replicas", "healthcheck"} def is_int(v): return isinstance(v, int) and not isinstance(v, bool) # True is an int in Python def lint(cfg): # Returns a list of (level, key, message). out = [] if not isinstance(cfg, dict): return [("ERROR", "<root>", "top level must be an object")] for key in ("name", "port", "env"): if key not in cfg: out.append(("ERROR", key, "required key is missing")) name = cfg.get("name") if name is not None and not (isinstance(name, str) and NAME_RE.match(name)): out.append(("ERROR", "name", f"must be lowercase letters, digits and '-', got {name!r}")) port = cfg.get("port") if port is not None and not (is_int(port) and 1 <= port <= 65535): out.append(("ERROR", "port", f"must be an integer 1-65535, got {json.dumps(port)}")) env = cfg.get("env") if env is not None and env not in ENVS: out.append(("ERROR", "env", f"must be one of {', '.join(sorted(ENVS))}, got {env!r}")) replicas = cfg.get("replicas", 1) if not (is_int(replicas) and replicas >= 1): out.append(("ERROR", "replicas", f"must be an integer >= 1, got {json.dumps(replicas)}")) elif env == "prod" and replicas < 2: out.append(("WARN", "replicas", "prod with a single replica has no redundancy")) hc = cfg.get("healthcheck") if hc is not None: if not isinstance(hc, dict): out.append(("ERROR", "healthcheck", "must be an object")) elif not str(hc.get("path", "")).startswith("/"): out.append(("ERROR", "healthcheck.path", f"must start with '/', got {hc.get('path')!r}")) for key in sorted(set(cfg) - KNOWN): out.append(("WARN", key, "unknown key (typo?)")) return out def lint_file(path): try: with open(path, encoding="utf-8") as f: cfg = json.load(f) except FileNotFoundError: return [("ERROR", "<file>", "file not found")] except json.JSONDecodeError as e: return [("ERROR", "<file>", f"invalid JSON at line {e.lineno} col {e.colno}: {e.msg}")] except UnicodeDecodeError: return [("ERROR", "<file>", "not valid UTF-8")] return lint(cfg) def main(argv=None): p = argparse.ArgumentParser(prog="lint_config.py") p.add_argument("files", nargs="+") p.add_argument("--strict", action="store_true", help="treat warnings as errors") args = p.parse_args(argv) errors = warnings = 0 for path in args.files: problems = lint_file(path) for level, key, msg in problems: print(f"{path}: {level:<5} {key}: {msg}") errors += sum(1 for p in problems if p[0] == "ERROR") warnings += sum(1 for p in problems if p[0] == "WARN") if not problems: print(f"{path}: ok") print(f"{errors} error(s), {warnings} warning(s) in {len(args.files)} file(s)") return 1 if errors or (args.strict and warnings) else 0 if __name__ == "__main__": sys.exit(main())Follow-up questions
- Suggest the closest known key for an unknown one (
replica-> did you meanreplicas?) usingdifflib.get_close_matches. - Add a
--format jsonoutput so a code review bot can annotate the exact lines. - Check rules across files, for example that no two services use the same port.
Frequently asked questions
Config validation in CI is one of the cheapest ways to prevent outages: a wrong port or a typo'd key is caught at review time instead of at deploy time. Interviewers use a linter task to see whether you report all problems clearly and whether the exit code is right, since that is what the pipeline acts on.
In a real repo, a JSON Schema plus a validator library is a good choice. It is not in the standard library, though, and interviews usually want to see the checks written out. The structure here (collect problems, then decide the exit code) is the same one a schema-based tool uses.
Usually not by default, or teams start ignoring the linter. --strict gives repos that want zero warnings a way to opt in, and makes it easy to tighten rules gradually.