Skip to content

Conversation

krikera
Copy link
Contributor

@krikera krikera commented Jun 23, 2025

Summary

Fixes missing blank lines before decorated classes that follow functions in .pyi (stub) files.

Problem: When a decorated class follows a function definition in a stub file, the formatter was incorrectly omitting the blank line before the class, resulting in formatting like:

def hello(): ...
@decorator
class A: ...

Solution: Removed the class_decorator_instead_of_empty_line condition that was preventing blank lines from being inserted in this scenario. This condition was based on a Black quirk that was actually a bug and shouldn't be replicated.

The fix is gated behind preview mode to ensure backward compatibility. When preview mode is enabled, decorated classes following functions now correctly get blank lines:

def hello(): ...

@decorator
class A: ...

This brings Ruff's behavior in line with proper Python style guidelines and fixes the inconsistency where blank lines were added for other statement types but not decorated classes.

Test Plan

Manual Testing:

  • Created test files with functions followed by decorated classes
  • Verified fix works with --preview flag: cargo run --bin ruff_python_formatter -- --emit stdout --preview test.pyi
  • Verified backward compatibility without --preview: old behavior preserved
  • Tested with various decorator types (@lambda, @final, multiple decorators)

Automated Testing:

  • Added new test case decorated_class_after_function.pyi with preview options
  • Ran cargo test -p ruff_python_formatter - all tests pass
  • Snapshot tests show correct formatting changes for both new and existing test cases
  • Existing functionality unaffected (no regressions)

Preview Mode Integration:

  • Added is_blank_line_before_decorated_class_in_stub_enabled() preview function
  • Verified condition is only applied when preview mode is disabled
  • Confirmed proper gating follows established preview patterns

Closes #18865

@krikera krikera requested a review from MichaReiser as a code owner June 23, 2025 10:20
@MichaReiser MichaReiser added formatter Related to the formatter preview Related to preview mode features labels Jun 23, 2025
@MichaReiser
Copy link
Member

The code changes look good. Can you run cargo fmt and have a look through the existing tests that changed.

@MichaReiser MichaReiser changed the title [formatter] Fix missing blank lines before decorated classes in .pyi files (#18865) [formatter] Fix missing blank lines before decorated classes in .pyi files Jun 24, 2025
@MichaReiser
Copy link
Member

Thank you, this is great

Copy link
Contributor

ruff-ecosystem results

Formatter (stable)

✅ ecosystem check detected no format changes.

Formatter (preview)

ℹ️ ecosystem check detected format changes. (+14 -0 lines in 5 files in 1 projects; 54 projects unchanged)

python/typeshed (+14 -0 lines across 5 files)

ruff format --preview

stdlib/_frozen_importlib_external.pyi~L36

     loader: LoaderProtocol | None = None,
     submodule_search_locations: list[str] | None = ...,
 ) -> importlib.machinery.ModuleSpec | None: ...
+
 @deprecated(
     "Deprecated as of Python 3.6: Use site configuration instead. "
     "Future versions of Python may not enable this finder by default."

stdlib/abc.pyi~L29

     def register(cls: ABCMeta, subclass: type[_T]) -> type[_T]: ...
 
 def abstractmethod(funcobj: _FuncT) -> _FuncT: ...
+
 @deprecated("Use 'classmethod' with 'abstractmethod' instead")
 class abstractclassmethod(classmethod[_T, _P, _R_co]):
     __isabstractmethod__: Literal[True]

stdlib/os/init.pyi~L872

 def listdir(path: BytesPath) -> list[bytes]: ...
 @overload
 def listdir(path: int) -> list[str]: ...
+
 @final
 class DirEntry(Generic[AnyStr]):
     # This is what the scandir iterator yields

stdlib/os/init.pyi~L945

 def getppid() -> int: ...
 def strerror(code: int, /) -> str: ...
 def umask(mask: int, /) -> int: ...
+
 @final
 class uname_result(structseq[str], tuple[str, str, str, str, str]):
     if sys.version_info >= (3, 10):

stdlib/os/init.pyi~L1243

     src: StrOrBytesPath, dst: StrOrBytesPath, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None
 ) -> None: ...
 def rmdir(path: StrOrBytesPath, *, dir_fd: int | None = None) -> None: ...
