askcli: JSON-lines logging, a request id, and env > file > defaultEvery service you deploy on this track starts with the same three pieces of
scaffolding before any model code: a command line with honest exit codes, logs a machine can
parse — one JSON object per line, each stamped with the request it belongs to — and a config
loader where the environment beats the file and the file beats the defaults. In Spring Boot
those come with the starter; in Python you write them once, here, in a package called
askcli, and flagship F1 reuses them. It runs on your Mac with uv:
the concurrency check starts real threads.
src/askcli/config.py — load_config(defaults, path, env) -> Config.
Config is a provided frozen dataclass (model_name,
timeout_s, log_level). Precedence is per key: an
ASKCLI_MODEL_NAME-style variable beats the TOML file, which beats
DEFAULTS. path=None or a missing file means no file layer. A key in
the file that is not a field raises ValueError naming the key. Environment
values arrive as strings: ASKCLI_TIMEOUT_S="5" must become the float
5.0. (The config panel shows every
case.)src/askcli/logjson.py — the request id's storage: one value per thread and
per asyncio task, "-" outside a request; request_scope(id), a
context manager that sets it and restores the previous value on exit, even on an exception;
current_request_id(); JsonFormatter.format(record), returning
one line of JSON with ts (UTC, ISO 8601, milliseconds), level,
logger, msg (with its %-args filled in) and
request_id; and configure_logging(json_lines=, level=) — one
StreamHandler on the askcli logger, writing to sys.stderr
looked up at call time, propagate = False, and idempotent: a second call must not
leave two handlers.src/askcli/cli.py — build_parser() for
askcli ask "<prompt>" [--model NAME] [--config PATH] [--json];
ask(prompt, cfg), which serves one request inside request_scope with a
fresh id and logs exactly two lines — request start: '…' before
backend.complete(...) and request done: N answer chars after; and
main(argv) -> int: load the config, apply --model on top of every
layer, configure logging, ask, print the answer (with --json: one JSON object with
model and answer) on stdout, return 0. Bad arguments return 2 —
argparse raises SystemExit(2); catch it.src/askcli/backend.py (the stand-in model, which logs model call: …
through the child logger askcli.backend) and src/askcli/__init__.py are
provided. The backend never receives a request id, yet its line must carry the right one —
that is the whole reason the id lives in a ContextVar the formatter reads, and not in
an argument or a global.
The tests are ordinary pytest and ship in the public folder with the starter — read them first; the names below are the check list. Solutions are not published.
Needs git. uv installs the right Python itself, so nothing else is required.
# once, anywhere on your machine
git clone https://github.com/theDocWho/ai-ml-roadmap.git
cd ai-ml-roadmap
No git? Download the ZIP, unzip it, and cd into the unzipped folder instead.
From the repo root:
# one-time: uv (https://docs.astral.sh/uv/) manages the venv and pins Python ≥ 3.12 cd exercises/py-09-cli-logging && uv sync && uv run pytest -q # the real thing, once the tests pass: uv sync installed an `askcli` launcher uv run askcli ask "what is a logger tree?" --json ASKCLI_MODEL_NAME=gpt-mini uv run askcli ask "hi" uv run askcli ask; echo "exit code $?"
Done when uv run pytest -q prints 7 passed. The
untouched starter fails all seven.
test_defaults_apply_when_nothing_else_sets_a_key — with path=None
and with a missing file, and env={}, the result is
Config(model_name='tiny-local', timeout_s=30.0, log_level='INFO').test_file_beats_default — a file with model_name = "gpt-large" and
timeout_s = 12.5 wins both keys; log_level stays the default.test_env_beats_file — ASKCLI_MODEL_NAME=gpt-mini and
ASKCLI_TIMEOUT_S=5 beat the same file, timeout_s arrives as the float
5.0, and an unprefixed MODEL_NAME is ignored.test_unknown_key_in_file_raises — modle_name = "gpt-large" raises
ValueError whose message contains modle_name.test_json_flag_prints_one_object_per_line — askcli ask "…" --json
writes exactly three stderr lines, each one JSON object with the five keys, from
askcli.cli, askcli.backend, askcli.cli; stdout is one
JSON object whose answer holds the prompt. Run twice — the second run must also
print three lines, not six.test_request_id_same_within_a_request_and_distinct_across_threads — two requests
in two threads, held in flight at the same time by a barrier inside the model call: the six
lines carry exactly two ids, never "-", and each id stamps one start, one model
call and one done.test_exit_code_2_on_bad_args_and_0_on_success — no sub-command, no prompt, and
an unknown flag each give 2; ask "hi there" --model gpt-mini gives 0 and prints an
answer from gpt-mini.askcli entry point, pytest, ruff and mypy configThis is self-attestation — the site cannot see your terminal, so the box and the button are you telling The Path the suite went green on your machine.
load_config — start from dict(defaults), then
update it with the file, then with the environment: the layer applied
last wins. Check the file's keys against
{f.name for f in dataclasses.fields(Config)} before merging.
tomllib.load wants the file opened in binary mode ("rb").contextvars.ContextVar("request_id", default="-");
token = var.set(rid) on the way in, var.reset(token) in a
finally. A value one thread sets is invisible to every other thread and task,
which is exactly the property the concurrency check needs.JsonFormatter — record.getMessage() fills in the
%-args; datetime.fromtimestamp(record.created, tz=UTC)
.isoformat(timespec="milliseconds") is the timestamp; json.dumps without
indent keeps it on one line.propagate. Remove old handlers before adding the new one.parser.parse_args(argv) raises SystemExit with
code 2 after printing the usage line; except SystemExit as exc: return exc.code.
add_subparsers(dest="command", required=True) makes a missing sub-command an
error too.uv run pytest -q -x --tb=short stops at the first
failure; the message names the behaviour, not the fix.