Always prefer double quotes for docstrings and triple-quoted srings (#7680)

## Summary

At present, `quote-style` is used universally. However, [PEP
8](https://peps.python.org/pep-0008/) and [PEP
257](https://peps.python.org/pep-0257/) suggest that while either single
or double quotes are acceptable in general (as long as they're
consistent), docstrings and triple-quoted strings should always use
double quotes. In our research, the vast majority of Ruff users that
enable the `flake8-quotes` rules only enable them for inline strings
(i.e., non-triple-quoted strings).

Additionally, many Black forks (like Blue and Pyink) use double quotes
for docstrings and triple-quoted strings.

Our decision for now is to always prefer double quotes for triple-quoted
strings (which should include docstrings). Based on feedback, we may
consider adding additional options (e.g., a `"preserve"` mode, to avoid
changing quotes; or a `"multiline-quote-style"` to override this).

Closes https://github.com/astral-sh/ruff/issues/7615.

## Test Plan

`cargo test`
This commit is contained in:
Charlie Marsh 2023-09-28 15:11:33 -04:00 committed by GitHub
parent f62b4c801f
commit 695dbbc539
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 84 additions and 68 deletions

View file

@ -323,24 +323,32 @@ impl StringPart {
self,
quoting: Quoting,
locator: &'a Locator,
quote_style: QuoteStyle,
configured_style: QuoteStyle,
) -> NormalizedString<'a> {
// Per PEP 8 and PEP 257, always prefer double quotes for docstrings and triple-quoted
// strings. (We assume docstrings are always triple-quoted.)
let preferred_style = if self.quotes.triple {
QuoteStyle::Double
} else {
configured_style
};
let raw_content = locator.slice(self.content_range);
let preferred_quotes = match quoting {
let quotes = match quoting {
Quoting::Preserve => self.quotes,
Quoting::CanChange => {
if self.prefix.is_raw_string() {
preferred_quotes_raw(raw_content, self.quotes, quote_style)
choose_quotes_raw(raw_content, self.quotes, preferred_style)
} else {
preferred_quotes(raw_content, self.quotes, quote_style)
choose_quotes(raw_content, self.quotes, preferred_style)
}
}
};
let normalized = normalize_string(
locator.slice(self.content_range),
preferred_quotes,
quotes,
self.prefix.is_raw_string(),
);
@ -348,7 +356,7 @@ impl StringPart {
prefix: self.prefix,
content_range: self.content_range,
text: normalized,
quotes: preferred_quotes,
quotes,
}
}
}
@ -460,16 +468,17 @@ impl Format<PyFormatContext<'_>> for StringPrefix {
}
}
/// Detects the preferred quotes for raw string `input`.
/// The configured quote style is preferred unless `input` contains unescaped quotes of the
/// configured style. For example, `r"foo"` is preferred over `r'foo'` if the configured
/// quote style is double quotes.
fn preferred_quotes_raw(
/// Choose the appropriate quote style for a raw string.
///
/// The preferred quote style is chosen unless the string contains unescaped quotes of the
/// preferred style. For example, `r"foo"` is chosen over `r'foo'` if the preferred quote
/// style is double quotes.
fn choose_quotes_raw(
input: &str,
quotes: StringQuotes,
configured_style: QuoteStyle,
preferred_style: QuoteStyle,
) -> StringQuotes {
let configured_quote_char = configured_style.as_char();
let preferred_quote_char = preferred_style.as_char();
let mut chars = input.chars().peekable();
let contains_unescaped_configured_quotes = loop {
match chars.next() {
@ -478,7 +487,7 @@ fn preferred_quotes_raw(
chars.next();
}
// `"` or `'`
Some(c) if c == configured_quote_char => {
Some(c) if c == preferred_quote_char => {
if !quotes.triple {
break true;
}
@ -487,13 +496,13 @@ fn preferred_quotes_raw(
// We can't turn `r'''\""'''` into `r"""\"""""`, this would confuse the parser
// about where the closing triple quotes start
None => break true,
Some(next) if *next == configured_quote_char => {
Some(next) if *next == preferred_quote_char => {
// `""` or `''`
chars.next();
// We can't turn `r'''""'''` into `r""""""""`, nor can we have
// `"""` or `'''` respectively inside the string
if chars.peek().is_none() || chars.peek() == Some(&configured_quote_char) {
if chars.peek().is_none() || chars.peek() == Some(&preferred_quote_char) {
break true;
}
}
@ -510,26 +519,27 @@ fn preferred_quotes_raw(
style: if contains_unescaped_configured_quotes {
quotes.style
} else {
configured_style
preferred_style
},
}
}
/// Detects the preferred quotes for `input`.
/// * single quoted strings: The preferred quote style is the one that requires less escape sequences.
/// * triple quoted strings: Use double quotes except the string contains a sequence of `"""`.
fn preferred_quotes(
input: &str,
quotes: StringQuotes,
configured_style: QuoteStyle,
) -> StringQuotes {
let preferred_style = if quotes.triple {
/// Choose the appropriate quote style for a string.
///
/// For single quoted strings, the preferred quote style is used, unless the alternative quote style
/// would require fewer escapes.
///
/// For triple quoted strings, the preferred quote style is always used, unless the string contains
/// a triplet of the quote character (e.g., if double quotes are preferred, double quotes will be
/// used unless the string contains `"""`).
fn choose_quotes(input: &str, quotes: StringQuotes, preferred_style: QuoteStyle) -> StringQuotes {
let style = if quotes.triple {
// True if the string contains a triple quote sequence of the configured quote style.
let mut uses_triple_quotes = false;
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
let configured_quote_char = configured_style.as_char();
let preferred_quote_char = preferred_style.as_char();
match c {
'\\' => {
if matches!(chars.peek(), Some('"' | '\\')) {
@ -537,14 +547,14 @@ fn preferred_quotes(
}
}
// `"` or `'`
c if c == configured_quote_char => {
c if c == preferred_quote_char => {
match chars.peek().copied() {
Some(c) if c == configured_quote_char => {
Some(c) if c == preferred_quote_char => {
// `""` or `''`
chars.next();
match chars.peek().copied() {
Some(c) if c == configured_quote_char => {
Some(c) if c == preferred_quote_char => {
// `"""` or `'''`
chars.next();
uses_triple_quotes = true;
@ -579,7 +589,7 @@ fn preferred_quotes(
// Keep the existing quote style.
quotes.style
} else {
configured_style
preferred_style
}
} else {
let mut single_quotes = 0u32;
@ -599,7 +609,7 @@ fn preferred_quotes(
}
}
match configured_style {
match preferred_style {
QuoteStyle::Single => {
if single_quotes > double_quotes {
QuoteStyle::Double
@ -619,7 +629,7 @@ fn preferred_quotes(
StringQuotes {
triple: quotes.triple,
style: preferred_style,
style,
}
}
@ -668,7 +678,7 @@ impl Format<PyFormatContext<'_>> for StringQuotes {
}
/// Adds the necessary quote escapes and removes unnecessary escape sequences when quoting `input`
/// with the provided `style`.
/// with the provided [`StringQuotes`] style.
///
/// Returns the normalized string and whether it contains new lines.
fn normalize_string(input: &str, quotes: StringQuotes, is_raw: bool) -> Cow<str> {

View file

@ -320,17 +320,17 @@ if True:
b'This string will not include \
backslashes or newline characters.'
b'''Multiline
b"""Multiline
String \"
'''
"""
b'''Multiline
b"""Multiline
String \'
'''
"""
b'''Multiline
b"""Multiline
String ""
'''
"""
b'''Multiline
String """
@ -346,9 +346,9 @@ String '''
b"""Multiline
String '"""
b'''Multiline
b"""Multiline
String \"\"\"
'''
"""
# String continuation

View file

@ -365,17 +365,17 @@ if True:
'This string will not include \
backslashes or newline characters.'
'''Multiline
"""Multiline
String \"
'''
"""
'''Multiline
"""Multiline
String \'
'''
"""
'''Multiline
"""Multiline
String ""
'''
"""
'''Multiline
String """
@ -391,9 +391,9 @@ String '''
"""Multiline
String '"""
'''Multiline
"""Multiline
String \"\"\"
'''
"""
# String continuation
@ -471,16 +471,16 @@ test_particular = [
# Regression test for https://github.com/astral-sh/ruff/issues/5893
x = (
'''aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'''
'''bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'''
"""aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"""
"""bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"""
)
x = (
f'''aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'''
f'''bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'''
f"""aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"""
f"""bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"""
)
x = (
b'''aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'''
b'''bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'''
b"""aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"""
b"""bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"""
)
# https://github.com/astral-sh/ruff/issues/7460

View file

@ -2461,7 +2461,7 @@ pub struct FormatOptions {
default = "false",
value_type = "bool",
example = r#"
# Enable preview style formatting
# Enable preview style formatting.
preview = true
"#
)]
@ -2469,34 +2469,40 @@ pub struct FormatOptions {
/// Whether to use 4 spaces or hard tabs for indenting code.
///
/// Defaults to 4 spaces. We care about accessibility; if you do not need tabs for accessibility, we do not recommend you use them.
/// Defaults to 4 spaces. We care about accessibility; if you do not need tabs for
/// accessibility, we do not recommend you use them.
#[option(
default = "space",
value_type = r#""space" | "tab""#,
example = r#"
# Use tabs instead of 4 space indentation
# Use tabs instead of 4 space indentation.
indent-style = "tab"
"#
)]
pub indent_style: Option<IndentStyle>,
/// Whether to prefer single `'` or double `"` quotes for strings and docstrings.
/// Whether to prefer single `'` or double `"` quotes for strings. Defaults to double quotes.
///
/// Ruff may deviate from this option if using the configured quotes would require more escaped quotes:
/// In compliance with [PEP 8](https://peps.python.org/pep-0008/) and [PEP 257](https://peps.python.org/pep-0257/),
/// Ruff prefers double quotes for multiline strings and docstrings, regardless of the
/// configured quote style.
///
/// Ruff may also deviate from this option if using the configured quotes would require
/// escaping quote characters within the string. For example, given:
///
/// ```python
/// a = "It's monday morning"
/// b = "a string without any quotes"
/// a = "a string without any quotes"
/// b = "It's monday morning"
/// ```
///
/// Ruff leaves `a` unchanged when using `quote-style = "single"` because it is otherwise
/// necessary to escape the `'` which leads to less readable code: `'It\'s monday morning'`.
/// Ruff changes the quotes of `b` to use single quotes.
/// Ruff will change `a` to use single quotes when using `quote-style = "single"`. However,
/// `a` will be unchanged, as converting to single quotes would require the inner `'` to be
/// escaped, which leads to less readable code: `'It\'s monday morning'`.
#[option(
default = r#"double"#,
value_type = r#""double" | "single""#,
example = r#"
# Prefer single quotes over double quotes
# Prefer single quotes over double quotes.
quote-style = "single"
"#
)]

2
ruff.schema.json generated
View file

@ -1207,7 +1207,7 @@
]
},
"quote-style": {
"description": "Whether to prefer single `'` or double `\"` quotes for strings and docstrings.\n\nRuff may deviate from this option if using the configured quotes would require more escaped quotes:\n\n```python a = \"It's monday morning\" b = \"a string without any quotes\" ```\n\nRuff leaves `a` unchanged when using `quote-style = \"single\"` because it is otherwise necessary to escape the `'` which leads to less readable code: `'It\\'s monday morning'`. Ruff changes the quotes of `b` to use single quotes.",
"description": "Whether to prefer single `'` or double `\"` quotes for strings. Defaults to double quotes.\n\nIn compliance with [PEP 8](https://peps.python.org/pep-0008/) and [PEP 257](https://peps.python.org/pep-0257/), Ruff prefers double quotes for multiline strings and docstrings, regardless of the configured quote style.\n\nRuff may also deviate from this option if using the configured quotes would require escaping quote characters within the string. For example, given:\n\n```python a = \"a string without any quotes\" b = \"It's monday morning\" ```\n\nRuff will change `a` to use single quotes when using `quote-style = \"single\"`. However, `a` will be unchanged, as converting to single quotes would require the inner `'` to be escaped, which leads to less readable code: `'It\\'s monday morning'`.",
"anyOf": [
{
"$ref": "#/definitions/QuoteStyle"