+
 @final
 class _ScandirIterator(Generic[AnyStr]):
     def __del__(self) -> None: ...

stdlib/os/init.pyi~L1403

     def spawnve(mode: int, path: StrOrBytesPath, argv: _ExecVArgs, env: _ExecEnv, /) -> int: ...
 
 def system(command: StrOrBytesPath) -> int: ...
+
 @final
 class times_result(structseq[float], tuple[float, float, float, float, float]):
     if sys.version_info >= (3, 10):

stdlib/typing.pyi~L150

 class _Final: ...
 
 def final(f: _T) -> _T: ...
+
 @final
 class TypeVar:
     @property

stdlib/typing.pyi~L458

 # Abstract base classes.
 
 def runtime_checkable(cls: _TC) -> _TC: ...
+
 @runtime_checkable
 class SupportsInt(Protocol, metaclass=ABCMeta):
     @abstractmethod

stubs/gdb/gdb/init.pyi~L142

 # Types
 
 def lookup_type(name: str, block: Block = ...) -> Type: ...
+
 @final
 class Type(Mapping[str, Field]):
     alignof: int

stubs/gdb/gdb/init.pyi~L333

 class Thread(threading.Thread): ...
 
 def selected_thread() -> InferiorThread: ...
+
 @final
 class InferiorThread:
     name: str | None

stubs/gdb/gdb/init.pyi~L466

 
 def current_progspace() -> Progspace | None: ...
 def progspaces() -> Sequence[Progspace]: ...
+
 @final
 class Progspace:
     executable_filename: str | None

stubs/gdb/gdb/init.pyi~L489

 def current_objfile() -> Objfile | None: ...
 def objfiles() -> list[Objfile]: ...
 def lookup_objfile(name: str, by_build_id: bool = ...) -> Objfile | None: ...
+
 @final
 class Objfile:
     filename: str | None

stubs/gdb/gdb/init.pyi~L554

 # Blocks
 
 def block_for_pc(pc: int) -> Block | None: ...
+
 @final
 class Block:
     start: int

stubs/gdb/gdb/init.pyi~L580

 def lookup_global_symbol(name: str, domain: int = ...) -> Symbol | None: ...
 def lookup_static_symbol(name: str, domain: int = ...) -> Symbol | None: ...
 def lookup_static_symbols(name: str, domain: int = ...) -> list[Symbol]: ...
+
 @final
 class Symbol:
     type: Type | None

@MichaReiser MichaReiser merged commit 47653ca into astral-sh:main Jun 24, 2025
35 checks passed
dcreager added a commit that referenced this pull request Jun 24, 2025
* main:
  [ty] Fix false positives when subscripting an object inferred as having an `Intersection` type (#18920)
  [`flake8-use-pathlib`] Add autofix for `PTH202` (#18763)
  [ty] Add relative import completion tests
  [ty] Clarify what "cursor" means
  [ty] Add a cursor test builder
  [ty] Enforce sort order of completions (#18917)
  [formatter] Fix missing blank lines before decorated classes in .pyi files (#18888)
  Apply fix availability and applicability when adding to `DiagnosticGuard` and remove `NoqaCode::rule` (#18834)
  py-fuzzer: allow relative executable paths (#18915)
  [ty] Change `environment.root` to accept multiple paths (#18913)
  [ty] Rename `src.root` setting to `environment.root` (#18760)
  Use file path for detecting package root (#18914)
  Consider virtual path for various server actions (#18910)
  [ty] Introduce `UnionType::try_from_elements` and `UnionType::try_map` (#18911)
  [ty] Support narrowing on `isinstance()`/`issubclass()` if the second argument is a dynamic, intersection, union or typevar type (#18900)
  [ty] Add decorator check for implicit attribute assignments (#18587)
  [`ruff`] Trigger `RUF037` for empty string and byte strings (#18862)
  [ty] Avoid duplicate diagnostic in unpacking (#18897)
  [`pyupgrade`] Extend version detection to include `sys.version_info.major` (`UP036`) (#18633)
  [`ruff`] Frozen Dataclass default should be valid (`RUF009`) (#18735)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
formatter Related to the formatter preview Related to preview mode features
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Decorated classes below functions aren't separated with blank lines in .pyi files
2 participants