Skip to content

Commit b02bfc5

Browse files
committed
🐛 fix: read the file back before handing it over
Both formatters returned what they wrote without reading it, leaving the file itself as the first thing to try. Each one parses its output now, and the tox formatter says why it rejects a file rather than handing back what it was given. Whitespace between the clauses of `requires-python` says nothing, while whitespace inside one is what makes the text something PEP 440 does not read: `> = 3.10` stays as written rather than becoming a constraint the file never named, and a field this cannot read leaves the classifiers beside it alone. A setting read from a TOML table is held to what the command line would accept, so a negative count, a boolean, or a `table_format` no formatter knows is reported against the file that names it. Text `tomllib` cannot read is left to the formatter to report on, rather than reaching the user as a traceback from a reader that is only there for the settings. A list the file wrote as a comment names no environment tox runs, so it no longer decides where the tables that hold them go.
1 parent 34f9e4c commit b02bfc5

15 files changed

Lines changed: 337 additions & 206 deletions

File tree

common/src/lib.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,21 @@ pub mod pep508;
1111
pub mod sections;
1212
pub mod spacing;
1313
pub mod strings;
14+
15+
/// Hand back what the formatter wrote, once it reads back as the document it is meant to be.
16+
///
17+
/// The file the caller holds is the last valid one until this returns, so text no parser accepts
18+
/// never reaches it.
19+
///
20+
/// # Errors
21+
///
22+
/// Returns where the written text stops being a document.
23+
pub fn written_document(written: &str) -> Result<String, String> {
24+
match toml_doc::parse(written) {
25+
Ok(_) => Ok(written.to_owned()),
26+
Err(errors) => Err(format!(
27+
"the formatter wrote something no reader accepts: {}",
28+
errors[0]
29+
)),
30+
}
31+
}

