refactor: dispatch config file loading by filename, then suffix - #14807
Conversation
1878bb4 to
b7cc13f
Compare
bluetech
left a comment
There was a problem hiding this comment.
Review of the first commit:
The refactor looks like a nice improvement to me. I left a few suggestions but feel free to cherry-pick it with my approve.
| } | ||
|
|
||
| return None | ||
| loader = CONFIG_LOADERS.get(filepath.name) or CONFIG_SUFFIXES.get(filepath.suffix) |
There was a problem hiding this comment.
Suggestion: split the or to separate statements, easier this way in coverage and debugging.
|
I haven't reviewed the 2nd and 3rd commits yet, but: Regarding the 2nd commit's description:
Regarding 3rd commit's description: I haven't considered the change, but it is a breaking change, I'm not sure we can just change it. Maybe if we assume the next release will be major, is this the intention of the change? |
Knowledge about config files was spread across three places that had to be kept in sync by hand: load_config_dict_from_file dispatched on suffix with filename checks nested inside those branches, locate_config re-declared the discovery order in its own hardcoded list, and adding a format meant editing both in the right order. Introduce two tables instead. CONFIG_LOADERS maps a config file *name* to its loader and doubles as the discovery order used by locate_config, so order and parsing can no longer drift apart. CONFIG_SUFFIXES maps a *suffix* to a loader for files passed explicitly via -c/--config-file, which may be named anything. load_config_dict_from_file now looks up the name first and falls back to the suffix. The per-format rules move out of nested conditionals into named functions -- _parse_pytest_ini, _parse_ini_file, _parse_cfg_file, _parse_pytest_toml and _parse_pyproject_toml -- each documenting the rule it implements. This is a pure refactor: no test needed changing, and running load_config_dict_from_file over a matrix of every supported name crossed with present/absent/empty/malformed sections (plus unsupported and extension-less files) yields identical values, modes, origins and exceptions before and after. Two pre-existing warts are deliberately preserved rather than fixed here: the CFG_PYTEST_SECTION message still names setup.cfg for any .cfg file, and a scalar `pytest` key still raises AttributeError. This redoes the structural half of pytest-dev#8358 on top of current main; that PR bundled it with the abandoned setup.cfg deprecation from pytest-dev#3523. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Consolidates three open reports that all bottom out in how an explicitly given config file is located and parsed. The preceding refactor turns each of them into a small change rather than another special case in a conditional. Custom TOML files also read [pytest] (pytest-dev#14705) pytest documents [pytest] as the table its own TOML configuration files use, but a TOML file passed via -c was parsed with pyproject.toml semantics, so a [pytest] table in it was silently ignored. Such files now read [pytest] as well, while the [tool.pytest]/[tool.pytest.ini_options] tables they were previously restricted to keep working -- writing both styles into one file is a UsageError. Suffix dispatch makes this one loader; pyproject.toml itself is unaffected, as name dispatch wins. -c/--config-file validates its argument (pytest-dev#14716) Previously an invalid path either silently produced an empty configuration -- while still reporting `configfile:` in the header -- or crashed with a raw FileNotFoundError traceback, depending on its extension. Now a path that does not exist, a directory, and a regular file pytest has no loader for are each a UsageError. The supported extensions in the message are derived from CONFIG_LOADERS_BY_SUFFIX rather than restated in a separate constant. This is breaking for invocations that passed an unparsable file to -c and relied on it being ignored. The pytest-dev#14683 regression test did exactly that with a conftest.py and now uses a real config file; it passes --rootdir explicitly, so the config file was incidental to what it covers. The rootdir is not derived from a non-regular config file (pytest-dev#11502) --config-file=/dev/null is a common way to load no configuration at all. Deriving the rootdir from its parent made the rootdir /dev, and the cache plugin then warned on every run that it could not create /dev/.pytest_cache. Such a path says nothing about where the project lives, so fall back to the usual common-ancestor logic. Whether a path is parsed and whether its directory decides the rootdir are kept separate: a loader runs whenever one matches the name or suffix, so a config file that happens to be a fifo is still read, and only a path with no loader *and* no chance of holding configuration -- a character device such as /dev/null -- means "no configuration" instead of an error. The diagnoses come from pytest-dev#14707 (@DebadityaHait), pytest-dev#14723 (@wanxiankai) and pytest-dev#14671 (@apoorvdarshan); the implementations differ because the table-based dispatch makes each one smaller. Closes pytest-dev#14705 Closes pytest-dev#14716 Closes pytest-dev#11502 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bffbaf1 to
d5ef28c
Compare
bluetech
left a comment
There was a problem hiding this comment.
Thanks, LGTM!
I think it would be nice to split the second commit to 2 or 3 separate commits (one per fix), but I won't torture you with this :)
Supersedes #8358, #14707, #14723 and #14671.
Closes #14705
Closes #14716
Closes #11502
Updated after @bluetech's review. Two changes to what this PR is:
-c config/pytest.ini) from non-project-root directory leads to local fixtures ignore #13246, Scopes of several files are merged when specifying a config using-c#9703, superseding determine_setup: use invocation_dir as rootdir when -c is given #14454 and Fix rootdir with explicit config and test paths #14579) moved out to fix: the config file's directory no longer decides the rootdir alone #14837, since it is a breaking change to documented behaviour and its release target is a separate question.[pytest]in addition to[tool.pytest]/[tool.pytest.ini_options], per @bluetech's suggestion, rather than instead of them.Review comments applied: loaders in the dispatch tables are
_load_*and the format helpers stay_parse_*; the tables areCONFIG_LOADERS_BY_NAME/CONFIG_LOADERS_BY_SUFFIX; the dispatchoris split into statements; the implementation-detail docstring is now a comment on the table.Two commits: a behaviour-preserving refactor of config file dispatch, then the fixes it makes small. Review them separately — the first is intended to be a pure refactor.
Commit 1: refactor config file dispatch
Re-does the structural part of #8358 on top of current
main. That PR bundled the refactor with thesetup.cfgdeprecation from #3523; the deprecation was abandoned (and reverted within the branch), and the refactor — which @nicoddemus had signed off as "Overall the changes look good" — went down with it. This PR carries only the structural change. No deprecation, no policy change, no behaviour change.It can't be cherry-picked: the 2021 branch predates
ConfigValue, native TOML mode, ini option aliases,pytest.toml/.pytest.toml, andignored_config_files. So this is a re-implementation of the same design against today's code.The problem
Knowledge about config files is currently spread across three places that must be kept in sync by hand:
load_config_dict_from_filedispatches on suffix (if filepath.suffix == ".ini" / elif ".cfg" / elif ".toml"), with filename checks nested inside those branches (if filepath.name in {"pytest.ini", ".pytest.ini"},if filepath.name in ("pytest.toml", ".pytest.toml")).locate_configre-declares the discovery order in a separate hardcodedconfig_nameslist.The name-vs-suffix distinction is real and load-bearing —
pytest.iniis config even when empty, a random.iniis not;pyproject.tomlreads[tool.pytest],pytest.tomlreads[pytest]— but it is implicit and interleaved rather than expressed.The change
Two tables, and dispatch that reads off them:
locate_confignow iteratesCONFIG_LOADERS_BY_NAMEdirectly instead of maintaining its own list, so discovery order and parsing can no longer drift apart.Per-format semantics move into named functions —
_load_pytest_ini,_load_ini_file,_load_cfg_file,_load_pytest_toml,_load_pyproject_toml— each with a docstring stating its rule, instead of living in nested conditionals inside one ~100-line function.Behaviour preservation
This is intended to be a pure refactor.
load_config_dict_from_fileover a 38-case matrix (every supported name × present/absent/empty/malformed section, plus.txtand extension-less files) undermainand under this branch, comparing returned values,ConfigValue.mode/origin, and raised exception types and messages. Output is byte-identical.Two spots deliberately keep a pre-existing wart rather than quietly fixing it, so the diff stays behaviour-preserving:
_load_cfg_filestill hardcodessetup.cfgin theCFG_PYTEST_SECTIONmessage even for other.cfgfiles._load_pytest_toml/_load_pyproject_tomlstill assume thepytestkey is a table; a scalar there raisesAttributeErroras before (hence thetype: ignorecomments). Worth hardening, separately.In this commit
CONFIG_LOADERS_BY_SUFFIX[".toml"]still points at_load_pyproject_toml, which is whatmaindoes today, so that the refactor decides nothing. Commit 2 changes it — that is #14705.Why now
Three open PRs are each adding another special case to exactly the conditionals this removes, and two of them conflict:
-c) is the name-vs-suffix confusion itself. On this branch it becomes a one-line change — pointCONFIG_LOADERS_BY_SUFFIX[".toml"]at a[pytest]-aware loader — with no risk topyproject.tomldiscovery. As currently written it inverts a nested check tofilepath.name != "pyproject.toml", i.e. dispatch by negation.SUPPORTED_CONFIG_FILE_EXTENSIONS— a fourth hardcoded table of file knowledge. Here that set isCONFIG_LOADERS_BY_SUFFIX.keys(), derived rather than declared.determine_setupbranch in opposite directions: fix: validate explicit config file paths #14723 rejects any config path whereis_file()is false, Do not derive rootdir from a non-regular config file #14671 exists to support--config-file=/dev/null(a character device, sois_file()is false). Whichever lands second silently undoes the other.Landing this first turns those three into small, independent, non-conflicting changes — which is what commit 2 does.
Commit 2: the three fixes the refactor enables
fix: validate -c/--config-file and read [pytest] from custom TOML filesConsolidates three open reports that all bottom out in how an explicitly given config file is located and parsed:
[pytest]table in a custom TOML file is ignored.tomlsuffix loader reads[pytest]as well as[tool.pytest]/[tool.pytest.ini_options]-chas two failure modes for invalid pathsdetermine_setuprejects nonexistent paths, directories, and regular files with no loaderCONFIG_LOADERS_BY_SUFFIX--config-file=/dev/nullgivesrootdir: /devis_file()The diagnoses come from #14707 (@DebadityaHait), #14723 (@wanxiankai) and #14671 (@apoorvdarshan) — this PR supersedes all three, with smaller implementations because the dispatch tables carry the file knowledge.
Notably #14723 and #14671 are mutually exclusive as written: #14723 rejects any config path where
is_file()is false, while #14671 exists to support--config-file=/dev/null, a character device for whichis_file()is false. Splitting the check into exists (error if not) and is a regular file (its directory may determine the rootdir) satisfies both.On #14705 — both table styles, not a swap
The first version of this commit made custom TOML files read
[pytest]instead of the[tool.pytest]tables. @bluetech pointed out that this breaks working setups, and he is right:-c custom.tomlwith[tool.pytest.ini_options]has worked since TOML support landed. It now accepts both, and writing both styles into one file is aUsageError.pyproject.tomlitself is untouched — name dispatch wins over suffix dispatch, so it keeps[tool.pytest]-only semantics.On #11502 — parsing and rootdir are separate questions
@bluetech also flagged that falling back to an empty config for a non-regular file is undesirable, and suggested erroring with
/dev/nullspecial-cased. The version here reaches the same place from the other side, without a platform-specific name check:UsageError;/dev/null, andNULon Windows, with no name matching needed;is_file(), because a character device says nothing about where the project lives.Happy to switch to an explicit
os.devnullcheck if you prefer it stated rather than derived.Passing a file pytest has no loader for to
-c/--config-fileis now aUsageErrorinstead of being silently ignored. pytest's own #14683 regression test did this with aconftest.py; it now uses a real config file, and since it passes--rootdirexplicitly the config file was incidental to what it covers. (@bluetech: "technically a breaking change but clearly a bug/broken setup, I think it's OK to break this".)Verification
Full suite green (4344 passed, 47 skipped, 13 xfailed, 7 xpassed — the xpasses are pre-existing, #11603/#10042). Each reported reproducer checked by hand as well: the custom-TOML case is confirmed by
python_filesfrom the custom file actually taking effect, and the/dev/nullcase by the rootdir landing on the project directory with noPytestCacheWarning.Follow-up: #14837
The rootdir fix that was commit 3 here now lives in #14837, based on this branch. It is breaking against documented behaviour, so its release target needs deciding separately.