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

View file

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

View file

@ -2461,7 +2461,7 @@ pub struct FormatOptions {
default = "false", default = "false",
value_type = "bool", value_type = "bool",
example = r#" example = r#"
# Enable preview style formatting # Enable preview style formatting.
preview = true preview = true
"# "#
)] )]
@ -2469,34 +2469,40 @@ pub struct FormatOptions {
/// Whether to use 4 spaces or hard tabs for indenting code. /// 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( #[option(
default = "space", default = "space",
value_type = r#""space" | "tab""#, value_type = r#""space" | "tab""#,
example = r#" example = r#"
# Use tabs instead of 4 space indentation # Use tabs instead of 4 space indentation.
indent-style = "tab" indent-style = "tab"
"# "#
)] )]
pub indent_style: Option<IndentStyle>, 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 /// ```python
/// a = "It's monday morning" /// a = "a string without any quotes"
/// b = "a string without any quotes" /// b = "It's monday morning"
/// ``` /// ```
/// ///
/// Ruff leaves `a` unchanged when using `quote-style = "single"` because it is otherwise /// Ruff will change `a` to use single quotes when using `quote-style = "single"`. However,
/// necessary to escape the `'` which leads to less readable code: `'It\'s monday morning'`. /// `a` will be unchanged, as converting to single quotes would require the inner `'` to be
/// Ruff changes the quotes of `b` to use single quotes. /// escaped, which leads to less readable code: `'It\'s monday morning'`.
#[option( #[option(
default = r#"double"#, default = r#"double"#,
value_type = r#""double" | "single""#, value_type = r#""double" | "single""#,
example = r#" example = r#"
# Prefer single quotes over double quotes # Prefer single quotes over double quotes.
quote-style = "single" quote-style = "single"
"# "#
)] )]

2
ruff.schema.json generated
View file

@ -1207,7 +1207,7 @@
] ]
}, },
"quote-style": { "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": [ "anyOf": [
{ {
"$ref": "#/definitions/QuoteStyle" "$ref": "#/definitions/QuoteStyle"