common/src/pep508/version_op.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,12 @@ impl VersionOp {
2525
if literal.is_empty() {
2626
return Err(format!("The clause names no version: '{spec}'"));
2727
}
28-
// `===` compares the text it is given, which PEP 440 lets be anything at all
28+
// `===` compares the text it is given, which PEP 440 lets be anything a version token can
29+
// hold, and whitespace is not one of those
2930
if op == Operator::ArbitraryEqual {
31+
if literal.contains(char::is_whitespace) {
32+
return Err(format!("The version names more than one word: '{literal}'"));
33+
}
3034
return Ok(Self {
3135
version: Version::new(&literal).ok(),
3236
op,

common/tests/invariants.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,14 @@ fn reordering_members_leaves_a_document_that_reads_back() {
166166
}
167167
});
168168
}
169+
170+
/// The file the caller holds is the last valid one until the formatter returns, so what it wrote is
171+
/// read back before it goes anywhere.
172+
#[test]
173+
fn what_the_formatter_writes_reads_back_as_a_document() {
174+
assert_eq!(
175+
common::written_document("a = [1,\n]\n"),
176+
Ok(String::from("a = [1,\n]\n"))
177+
);
178+
assert!(common::written_document("a = [1,\n").is_err());
179+
}

common/tests/pep508.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,7 @@ fn test_a_version_list_the_parentheses_do_not_close_is_not_a_requirement() {
530530
#[test]
531531
fn test_an_operator_and_a_version_pep_440_does_not_pair_is_not_a_requirement() {
532532
for raw in [
533+
"pkg=== foo bar",
533534
"pkg>=1.*",
534535
"pkg<=1.*",
535536
"pkg~=1",

pyproject-fmt/rust/src/main.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,8 @@ pub fn format_toml(content: &str, opt: &Settings) -> Result<String, String> {
171171
// below hands the formatter a document with its disabled keys turned back on, which may say the
172172
// same name twice
173173
toml_doc::parse(content).map_err(|errors| errors[0].to_string())?;
174-
common::disabled::try_with_disabled_keys(content, |content| format_core(content, opt))
174+
let written = common::disabled::try_with_disabled_keys(content, |content| format_core(content, opt))?;
175+
common::written_document(&written)
175176
}
176177

177178
fn format_core(content: &str, opt: &Settings) -> Result<String, String> {

pyproject-fmt/rust/src/project.rs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,12 @@ pub fn fix(
141141
});
142142
}
143143
"requires-python" => {
144-
common::strings::update(value, |text| text.split_whitespace().collect());
144+
// whitespace between the clauses says nothing, while whitespace inside one is what
145+
// makes the text something PEP 440 does not read: taking it out would write a
146+
// constraint the file does not name
147+
common::strings::update(value, |text| {
148+
read_specifiers(text).map_or_else(|| text.to_owned(), |_| text.split_whitespace().collect())
149+
});
145150
}
146151
"dependencies" | "optional-dependencies" => {
147152
normalize_and_sort_requirements(value, keep_full_version);
@@ -328,6 +333,9 @@ fn generate_classifiers(
328333
}
329334
let (minors, existing) =
330335
supported_minors_with_classifier(&section.entries, max_supported_python, min_supported_python);
336+
let Some(minors) = minors else {
337+
return;
338+
};
331339
let Some(existing) = existing else {
332340
// written as something other than a list of classifiers; a second key would say it twice
333341
if minors.is_empty()
@@ -381,7 +389,7 @@ fn apply_classifiers(array: &mut toml_doc::Array<'_>, minors: &[u8], existing: &
381389
array.trailing_comma = true;
382390
}
383391

384-
type MinorsWithClassifier = (Vec<u8>, Option<HashSet<String>>);
392+
type MinorsWithClassifier = (Option<Vec<u8>>, Option<HashSet<String>>);
385393

386394
/// The Python 3 minor versions the project supports, and the classifiers it already names.
387395
///
@@ -410,13 +418,18 @@ fn supported_minors_with_classifier(
410418
}
411419
}
412420
let window = min_supported_python.1..=max_supported_python.1;
413-
let Some(clauses) = requires.as_deref().and_then(read_specifiers) else {
414-
return (window.collect(), classifiers);
421+
let Some(text) = requires else {
422+
return (Some(window.collect()), classifiers);
423+
};
424+
// a constraint this cannot read still says what the project supports, and the configured window
425+
// would say something else in its place
426+
let Some(clauses) = read_specifiers(&text) else {
427+
return (None, classifiers);
415428
};
416429
let minors = window
417430
.filter(|minor| series_holds_a_release(&clauses, *minor))
418431
.collect();
419-
(minors, classifiers)
432+
(Some(minors), classifiers)
420433
}
421434

422435
/// The clauses of a specifier set, or `None` when the text is not one this can read.

pyproject-fmt/rust/src/tests/project_tests.rs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2233,8 +2233,8 @@ fn test_project_requires_python_reads_the_whole_specifier_set() {
22332233
assert_eq!(minors("===foobar"), named(&[]));
22342234
}
22352235

2236-
/// Text that is not a specifier set says nothing about what the project supports, so the configured
2237-
/// window stands.
2236+
/// A constraint this cannot read still says what the project supports, so the classifiers beside it
2237+
/// are left as the file wrote them and the text itself is not tidied into something else.
22382238
#[test]
22392239
fn test_project_requires_python_that_is_not_a_specifier() {
22402240
let start = indoc! {r#"
@@ -2248,13 +2248,36 @@ fn test_project_requires_python_that_is_not_a_specifier() {
22482248
[project]
22492249
name = "test"
22502250
requires-python = "three"
2251+
classifiers = [ "License :: OSI Approved :: MIT License" ]
2252+
"#);
2253+
}
2254+
2255+
/// Whitespace between the clauses says nothing, while whitespace inside one is what makes the text
2256+
/// something PEP 440 does not read.
2257+
#[test]
2258+
fn test_project_requires_python_holding_whitespace_inside_a_clause() {
2259+
let written = |text: &str| {
2260+
let start = format!("[project]\nname = \"test\"\nrequires-python = \"{text}\"\n");
2261+
evaluate_project(&start, false, (3, 12), true)
2262+
};
2263+
2264+
insta::assert_snapshot!(written(">= 3.9, < 4"), @r#"
2265+
[project]
2266+
name = "test"
2267+
requires-python = ">=3.9,<4"
22512268
classifiers = [
2252-
"License :: OSI Approved :: MIT License",
22532269
"Programming Language :: Python :: 3 :: Only",
22542270
"Programming Language :: Python :: 3.9",
22552271
"Programming Language :: Python :: 3.10",
2272+
"Programming Language :: Python :: 3.11",
2273+
"Programming Language :: Python :: 3.12",
22562274
]
22572275
"#);
2276+
for held in ["> = 3.10", ">=3 . 10", "=== foo bar"] {
2277+
let result = written(held);
2278+
assert!(result.contains(&format!("requires-python = \"{held}\"")), "{result}");
2279+
assert!(!result.contains("Programming Language"), "{result}");
2280+
}
22582281
}
22592282

22602283
#[test]

pyproject-fmt/tests/test_main.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,31 @@ def test_a_setting_the_formatter_cannot_hold(
146146
assert message in capsys.readouterr().err
147147

148148

149+
@pytest.mark.parametrize(
150+
("setting", "message"),
151+
[
152+
pytest.param("indent = -1", "must not be negative", id="negative-count"),
153+
pytest.param('table_format = "wide"', "invalid choice", id="unknown-table-format"),
154+
pytest.param("column_width = true", "invalid count", id="boolean-count"),
155+
],
156+
)
157+
def test_a_configured_setting_the_formatter_cannot_hold(
158+
tmp_path: Path,
159+
capsys: pytest.CaptureFixture[str],
160+
setting: str,
161+
message: str,
162+
) -> None:
163+
pyproject_toml = tmp_path / "pyproject.toml"
164+
start = f'[project]\nname = "x"\n\n[tool.pyproject-fmt]\n{setting}\n'
165+
pyproject_toml.write_text(start)
166+
167+
with pytest.raises(SystemExit):
168+
run([str(pyproject_toml)])
169+
170+
assert message in capsys.readouterr().err
171+
assert pyproject_toml.read_text() == start
172+
173+
149174
def test_keep_full_version_cli(tmp_path: Path) -> None:
150175
start = """\
151176
[build-system]

0 commit comments

Comments
 (0)