diff --git a/.editorconfig b/.editorconfig index 11c29680d79e7..843bd1f3ad83e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,7 +10,7 @@ indent_style = space insert_final_newline = true indent_size = 2 -[*.{rs,py}] +[*.{rs,py,pyi}] indent_size = 4 [*.snap] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/module_dangling_comment1.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/module_dangling_comment1.py new file mode 100644 index 0000000000000..5b52d9b5c51b5 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/module_dangling_comment1.py @@ -0,0 +1,3 @@ +__all__ = ["X", "XK", "Xatom", "Xcursorfont", "Xutil", "display", "error", "rdb"] + +# Shared types throughout the stub diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/module_dangling_comment2.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/module_dangling_comment2.py new file mode 100644 index 0000000000000..15d2886e54072 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/module_dangling_comment2.py @@ -0,0 +1,2 @@ +__all__ = ["X", "XK", "Xatom", "Xcursorfont", "Xutil", "display", "error", "rdb"] +# Shared types throughout the stub diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/comments.pyi b/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/comments.pyi new file mode 100644 index 0000000000000..fe1713355a7f2 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/comments.pyi @@ -0,0 +1,7 @@ +class SupportsAnext: + def __anext__(self): ... + +# Comparison protocols + +class SupportsDunderLT: + def __init__(self): ... diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/nesting.pyi b/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/nesting.pyi new file mode 100644 index 0000000000000..8fe78c4e5c726 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/nesting.pyi @@ -0,0 +1,31 @@ +"""Tests specifically for https://github.com/psf/black/issues/3861""" + +import sys + + +class OuterClassOrOtherSuite: + class Nested11: + class Nested12: + assignment = 1 + def function_definition(self): ... + + def f1(self) -> str: ... + + class Nested21: + class Nested22: + def function_definition(self): ... + assignment = 1 + + def f2(self) -> str: ... + +if sys.version_info > (3, 7): + if sys.platform == "win32": + assignment = 1 + def function_definition(self): ... + + def f1(self) -> str: ... + if sys.platform != "win32": + def function_definition(self): ... + assignment = 1 + + def f2(self) -> str: ... diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/suite.pyi b/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/suite.pyi new file mode 100644 index 0000000000000..c14b239354cb2 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/suite.pyi @@ -0,0 +1,149 @@ +"""Tests for empty line rules in stub files, mostly inspired by typeshed. +The rules are a list of nested exceptions. See also +https://github.com/psf/black/blob/c160e4b7ce30c661ac4f2dfa5038becf1b8c5c33/src/black/lines.py#L576-L744 +""" + +import sys +from typing import Self, TypeAlias, final + +if sys.version_info >= (3, 8): + class InnerClass1: ... + + class InnerClass2: + def a(self): ... + + class InnerClass3: + def a(self): ... + + class InnerClass4: ... + details: int + def f1(self, hresult: int, text: str | None, detail: int) -> None: ... + details: int + def f2(self, hresult: int, text: str | None, detail: int) -> None: ... + @final + class DecoratorInsteadOfEmptyLine: ... + + def open(device: str) -> None: ... + + # oss_mixer_device return type + def openmixer(device: str = ...) -> None: ... + def open2(device: str) -> None: ... + # oss_mixer_device2 return type + def openmixer2(device: str = ...) -> None: ... + +else: + class Slice1: ... + _Slice1: TypeAlias = Slice1 + + class Slice2: ... + _Slice2: TypeAlias = Slice2 + +class NoEmptyLinesBetweenFunctions: + def multi_line_but_only_ellipsis( + self, + mandatory_release: float | None, + ) -> None: ... + def only_ellipsis1(self) -> float: ... + def only_ellipsis2(self) -> float | None: ... + def has_impl1(self): + print(self) + return 1 + + def has_impl2(self): + print(self) + return 2 + + def no_impl4(self): ... + +class NoEmptyLinesBetweenField: + field1: int + field2: ( + # type + int + ) + field3 = 3 + field4 = ( + 1, + 2, + ) + field5 = 5 + +class FieldAndFunctionsWithOptionalEmptyLines: + details1: int + def f1(self, hresult: int, text: str | None, detail: int) -> None: ... + details2: int + def f2(self, hresult: int, text: str | None, detail: int) -> None: ... + details3: int + +class NewlinesBetweenStubInnerClasses: + def f1(self): ... + + class InnerClass1: ... + class InnerClass2: ... + + def f2(self): ... + + class InnerClass3: ... + class InnerClass4: ... + field = 1 + + class InnerClass3: ... + class InnerClass4: ... + + def f3(self): ... + @final + class DecoratorInsteadOfEmptyLine: ... + + @final + class DecoratorStillEmptyLine: ... + +class NewlinesBetweenInnerClasses: + class InnerClass1: ... + + class InnerClass2: + def a(self): ... + + class InnerClass3: + def a(self): ... + + class InnerClass4: ... + + class InnerClass5: + def a(self): ... + field1 = 1 + + class InnerClass6: + def a(self): ... + + def f1(self): ... + + class InnerClass7: + def a(self): ... + print("hi") + + class InnerClass8: + def a(self): ... + +class ComplexStatements: + # didn't match the name in the C implementation, + # meaning it is only *safe* to pass it as a keyword argument on 3.12+ + if sys.version_info >= (3, 12): + @classmethod + def fromtimestamp(cls, timestamp: float, tz: float | None = ...) -> Self: ... + else: + @classmethod + def fromtimestamp(cls, __timestamp: float, tz: float | None = ...) -> Self: ... + + @classmethod + def utcfromtimestamp(cls, __t: float) -> Self: ... + if sys.version_info >= (3, 8): + @classmethod + def now(cls, tz: float | None = None) -> Self: ... + else: + @classmethod + def now(cls, tz: None = None) -> Self: ... + @classmethod + def now2(cls, tz: float) -> Self: ... + + @classmethod + def utcnow(cls) -> Self: ... diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/top_level.pyi b/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/top_level.pyi new file mode 100644 index 0000000000000..fe08563fffc60 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/top_level.pyi @@ -0,0 +1,17 @@ +from typing import final + + +def count1(): ... +def count2(): ... +@final +def count3(): ... +@final +class LockType1: ... + +def count4(): ... + +class LockType2: ... +class LockType3: ... + +@final +class LockType4: ... diff --git a/crates/ruff_python_formatter/src/comments/format.rs b/crates/ruff_python_formatter/src/comments/format.rs index a48eb8898b48b..08a97713ea5e8 100644 --- a/crates/ruff_python_formatter/src/comments/format.rs +++ b/crates/ruff_python_formatter/src/comments/format.rs @@ -2,6 +2,7 @@ use std::borrow::Cow; use ruff_formatter::{format_args, write, FormatError, FormatOptions, SourceCode}; use ruff_python_ast::node::{AnyNodeRef, AstNode}; +use ruff_python_ast::PySourceType; use ruff_python_trivia::{lines_after, lines_after_ignoring_trivia, lines_before}; use ruff_text_size::{Ranged, TextLen, TextRange}; @@ -485,11 +486,22 @@ fn strip_comment_prefix(comment_text: &str) -> FormatResult<&str> { /// /// This builder will insert two empty lines before the comment. /// ``` -pub(crate) const fn empty_lines_before_trailing_comments( - comments: &[SourceComment], - expected: u32, -) -> FormatEmptyLinesBeforeTrailingComments { - FormatEmptyLinesBeforeTrailingComments { comments, expected } +pub(crate) fn empty_lines_before_trailing_comments<'a>( + f: &PyFormatter, + comments: &'a [SourceComment], +) -> FormatEmptyLinesBeforeTrailingComments<'a> { + // Black has different rules for stub vs. non-stub and top level vs. indented + let empty_lines = match (f.options().source_type(), f.context().node_level()) { + (PySourceType::Stub, NodeLevel::TopLevel) => 1, + (PySourceType::Stub, _) => 0, + (_, NodeLevel::TopLevel) => 2, + (_, _) => 1, + }; + + FormatEmptyLinesBeforeTrailingComments { + comments, + empty_lines, + } } #[derive(Copy, Clone, Debug)] @@ -497,7 +509,7 @@ pub(crate) struct FormatEmptyLinesBeforeTrailingComments<'a> { /// The trailing comments of the node. comments: &'a [SourceComment], /// The expected number of empty lines before the trailing comments. - expected: u32, + empty_lines: u32, } impl Format> for FormatEmptyLinesBeforeTrailingComments<'_> { @@ -508,7 +520,7 @@ impl Format> for FormatEmptyLinesBeforeTrailingComments<'_> .find(|comment| comment.line_position().is_own_line()) { let actual = lines_before(comment.start(), f.context().source()).saturating_sub(1); - for _ in actual..self.expected { + for _ in actual..self.empty_lines { write!(f, [empty_line()])?; } } diff --git a/crates/ruff_python_formatter/src/module/mod_module.rs b/crates/ruff_python_formatter/src/module/mod_module.rs index 145ad8a61bd44..54b1dbd51d464 100644 --- a/crates/ruff_python_formatter/src/module/mod_module.rs +++ b/crates/ruff_python_formatter/src/module/mod_module.rs @@ -1,9 +1,10 @@ use ruff_formatter::prelude::hard_line_break; -use ruff_formatter::write; +use ruff_formatter::{Buffer, FormatResult}; use ruff_python_ast::ModModule; -use crate::prelude::*; +use crate::comments::{trailing_comments, SourceComment}; use crate::statement::suite::SuiteKind; +use crate::{write, AsFormat, FormatNodeRule, PyFormatter}; #[derive(Default)] pub struct FormatModModule; @@ -11,13 +12,25 @@ pub struct FormatModModule; impl FormatNodeRule for FormatModModule { fn fmt_fields(&self, item: &ModModule, f: &mut PyFormatter) -> FormatResult<()> { let ModModule { range: _, body } = item; + let comments = f.context().comments().clone(); + write!( f, [ body.format().with_options(SuiteKind::TopLevel), + trailing_comments(comments.dangling(item)), // Trailing newline at the end of the file hard_line_break() ] ) } + + fn fmt_dangling_comments( + &self, + _dangling_comments: &[SourceComment], + _f: &mut PyFormatter, + ) -> FormatResult<()> { + // Handled as part of `fmt_fields` + Ok(()) + } } diff --git a/crates/ruff_python_formatter/src/statement/stmt_class_def.rs b/crates/ruff_python_formatter/src/statement/stmt_class_def.rs index fa27970b9056d..231320eac75f4 100644 --- a/crates/ruff_python_formatter/src/statement/stmt_class_def.rs +++ b/crates/ruff_python_formatter/src/statement/stmt_class_def.rs @@ -5,7 +5,6 @@ use ruff_text_size::Ranged; use crate::comments::format::empty_lines_before_trailing_comments; use crate::comments::{leading_comments, trailing_comments, SourceComment}; -use crate::context::NodeLevel; use crate::prelude::*; use crate::statement::clause::{clause_body, clause_header, ClauseHeader}; use crate::statement::suite::SuiteKind; @@ -120,7 +119,7 @@ impl FormatNodeRule for FormatStmtClassDef { // # comment // ``` // - // At the top-level, reformat as: + // At the top-level in a non-stub file, reformat as: // ```python // class Class: // ... @@ -128,15 +127,7 @@ impl FormatNodeRule for FormatStmtClassDef { // // # comment // ``` - empty_lines_before_trailing_comments( - comments.trailing(item), - if f.context().node_level() == NodeLevel::TopLevel { - 2 - } else { - 1 - }, - ) - .fmt(f) + empty_lines_before_trailing_comments(f, comments.trailing(item)).fmt(f) } fn fmt_dangling_comments( diff --git a/crates/ruff_python_formatter/src/statement/stmt_function_def.rs b/crates/ruff_python_formatter/src/statement/stmt_function_def.rs index 0a7a462282c95..df4875cddde35 100644 --- a/crates/ruff_python_formatter/src/statement/stmt_function_def.rs +++ b/crates/ruff_python_formatter/src/statement/stmt_function_def.rs @@ -5,7 +5,6 @@ use ruff_python_trivia::{SimpleTokenKind, SimpleTokenizer}; use ruff_text_size::Ranged; use crate::comments::SourceComment; -use crate::context::NodeLevel; use crate::expression::maybe_parenthesize_expression; use crate::expression::parentheses::{Parentheses, Parenthesize}; use crate::prelude::*; @@ -156,7 +155,7 @@ impl FormatNodeRule for FormatStmtFunctionDef { // # comment // ``` // - // At the top-level, reformat as: + // At the top-level in a non-stub file, reformat as: // ```python // def func(): // ... @@ -164,15 +163,7 @@ impl FormatNodeRule for FormatStmtFunctionDef { // // # comment // ``` - empty_lines_before_trailing_comments( - comments.trailing(item), - if f.context().node_level() == NodeLevel::TopLevel { - 2 - } else { - 1 - }, - ) - .fmt(f) + empty_lines_before_trailing_comments(f, comments.trailing(item)).fmt(f) } fn fmt_dangling_comments( diff --git a/crates/ruff_python_formatter/src/statement/suite.rs b/crates/ruff_python_formatter/src/statement/suite.rs index de5ab8e4dadc2..8ae33eebb39c9 100644 --- a/crates/ruff_python_formatter/src/statement/suite.rs +++ b/crates/ruff_python_formatter/src/statement/suite.rs @@ -5,7 +5,9 @@ use ruff_python_ast::{self as ast, Constant, Expr, ExprConstant, Stmt, Suite}; use ruff_python_trivia::{lines_after, lines_after_ignoring_trivia, lines_before}; use ruff_text_size::{Ranged, TextRange}; -use crate::comments::{leading_comments, trailing_comments, Comments}; +use crate::comments::{ + leading_comments, trailing_comments, Comments, LeadingDanglingTrailingComments, +}; use crate::context::{NodeLevel, WithNodeLevel}; use crate::expression::expr_constant::ExprConstantLayout; use crate::expression::string::StringLayout; @@ -69,7 +71,10 @@ impl FormatRule> for FormatSuite { // Format the first statement in the body, which often has special formatting rules. let first = match self.kind { SuiteKind::Other => { - if is_class_or_function_definition(first) && !comments.has_leading(first) { + if is_class_or_function_definition(first) + && !comments.has_leading(first) + && !source_type.is_stub() + { // Add an empty line for any nested functions or classes defined within // non-function or class compound statements, e.g., this is stable formatting: // ```python @@ -94,7 +99,10 @@ impl FormatRule> for FormatSuite { SuiteKind::Class => { if let Some(docstring) = DocstringStmt::try_from_statement(first) { - if !comments.has_leading(first) && lines_before(first.start(), source) > 1 { + if !comments.has_leading(first) + && lines_before(first.start(), source) > 1 + && !source_type.is_stub() + { // Allow up to one empty line before a class docstring, e.g., this is // stable formatting: // ```python @@ -154,50 +162,24 @@ impl FormatRule> for FormatSuite { && !preceding_comments.has_trailing_own_line()) || is_class_or_function_definition(following) { - match self.kind { - SuiteKind::TopLevel if source_type.is_stub() => { - // Preserve the empty line if the definitions are separated by a comment - if preceding_comments.has_trailing() || following_comments.has_leading() { + if source_type.is_stub() { + stub_file_empty_lines( + self.kind, + preceding, + following, + &preceding_comments, + &following_comments, + f, + )?; + } else { + match self.kind { + SuiteKind::TopLevel => { + write!(f, [empty_line(), empty_line()])?; + } + SuiteKind::Function | SuiteKind::Class | SuiteKind::Other => { empty_line().fmt(f)?; - } else { - // Two subsequent classes that both have an ellipsis only body - // ```python - // class A: ... - // class B: ... - // ``` - let class_sequences_with_ellipsis_only = - preceding.as_class_def_stmt().is_some_and(|class| { - contains_only_an_ellipsis(&class.body, f.context().comments()) - }) && following.as_class_def_stmt().is_some_and(|class| { - contains_only_an_ellipsis(&class.body, f.context().comments()) - }); - - // Two subsequent functions where the preceding has an ellipsis only body - // ```python - // def test(): ... - // def b(): a - // ``` - let function_with_ellipsis = - preceding.as_function_def_stmt().is_some_and(|function| { - contains_only_an_ellipsis( - &function.body, - f.context().comments(), - ) - }) && following.is_function_def_stmt(); - - // Don't add an empty line between two classes that have an `...` body only or after - // a function with an `...` body. Otherwise add an empty line. - if !class_sequences_with_ellipsis_only && !function_with_ellipsis { - empty_line().fmt(f)?; - } } } - SuiteKind::TopLevel => { - write!(f, [empty_line(), empty_line()])?; - } - SuiteKind::Function | SuiteKind::Class | SuiteKind::Other => { - empty_line().fmt(f)?; - } } } else if is_import_definition(preceding) && (!is_import_definition(following) || following_comments.has_leading()) @@ -345,6 +327,89 @@ impl FormatRule> for FormatSuite { } } +/// Stub files have bespoke rules for empty lines. +/// +/// These rules are ported from black (preview mode at time of writing) using the stubs test case: +/// +fn stub_file_empty_lines( + kind: SuiteKind, + preceding: &Stmt, + following: &Stmt, + preceding_comments: &LeadingDanglingTrailingComments, + following_comments: &LeadingDanglingTrailingComments, + f: &mut PyFormatter, +) -> FormatResult<()> { + let source = f.context().source(); + // Preserve the empty line if the definitions are separated by a comment + let empty_line_condition = preceding_comments.has_trailing() + || following_comments.has_leading() + || !stub_suite_can_omit_empty_line(preceding, following, f); + match kind { + SuiteKind::TopLevel => { + if empty_line_condition { + empty_line().fmt(f) + } else { + hard_line_break().fmt(f) + } + } + SuiteKind::Class | SuiteKind::Other | SuiteKind::Function => { + if empty_line_condition && lines_after_ignoring_trivia(preceding.end(), source) > 1 { + empty_line().fmt(f) + } else { + hard_line_break().fmt(f) + } + } + } +} + +/// Only a function to compute it lazily +fn stub_suite_can_omit_empty_line(preceding: &Stmt, following: &Stmt, f: &PyFormatter) -> bool { + // Two subsequent class definitions that both have an ellipsis only body + // ```python + // class A: ... + // class B: ... + // + // @decorator + // class C: ... + // ``` + let class_sequences_with_ellipsis_only = preceding + .as_class_def_stmt() + .is_some_and(|class| contains_only_an_ellipsis(&class.body, f.context().comments())) + && following.as_class_def_stmt().is_some_and(|class| { + contains_only_an_ellipsis(&class.body, f.context().comments()) + && class.decorator_list.is_empty() + }); + + // Black for some reasons accepts decorators in place of empty lines + // ```python + // def _count1(): ... + // @final + // class LockType1: ... + // + // def _count2(): ... + // + // class LockType2: ... + // ``` + let class_decorator_instead_of_empty_line = preceding.is_function_def_stmt() + && following + .as_class_def_stmt() + .is_some_and(|class| !class.decorator_list.is_empty()); + + // A function definition following a stub function definition + // ```python + // def test(): ... + // def b(): a + // ``` + let function_with_ellipsis = preceding + .as_function_def_stmt() + .is_some_and(|function| contains_only_an_ellipsis(&function.body, f.context().comments())) + && following.is_function_def_stmt(); + + class_sequences_with_ellipsis_only + || class_decorator_instead_of_empty_line + || function_with_ellipsis +} + /// Returns `true` if a function or class body contains only an ellipsis with no comments. pub(crate) fn contains_only_an_ellipsis(body: &[Stmt], comments: &Comments) -> bool { match body { diff --git a/crates/ruff_python_formatter/tests/snapshots/format@module_dangling_comment1.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@module_dangling_comment1.py.snap new file mode 100644 index 0000000000000..eb28317e4a977 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@module_dangling_comment1.py.snap @@ -0,0 +1,20 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/module_dangling_comment1.py +--- +## Input +```py +__all__ = ["X", "XK", "Xatom", "Xcursorfont", "Xutil", "display", "error", "rdb"] + +# Shared types throughout the stub +``` + +## Output +```py +__all__ = ["X", "XK", "Xatom", "Xcursorfont", "Xutil", "display", "error", "rdb"] + +# Shared types throughout the stub +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@module_dangling_comment2.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@module_dangling_comment2.py.snap new file mode 100644 index 0000000000000..c419b524cadf6 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@module_dangling_comment2.py.snap @@ -0,0 +1,18 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/module_dangling_comment2.py +--- +## Input +```py +__all__ = ["X", "XK", "Xatom", "Xcursorfont", "Xutil", "display", "error", "rdb"] +# Shared types throughout the stub +``` + +## Output +```py +__all__ = ["X", "XK", "Xatom", "Xcursorfont", "Xutil", "display", "error", "rdb"] +# Shared types throughout the stub +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@stub_files__comments.pyi.snap b/crates/ruff_python_formatter/tests/snapshots/format@stub_files__comments.pyi.snap new file mode 100644 index 0000000000000..beb8e4a11eaae --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@stub_files__comments.pyi.snap @@ -0,0 +1,28 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/comments.pyi +--- +## Input +```py +class SupportsAnext: + def __anext__(self): ... + +# Comparison protocols + +class SupportsDunderLT: + def __init__(self): ... +``` + +## Output +```py +class SupportsAnext: + def __anext__(self): ... + +# Comparison protocols + +class SupportsDunderLT: + def __init__(self): ... +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@stub_files__nesting.pyi.snap b/crates/ruff_python_formatter/tests/snapshots/format@stub_files__nesting.pyi.snap new file mode 100644 index 0000000000000..ba86351d1ad5a --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@stub_files__nesting.pyi.snap @@ -0,0 +1,75 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/nesting.pyi +--- +## Input +```py +"""Tests specifically for https://github.com/psf/black/issues/3861""" + +import sys + + +class OuterClassOrOtherSuite: + class Nested11: + class Nested12: + assignment = 1 + def function_definition(self): ... + + def f1(self) -> str: ... + + class Nested21: + class Nested22: + def function_definition(self): ... + assignment = 1 + + def f2(self) -> str: ... + +if sys.version_info > (3, 7): + if sys.platform == "win32": + assignment = 1 + def function_definition(self): ... + + def f1(self) -> str: ... + if sys.platform != "win32": + def function_definition(self): ... + assignment = 1 + + def f2(self) -> str: ... +``` + +## Output +```py +"""Tests specifically for https://github.com/psf/black/issues/3861""" + +import sys + +class OuterClassOrOtherSuite: + class Nested11: + class Nested12: + assignment = 1 + def function_definition(self): ... + + def f1(self) -> str: ... + + class Nested21: + class Nested22: + def function_definition(self): ... + assignment = 1 + + def f2(self) -> str: ... + +if sys.version_info > (3, 7): + if sys.platform == "win32": + assignment = 1 + def function_definition(self): ... + + def f1(self) -> str: ... + if sys.platform != "win32": + def function_definition(self): ... + assignment = 1 + + def f2(self) -> str: ... +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@stub_files__suite.pyi.snap b/crates/ruff_python_formatter/tests/snapshots/format@stub_files__suite.pyi.snap new file mode 100644 index 0000000000000..cf4b0523c03c0 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@stub_files__suite.pyi.snap @@ -0,0 +1,312 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/suite.pyi +--- +## Input +```py +"""Tests for empty line rules in stub files, mostly inspired by typeshed. +The rules are a list of nested exceptions. See also +https://github.com/psf/black/blob/c160e4b7ce30c661ac4f2dfa5038becf1b8c5c33/src/black/lines.py#L576-L744 +""" + +import sys +from typing import Self, TypeAlias, final + +if sys.version_info >= (3, 8): + class InnerClass1: ... + + class InnerClass2: + def a(self): ... + + class InnerClass3: + def a(self): ... + + class InnerClass4: ... + details: int + def f1(self, hresult: int, text: str | None, detail: int) -> None: ... + details: int + def f2(self, hresult: int, text: str | None, detail: int) -> None: ... + @final + class DecoratorInsteadOfEmptyLine: ... + + def open(device: str) -> None: ... + + # oss_mixer_device return type + def openmixer(device: str = ...) -> None: ... + def open2(device: str) -> None: ... + # oss_mixer_device2 return type + def openmixer2(device: str = ...) -> None: ... + +else: + class Slice1: ... + _Slice1: TypeAlias = Slice1 + + class Slice2: ... + _Slice2: TypeAlias = Slice2 + +class NoEmptyLinesBetweenFunctions: + def multi_line_but_only_ellipsis( + self, + mandatory_release: float | None, + ) -> None: ... + def only_ellipsis1(self) -> float: ... + def only_ellipsis2(self) -> float | None: ... + def has_impl1(self): + print(self) + return 1 + + def has_impl2(self): + print(self) + return 2 + + def no_impl4(self): ... + +class NoEmptyLinesBetweenField: + field1: int + field2: ( + # type + int + ) + field3 = 3 + field4 = ( + 1, + 2, + ) + field5 = 5 + +class FieldAndFunctionsWithOptionalEmptyLines: + details1: int + def f1(self, hresult: int, text: str | None, detail: int) -> None: ... + details2: int + def f2(self, hresult: int, text: str | None, detail: int) -> None: ... + details3: int + +class NewlinesBetweenStubInnerClasses: + def f1(self): ... + + class InnerClass1: ... + class InnerClass2: ... + + def f2(self): ... + + class InnerClass3: ... + class InnerClass4: ... + field = 1 + + class InnerClass3: ... + class InnerClass4: ... + + def f3(self): ... + @final + class DecoratorInsteadOfEmptyLine: ... + + @final + class DecoratorStillEmptyLine: ... + +class NewlinesBetweenInnerClasses: + class InnerClass1: ... + + class InnerClass2: + def a(self): ... + + class InnerClass3: + def a(self): ... + + class InnerClass4: ... + + class InnerClass5: + def a(self): ... + field1 = 1 + + class InnerClass6: + def a(self): ... + + def f1(self): ... + + class InnerClass7: + def a(self): ... + print("hi") + + class InnerClass8: + def a(self): ... + +class ComplexStatements: + # didn't match the name in the C implementation, + # meaning it is only *safe* to pass it as a keyword argument on 3.12+ + if sys.version_info >= (3, 12): + @classmethod + def fromtimestamp(cls, timestamp: float, tz: float | None = ...) -> Self: ... + else: + @classmethod + def fromtimestamp(cls, __timestamp: float, tz: float | None = ...) -> Self: ... + + @classmethod + def utcfromtimestamp(cls, __t: float) -> Self: ... + if sys.version_info >= (3, 8): + @classmethod + def now(cls, tz: float | None = None) -> Self: ... + else: + @classmethod + def now(cls, tz: None = None) -> Self: ... + @classmethod + def now2(cls, tz: float) -> Self: ... + + @classmethod + def utcnow(cls) -> Self: ... +``` + +## Output +```py +"""Tests for empty line rules in stub files, mostly inspired by typeshed. +The rules are a list of nested exceptions. See also +https://github.com/psf/black/blob/c160e4b7ce30c661ac4f2dfa5038becf1b8c5c33/src/black/lines.py#L576-L744 +""" + +import sys +from typing import Self, TypeAlias, final + +if sys.version_info >= (3, 8): + class InnerClass1: ... + + class InnerClass2: + def a(self): ... + + class InnerClass3: + def a(self): ... + + class InnerClass4: ... + details: int + def f1(self, hresult: int, text: str | None, detail: int) -> None: ... + details: int + def f2(self, hresult: int, text: str | None, detail: int) -> None: ... + @final + class DecoratorInsteadOfEmptyLine: ... + + def open(device: str) -> None: ... + + # oss_mixer_device return type + def openmixer(device: str = ...) -> None: ... + def open2(device: str) -> None: ... + # oss_mixer_device2 return type + def openmixer2(device: str = ...) -> None: ... + +else: + class Slice1: ... + _Slice1: TypeAlias = Slice1 + + class Slice2: ... + _Slice2: TypeAlias = Slice2 + +class NoEmptyLinesBetweenFunctions: + def multi_line_but_only_ellipsis( + self, + mandatory_release: float | None, + ) -> None: ... + def only_ellipsis1(self) -> float: ... + def only_ellipsis2(self) -> float | None: ... + def has_impl1(self): + print(self) + return 1 + + def has_impl2(self): + print(self) + return 2 + + def no_impl4(self): ... + +class NoEmptyLinesBetweenField: + field1: int + field2: ( + # type + int + ) + field3 = 3 + field4 = ( + 1, + 2, + ) + field5 = 5 + +class FieldAndFunctionsWithOptionalEmptyLines: + details1: int + def f1(self, hresult: int, text: str | None, detail: int) -> None: ... + details2: int + def f2(self, hresult: int, text: str | None, detail: int) -> None: ... + details3: int + +class NewlinesBetweenStubInnerClasses: + def f1(self): ... + + class InnerClass1: ... + class InnerClass2: ... + + def f2(self): ... + + class InnerClass3: ... + class InnerClass4: ... + field = 1 + + class InnerClass3: ... + class InnerClass4: ... + + def f3(self): ... + @final + class DecoratorInsteadOfEmptyLine: ... + + @final + class DecoratorStillEmptyLine: ... + +class NewlinesBetweenInnerClasses: + class InnerClass1: ... + + class InnerClass2: + def a(self): ... + + class InnerClass3: + def a(self): ... + + class InnerClass4: ... + + class InnerClass5: + def a(self): ... + field1 = 1 + + class InnerClass6: + def a(self): ... + + def f1(self): ... + + class InnerClass7: + def a(self): ... + print("hi") + + class InnerClass8: + def a(self): ... + +class ComplexStatements: + # didn't match the name in the C implementation, + # meaning it is only *safe* to pass it as a keyword argument on 3.12+ + if sys.version_info >= (3, 12): + @classmethod + def fromtimestamp(cls, timestamp: float, tz: float | None = ...) -> Self: ... + else: + @classmethod + def fromtimestamp(cls, __timestamp: float, tz: float | None = ...) -> Self: ... + + @classmethod + def utcfromtimestamp(cls, __t: float) -> Self: ... + if sys.version_info >= (3, 8): + @classmethod + def now(cls, tz: float | None = None) -> Self: ... + else: + @classmethod + def now(cls, tz: None = None) -> Self: ... + @classmethod + def now2(cls, tz: float) -> Self: ... + + @classmethod + def utcnow(cls) -> Self: ... +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@stub_files__top_level.pyi.snap b/crates/ruff_python_formatter/tests/snapshots/format@stub_files__top_level.pyi.snap new file mode 100644 index 0000000000000..99c29acbdd457 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@stub_files__top_level.pyi.snap @@ -0,0 +1,47 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/stub_files/top_level.pyi +--- +## Input +```py +from typing import final + + +def count1(): ... +def count2(): ... +@final +def count3(): ... +@final +class LockType1: ... + +def count4(): ... + +class LockType2: ... +class LockType3: ... + +@final +class LockType4: ... +``` + +## Output +```py +from typing import final + +def count1(): ... +def count2(): ... +@final +def count3(): ... +@final +class LockType1: ... + +def count4(): ... + +class LockType2: ... +class LockType3: ... + +@final +class LockType4: ... +``` + + +