Skip to content

fix(config): restrict list-valued keys, teach cache.root consumers (0.22.2) - #181

Merged
johnyaku merged 1 commit into
mainfrom
fix/list-valued-config
Aug 17, 2026
Merged

johnyaku merged 1 commit into
mainfrom
fix/list-valued-config

Conversation

@johnyaku

Copy link
Copy Markdown
Contributor

Follow-up to the remote.root incident: the same trap is still live one line away, and the guard was at the wrong end.

What happened

remote.root became a list of four roots at some point. Every dt older than 0.20.0 reads it with get_value and calls Path() on the result, so that one config edit retroactively broke 26 already-published container images. Each aborts on startup with a bare TypeError, and being immutable, none of them can be fixed. Those modulefiles now refuse to load with an explanation instead.

cache.root is the same bug, not yet triggered

dt config add accepted any key — there was no whitelist. So dt config add cache.root /somewhere is an ordinary thing to do by analogy with remote.root, and produces the identical failure. Confirmed by probe against the current code:

_discover_dt_config()  ->  Endpoint.url = ['/scratch/...', '/g/data/...']   # no error
classify_url(url)      ->  AttributeError: 'list' object has no attribute 'strip'
check_endpoints([ep])  ->  TypeError: expected str, bytes or os.PathLike object, not list

Note the shape: the read succeeds. get_value hands the list on quite happily, and it detonates later, in another module, with nothing pointing back at the config. That is why this class of bug is expensive to diagnose.

Three consumers were affected — dt auth list/check/setup via endpoint discovery, cache.resolve_cache_path, and doctor.check_cache_root.

The fix, at both ends

Writing. add_to_list refuses keys outside LIST_VALUED_KEYS (remote.root, cache.root, secrets.gcp.locations); the CLI surfaces it as a usage error naming the keys that do take lists. This is the end that matters — a config outlives the code that reads it, so guarding only the reader leaves every already-released version exposed, exactly as happened here.

Reading. The three cache.root consumers go through get_str_list. Discovery emits one endpoint per root, since a second cache is worth checking; the other two take the first, matching remote.root's documented convention that the first entry is the one used when creating something new. A scalar behaves exactly as before.

dt config set is deliberately not restricted — it can't tell a YAML list from a string that looks like one, and forcing a list is a reasonable escape hatch. Documented.

Design decision worth a second opinion

I made cache.root list-valued (first = primary) rather than rejecting lists outright. A DVC repo has one cache, so the alternative — refuse it and keep cache.root strictly scalar — is also defensible. I went with list-tolerant because it matches remote.root, and because a config that already has a list should degrade rather than crash. Easy to flip.

Tests

2169 passed, 2 skipped, 0 failed under PBS via qxub. 24 new tests in tests/unit/test_list_valued_config.py covering both ends: which keys may become lists, that the CLI refuses the rest without a traceback, and that every cache.root consumer handles a list, a scalar, and an absent value.

🤖 Generated with Claude Code

….22.2)

`remote.root` became a list of four roots at some point. Every dt older than
0.20.0 reads it with get_value and calls Path() on the result, so that one
config edit retroactively broke 26 already-published container images. Each
aborts on startup with a bare TypeError, and being immutable, none of them can
be fixed. The modulefiles now refuse to load with an explanation instead.

The same trap is still live for cache.root, which is one line away in the same
function. `dt config add` accepted *any* key -- there was no whitelist -- so
`dt config add cache.root /somewhere` is an ordinary thing to do by analogy
with remote.root, and it would have produced the identical failure. Confirmed
by probe: _discover_dt_config quietly yields Endpoint.url as a list, which then
raises in classify_url ("'list' object has no attribute 'strip'") and in
check_endpoints (TypeError), a long way from the cause.

Two changes, at both ends:

**Writing.** add_to_list now refuses keys outside LIST_VALUED_KEYS
(remote.root, cache.root, secrets.gcp.locations), and the CLI surfaces that as
a usage error. This is the end that matters: a config outlives the code that
reads it, so guarding only the reader leaves every released version exposed.

**Reading.** The three cache.root consumers -- endpoint discovery, cache path
resolution, and the doctor check -- go through get_str_list. Discovery emits
one endpoint per root, since a second cache is worth checking; the other two
take the first, matching remote.root's documented convention that the first
entry is the one used when creating something new. A scalar still behaves
exactly as before.

Note `dt config set` is deliberately not restricted: it cannot tell a YAML list
from a string that looks like one, and forcing a list is a reasonable escape
hatch. Documented as such.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@johnyaku
johnyaku merged commit 8eae25f into main Aug 17, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant