diff --git a/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC201_sphinx.py b/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC201_sphinx.py new file mode 100644 index 0000000000000..2ba257368ba84 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC201_sphinx.py @@ -0,0 +1,60 @@ +# DOC201 +def foo(num: int) -> str: + """ + Do something + + :param num: A number + :type num: int + """ + return 'test' + + +# OK +def foo(num: int) -> str: + """ + Do something + + :param num: A number + :type num: int + :return: A string + :rtype: str + """ + return 'test' + + +class Bar: + + # OK + def foo(self) -> str: + """ + Do something + + :param num: A number + :type num: int + :return: A string + :rtype: str + """ + return 'test' + + + # DOC201 + def bar(self) -> str: + """ + Do something + + :param num: A number + :type num: int + """ + return 'test' + + + # OK + @property + def baz(self) -> str: + """ + Do something + + :param num: A number + :type num: int + """ + return 'test' diff --git a/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC202_sphinx.py b/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC202_sphinx.py new file mode 100644 index 0000000000000..45a08d8a82ef9 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC202_sphinx.py @@ -0,0 +1,48 @@ +# OK +def foo(num: int) -> str: + """ + Do something + + :param num: A number + :type num: int + """ + print('test') + + +# DOC202 +def foo(num: int) -> str: + """ + Do something + + :param num: A number + :type num: int + :return: A string + :rtype: str + """ + print('test') + + +class Bar: + + # DOC202 + def foo(self) -> str: + """ + Do something + + :param num: A number + :type num: int + :return: A string + :rtype: str + """ + print('test') + + + # OK + def bar(self) -> str: + """ + Do something + + :param num: A number + :type num: int + """ + print('test') diff --git a/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC402_sphinx.py b/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC402_sphinx.py new file mode 100644 index 0000000000000..df7f776517699 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC402_sphinx.py @@ -0,0 +1,126 @@ +# DOC402 +def foo(num: int) -> str: + """ + Do something + + :param num: A number + :type num: int + """ + yield 'test' + + +# OK +def foo(num: int) -> str: + """ + Do something + + :param num: A number + :type num: int + :yield: A string + :rtype: str + """ + yield 'test' + + +class Bar: + + # OK + def foo(self) -> str: + """ + Do something + + :param num: A number + :type num: int + :yield: A string + :rtype: str + """ + yield 'test' + + + # DOC402 + def bar(self) -> str: + """ + Do something + + :param num: A number + :type num: int + """ + yield 'test' + + +import typing + + +# OK +def foo() -> typing.Generator[None, None, None]: + """ + Do something + """ + yield None + + +# OK +def foo() -> typing.Generator[None, None, None]: + """ + Do something + """ + yield + + +# DOC402 +def foo() -> typing.Generator[int | None, None, None]: + """ + Do something + """ + yield None + yield 1 + + +# DOC402 +def foo() -> typing.Generator[int, None, None]: + """ + Do something + """ + yield None + + +# OK +def foo(): + """ + Do something + """ + yield None + + +# OK +def foo(): + """ + Do something + """ + yield + + +# DOC402 +def foo(): + """ + Do something + """ + yield None + yield 1 + + +# DOC402 +def foo(): + """ + Do something + """ + yield 1 + yield + + +# DOC402 +def bar() -> typing.Iterator[int | None]: + """ + Do something + """ + yield diff --git a/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC403_sphinx.py b/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC403_sphinx.py new file mode 100644 index 0000000000000..8eb55389a4dda --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC403_sphinx.py @@ -0,0 +1,91 @@ +# OK +def foo(num: int) -> str: + """ + Do something + + :param num: A number + :type num: int + """ + print('test') + + +# DOC403 +def foo(num: int) -> str: + """ + Do something + + :param num: A number + :type num: int + :yield: A string + :rtype: str + """ + print('test') + + +class Bar: + + # DOC403 + def foo(self) -> str: + """ + Do something + + :param num: A number + :type num: int + :yield: A string + :rtype: str + """ + print('test') + + + # OK + def bar(self) -> str: + """ + Do something + + :param num: A number + :type num: int + """ + print('test') + + +import typing + + +# OK +def foo() -> typing.Generator[None, None, None]: + """ + Do something + + :yield: When X. + """ + yield None + + +# OK +def foo() -> typing.Generator[None, None, None]: + """ + Do something + + :yield: When X. + """ + yield + + +# OK +def foo(): + """ + Do something + + :yield: When X. + """ + yield None + + +# OK +def foo(): + """ + Do something + + :yield: When X. + """ + yield diff --git a/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC501_sphinx.py b/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC501_sphinx.py new file mode 100644 index 0000000000000..2e66b8ce70488 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC501_sphinx.py @@ -0,0 +1,115 @@ +class FasterThanLightError(Exception): + ... + + +# OK +def calculate_speed(distance: float, time: float) -> float: + """ + Calculate speed as distance divided by time. + + :param distance: Distance traveled. + :type distance: float + :param time: Time spent traveling. + :type time: float + :return: Speed as distance divided by time. + :rtype: float + :raises FasterThanLightError: If speed is greater than the speed of light. + """ + try: + return distance / time + except ZeroDivisionError as exc: + raise FasterThanLightError from exc + + +# DOC501 +def calculate_speed(distance: float, time: float) -> float: + """ + Calculate speed as distance divided by time. + + :param distance: Distance traveled. + :type distance: float + :param time: Time spent traveling. + :type time: float + :return: Speed as distance divided by time. + :rtype: float + """ + try: + return distance / time + except ZeroDivisionError as exc: + raise FasterThanLightError from exc + + +# DOC501 +def calculate_speed(distance: float, time: float) -> float: + """ + Calculate speed as distance divided by time. + + :param distance: Distance traveled. + :type distance: float + :param time: Time spent traveling. + :type time: float + :return: Speed as distance divided by time. + :rtype: float + """ + try: + return distance / time + except ZeroDivisionError as exc: + raise FasterThanLightError from exc + except: + raise ValueError + + +# DOC501 +def calculate_speed(distance: float, time: float) -> float: + """ + Calculate speed as distance divided by time. + + :param distance: Distance traveled. + :type distance: float + :param time: Time spent traveling. + :type time: float + :return: Speed as distance divided by time. + :rtype: float + :raises ZeroDivisionError: If attempting to divide by zero. + """ + try: + return distance / time + except ZeroDivisionError: + print("Oh no, why would you divide something by zero?") + raise + except TypeError: + print("Not a number? Shame on you!") + raise + + +# This is fine +def calculate_speed(distance: float, time: float) -> float: + """ + Calculate speed as distance divided by time. + + :param distance: Distance traveled. + :type distance: float + :param time: Time spent traveling. + :type time: float + :return: Speed as distance divided by time. + :rtype: float + """ + try: + return distance / time + except Exception as e: + print(f"Oh no, we encountered {e}") + raise + + +def foo(): + """Foo. + + :return: 42 + :rtype: int + """ + if True: + raise TypeError # DOC501 + else: + raise TypeError # no DOC501 here because we already emitted a diagnostic for the earlier `raise TypeError` + raise ValueError # DOC501 + return 42 diff --git a/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC502_sphinx.py b/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC502_sphinx.py new file mode 100644 index 0000000000000..a41c29650f456 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pydoclint/DOC502_sphinx.py @@ -0,0 +1,80 @@ +class FasterThanLightError(Exception): + ... + + +# DOC502 +def calculate_speed(distance: float, time: float) -> float: + """ + Calculate speed as distance divided by time. + + :param distance: Distance traveled. + :type distance: float + :param time: Time spent traveling. + :type time: float + :return: Speed as distance divided by time. + :rtype: float + :raises FasterThanLightError: If speed is greater than the speed of light. + """ + return distance / time + + +# DOC502 +def calculate_speed(distance: float, time: float) -> float: + """ + Calculate speed as distance divided by time. + + :param distance: Distance traveled. + :type distance: float + :param time: Time spent traveling. + :type time: float + :return: Speed as distance divided by time. + :rtype: float + :raises FasterThanLightError: If speed is greater than the speed of light. + :raises DivisionByZero: If attempting to divide by zero. + """ + return distance / time + + +# DOC502 +def calculate_speed(distance: float, time: float) -> float: + """ + Calculate speed as distance divided by time. + + :param distance: Distance traveled. + :type distance: float + :param time: Time spent traveling. + :type time: float + :return: Speed as distance divided by time. + :rtype: float + :raises FasterThanLightError: If speed is greater than the speed of light. + :raises DivisionByZero: If attempting to divide by zero. + """ + try: + return distance / time + except ZeroDivisionError as exc: + raise FasterThanLightError from exc + + +# This is fine +def calculate_speed(distance: float, time: float) -> float: + """Calculate speed as distance divided by time. + + Calculate speed as distance divided by time. + + :param distance: Distance traveled. + :type distance: float + :param time: Time spent traveling. + :type time: float + :return: Speed as distance divided by time. + :rtype: float + :raises TypeError: If one or both of the parameters is not a number. + :raises ZeroDivisionError: If attempting to divide by zero. + """ + try: + return distance / time + except ZeroDivisionError: + print("Oh no, why would you divide something by zero?") + raise + except TypeError: + print("Not a number? Shame on you!") + raise diff --git a/crates/ruff_linter/resources/test/fixtures/pydocstyle/sections.py b/crates/ruff_linter/resources/test/fixtures/pydocstyle/sections.py index d04c38a74bff9..8c9b4a4f25cc5 100644 --- a/crates/ruff_linter/resources/test/fixtures/pydocstyle/sections.py +++ b/crates/ruff_linter/resources/test/fixtures/pydocstyle/sections.py @@ -619,3 +619,16 @@ def another_valid_google_style_docstring(a: str) -> str: """ return a + + +def foo(dag_id: str, keep_records_in_log: bool = True) -> int: + """ + Delete a DAG by a dag_id. + + :param dag_id: the dag_id of the DAG to delete + :param keep_records_in_log: whether keep records of the given dag_id + in the Log table in the backend database (for reasons like auditing). + The default value is True. + :return: count of deleted dags + """ + return 0 diff --git a/crates/ruff_linter/src/checkers/ast/analyze/definitions.rs b/crates/ruff_linter/src/checkers/ast/analyze/definitions.rs index ef2434b3e6643..2ddf1efd168ee 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/definitions.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/definitions.rs @@ -313,6 +313,7 @@ pub(crate) fn definitions(checker: &mut Checker) { let section_contexts = pydocstyle::helpers::get_section_contexts( &docstring, checker.settings.pydocstyle.convention(), + checker, ); if enforce_sections { diff --git a/crates/ruff_linter/src/docstrings/mod.rs b/crates/ruff_linter/src/docstrings/mod.rs index 7715beea865ba..5de7fe82ed18a 100644 --- a/crates/ruff_linter/src/docstrings/mod.rs +++ b/crates/ruff_linter/src/docstrings/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod extraction; pub(crate) mod google; pub(crate) mod numpy; pub(crate) mod sections; +pub(crate) mod sphinx; pub(crate) mod styles; #[derive(Debug)] diff --git a/crates/ruff_linter/src/docstrings/sections.rs b/crates/ruff_linter/src/docstrings/sections.rs index 1068a0db07535..520d9fdb5b1f6 100644 --- a/crates/ruff_linter/src/docstrings/sections.rs +++ b/crates/ruff_linter/src/docstrings/sections.rs @@ -1,7 +1,9 @@ use std::fmt::{Debug, Formatter}; use std::iter::FusedIterator; -use ruff_python_ast::docstrings::{leading_space, leading_words}; +use ruff_python_ast::docstrings::{ + leading_space, leading_space_and_colon, leading_words, sphinx_section_name, +}; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use strum_macros::EnumIter; @@ -31,17 +33,20 @@ pub(crate) enum SectionKind { Notes, OtherArgs, OtherArguments, - OtherParams, OtherParameters, + OtherParams, + Param, Parameters, Raises, References, Return, Returns, + RType, SeeAlso, ShortSummary, Tip, Todo, + Type, Warning, Warnings, Warns, @@ -71,17 +76,20 @@ impl SectionKind { "notes" => Some(Self::Notes), "other args" => Some(Self::OtherArgs), "other arguments" => Some(Self::OtherArguments), - "other params" => Some(Self::OtherParams), "other parameters" => Some(Self::OtherParameters), + "other params" => Some(Self::OtherParams), + "param" => Some(Self::Param), "parameters" => Some(Self::Parameters), "raises" => Some(Self::Raises), "references" => Some(Self::References), "return" => Some(Self::Return), "returns" => Some(Self::Returns), + "rtype" => Some(Self::RType), "see also" => Some(Self::SeeAlso), "short summary" => Some(Self::ShortSummary), "tip" => Some(Self::Tip), "todo" => Some(Self::Todo), + "type" => Some(Self::Type), "warning" => Some(Self::Warning), "warnings" => Some(Self::Warnings), "warns" => Some(Self::Warns), @@ -112,17 +120,20 @@ impl SectionKind { Self::Notes => "Notes", Self::OtherArgs => "Other Args", Self::OtherArguments => "Other Arguments", - Self::OtherParams => "Other Params", Self::OtherParameters => "Other Parameters", + Self::OtherParams => "Other Params", + Self::Param => "Param", Self::Parameters => "Parameters", Self::Raises => "Raises", Self::References => "References", Self::Return => "Return", Self::Returns => "Returns", + Self::RType => "RType", Self::SeeAlso => "See Also", Self::ShortSummary => "Short Summary", Self::Tip => "Tip", Self::Todo => "Todo", + Self::Type => "Type", Self::Warning => "Warning", Self::Warnings => "Warnings", Self::Warns => "Warns", @@ -180,7 +191,33 @@ impl<'a> SectionContexts<'a> { let mut previous_line = lines.next(); while let Some(line) = lines.next() { - if let Some(section_kind) = suspected_as_section(&line, style) { + if matches!(style, SectionStyle::Sphinx) { + if let Some(section_name) = sphinx_section_name(&line) { + if let Some(kind) = SectionKind::from_str(section_name) { + if style.sections().contains(&kind) { + let indent = leading_space_and_colon(&line); + let indent_size = indent.text_len(); + let section_name_size = section_name.text_len(); + + if let Some(mut last) = last.take() { + last.range = TextRange::new(last.start(), line.start()); + contexts.push(last); + } + + last = Some(SectionContextData { + kind, + indent_size: indent.text_len(), + name_range: TextRange::at( + line.start() + indent_size, + section_name_size, + ), + range: TextRange::empty(line.start()), + summary_full_end: line.full_end(), + }); + } + } + } + } else if let Some(section_kind) = suspected_as_section(&line, style) { let indent = leading_space(&line); let indent_size = indent.text_len(); diff --git a/crates/ruff_linter/src/docstrings/sphinx.rs b/crates/ruff_linter/src/docstrings/sphinx.rs new file mode 100644 index 0000000000000..9c3d82d85244c --- /dev/null +++ b/crates/ruff_linter/src/docstrings/sphinx.rs @@ -0,0 +1,12 @@ +//! Abstractions for Sphinx-style docstrings. + +use crate::docstrings::sections::SectionKind; + +pub(crate) static SPHINX_SECTIONS: &[SectionKind] = &[ + SectionKind::Param, + SectionKind::Type, + SectionKind::Raises, + SectionKind::Return, + SectionKind::RType, + SectionKind::Yield, +]; diff --git a/crates/ruff_linter/src/docstrings/styles.rs b/crates/ruff_linter/src/docstrings/styles.rs index b4d1d3d44c32c..be89850adc961 100644 --- a/crates/ruff_linter/src/docstrings/styles.rs +++ b/crates/ruff_linter/src/docstrings/styles.rs @@ -1,11 +1,13 @@ use crate::docstrings::google::GOOGLE_SECTIONS; use crate::docstrings::numpy::NUMPY_SECTIONS; use crate::docstrings::sections::SectionKind; +use crate::docstrings::sphinx::SPHINX_SECTIONS; #[derive(Copy, Clone, Debug, is_macro::Is)] pub(crate) enum SectionStyle { Numpy, Google, + Sphinx, } impl SectionStyle { @@ -13,6 +15,7 @@ impl SectionStyle { match self { SectionStyle::Numpy => NUMPY_SECTIONS, SectionStyle::Google => GOOGLE_SECTIONS, + SectionStyle::Sphinx => SPHINX_SECTIONS, } } } diff --git a/crates/ruff_linter/src/rules/pydoclint/mod.rs b/crates/ruff_linter/src/rules/pydoclint/mod.rs index 68565de689e19..d342a5bd566fb 100644 --- a/crates/ruff_linter/src/rules/pydoclint/mod.rs +++ b/crates/ruff_linter/src/rules/pydoclint/mod.rs @@ -12,6 +12,7 @@ mod tests { use crate::registry::Rule; use crate::rules::pydocstyle; use crate::rules::pydocstyle::settings::Convention; + use crate::settings::types::PreviewMode; use crate::test::test_path; use crate::{assert_messages, settings}; @@ -63,4 +64,24 @@ mod tests { assert_messages!(snapshot, diagnostics); Ok(()) } + + #[test_case(Rule::DocstringMissingReturns, Path::new("DOC201_sphinx.py"))] + #[test_case(Rule::DocstringExtraneousReturns, Path::new("DOC202_sphinx.py"))] + #[test_case(Rule::DocstringMissingYields, Path::new("DOC402_sphinx.py"))] + #[test_case(Rule::DocstringExtraneousYields, Path::new("DOC403_sphinx.py"))] + #[test_case(Rule::DocstringMissingException, Path::new("DOC501_sphinx.py"))] + #[test_case(Rule::DocstringExtraneousException, Path::new("DOC502_sphinx.py"))] + fn rules_sphinx_style(rule_code: Rule, path: &Path) -> Result<()> { + let snapshot = format!("{}_{}", rule_code.as_ref(), path.to_string_lossy()); + let diagnostics = test_path( + Path::new("pydoclint").join(path).as_path(), + &settings::LinterSettings { + preview: PreviewMode::Enabled, + pydocstyle: pydocstyle::settings::Settings::new(Some(Convention::Sphinx), [], []), + ..settings::LinterSettings::for_rule(rule_code) + }, + )?; + assert_messages!(snapshot, diagnostics); + Ok(()) + } } diff --git a/crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs b/crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs index 40d88023033e6..dc9c10e611803 100644 --- a/crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs +++ b/crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs @@ -415,17 +415,23 @@ impl<'a> DocstringSections<'a> { for section in sections { match section.kind() { SectionKind::Raises => { + if matches!(style, Some(SectionStyle::Sphinx)) { + continue; + } docstring_sections.raises = Some(RaisesSection::from_section(§ion, style)); } - SectionKind::Returns => { + SectionKind::Returns | SectionKind::Return => { docstring_sections.returns = Some(GenericSection::from_section(§ion)); } - SectionKind::Yields => { + SectionKind::Yields | SectionKind::Yield => { docstring_sections.yields = Some(GenericSection::from_section(§ion)); } _ => continue, } } + if matches!(style, Some(SectionStyle::Sphinx)) { + docstring_sections.raises = parse_raises_sphinx(sections); + } docstring_sections } } @@ -436,6 +442,7 @@ impl<'a> DocstringSections<'a> { /// entries are found. fn parse_entries(content: &str, style: Option) -> Vec { match style { + Some(SectionStyle::Sphinx) => panic!("Cannot process Sphinx style section"), Some(SectionStyle::Google) => parse_entries_google(content), Some(SectionStyle::Numpy) => parse_entries_numpy(content), None => { @@ -497,6 +504,44 @@ fn parse_entries_numpy(content: &str) -> Vec { entries } +/// Parses Sphinx-style docstring sections of the form: +/// +/// ```python +/// :raises FasterThanLightError: If speed is greater than the speed of light. +/// :raises DivisionByZero: If attempting to divide by zero. +/// ``` +fn parse_raises_sphinx<'a>(sections: &'a SectionContexts) -> Option> { + let mut entries: Vec = Vec::new(); + let mut range_start = None; + let mut range_end = None; + for section in sections { + if matches!(section.kind(), SectionKind::Raises) { + if range_start.is_none() { + range_start = Some(section.start()); + } + range_end = Some(section.end()); + let mut line = section.summary_line().split(':'); + let _indent = line.next(); + if let Some(header) = line.next() { + let mut header = header.split(' '); + let _raises = header.next(); + if let Some(exception) = header.next() { + entries.push(QualifiedName::user_defined(exception)); + } + } + } + } + if let Some(range_start) = range_start { + if let Some(range_end) = range_end { + return Some(RaisesSection { + raised_exceptions: entries, + range: TextRange::new(range_start, range_end), + }); + } + } + None +} + /// An individual `yield` expression in a function body. #[derive(Debug)] struct YieldEntry { @@ -857,7 +902,15 @@ pub(crate) fn check_docstring( Some(Convention::Numpy) => { DocstringSections::from_sections(section_contexts, Some(SectionStyle::Numpy)) } - Some(Convention::Pep257) | None => DocstringSections::from_sections(section_contexts, None), + Some(Convention::Sphinx) => { + DocstringSections::from_sections(section_contexts, Some(SectionStyle::Sphinx)) + } + Some(Convention::Pep257) | None => match section_contexts.style() { + SectionStyle::Sphinx => { + DocstringSections::from_sections(section_contexts, Some(SectionStyle::Sphinx)) + } + _ => DocstringSections::from_sections(section_contexts, None), + }, }; let body_entries = { diff --git a/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-extraneous-exception_DOC502_sphinx.py.snap b/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-extraneous-exception_DOC502_sphinx.py.snap new file mode 100644 index 0000000000000..d8c52042c1a4c --- /dev/null +++ b/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-extraneous-exception_DOC502_sphinx.py.snap @@ -0,0 +1,38 @@ +--- +source: crates/ruff_linter/src/rules/pydoclint/mod.rs +--- +DOC502_sphinx.py:16:1: DOC502 Raised exception is not explicitly raised: `FasterThanLightError` + | +14 | :return: Speed as distance divided by time. +15 | :rtype: float +16 | / :raises FasterThanLightError: If speed is greater than the speed of light. +17 | | """ + | |____^ DOC502 +18 | return distance / time + | + = help: Remove `FasterThanLightError` from the docstring + +DOC502_sphinx.py:32:1: DOC502 Raised exceptions are not explicitly raised: `FasterThanLightError`, `DivisionByZero` + | +30 | :return: Speed as distance divided by time. +31 | :rtype: float +32 | / :raises FasterThanLightError: If speed is greater than the speed of light. +33 | | :raises DivisionByZero: If attempting to divide by zero. +34 | | """ + | |____^ DOC502 +35 | return distance / time + | + = help: Remove `FasterThanLightError`, `DivisionByZero` from the docstring + +DOC502_sphinx.py:49:1: DOC502 Raised exception is not explicitly raised: `DivisionByZero` + | +47 | :return: Speed as distance divided by time. +48 | :rtype: float +49 | / :raises FasterThanLightError: If speed is greater than the speed of light. +50 | | :raises DivisionByZero: If attempting to divide by zero. +51 | | """ + | |____^ DOC502 +52 | try: +53 | return distance / time + | + = help: Remove `DivisionByZero` from the docstring diff --git a/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-extraneous-returns_DOC202_sphinx.py.snap b/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-extraneous-returns_DOC202_sphinx.py.snap new file mode 100644 index 0000000000000..735f52c6107a7 --- /dev/null +++ b/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-extraneous-returns_DOC202_sphinx.py.snap @@ -0,0 +1,26 @@ +--- +source: crates/ruff_linter/src/rules/pydoclint/mod.rs +--- +DOC202_sphinx.py:19:1: DOC202 Docstring should not have a returns section because the function doesn't return anything + | +17 | :param num: A number +18 | :type num: int +19 | / :return: A string +20 | | :rtype: str + | |_^ DOC202 +21 | """ +22 | print('test') + | + = help: Remove the "Returns" section + +DOC202_sphinx.py:34:1: DOC202 Docstring should not have a returns section because the function doesn't return anything + | +32 | :param num: A number +33 | :type num: int +34 | / :return: A string +35 | | :rtype: str + | |_^ DOC202 +36 | """ +37 | print('test') + | + = help: Remove the "Returns" section diff --git a/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-extraneous-yields_DOC403_sphinx.py.snap b/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-extraneous-yields_DOC403_sphinx.py.snap new file mode 100644 index 0000000000000..30669e049dc10 --- /dev/null +++ b/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-extraneous-yields_DOC403_sphinx.py.snap @@ -0,0 +1,26 @@ +--- +source: crates/ruff_linter/src/rules/pydoclint/mod.rs +--- +DOC403_sphinx.py:19:1: DOC403 Docstring has a "Yields" section but the function doesn't yield anything + | +17 | :param num: A number +18 | :type num: int +19 | / :yield: A string +20 | | :rtype: str + | |_^ DOC403 +21 | """ +22 | print('test') + | + = help: Remove the "Yields" section + +DOC403_sphinx.py:34:1: DOC403 Docstring has a "Yields" section but the function doesn't yield anything + | +32 | :param num: A number +33 | :type num: int +34 | / :yield: A string +35 | | :rtype: str + | |_^ DOC403 +36 | """ +37 | print('test') + | + = help: Remove the "Yields" section diff --git a/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-missing-exception_DOC501_sphinx.py.snap b/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-missing-exception_DOC501_sphinx.py.snap new file mode 100644 index 0000000000000..07b4d68a88d88 --- /dev/null +++ b/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-missing-exception_DOC501_sphinx.py.snap @@ -0,0 +1,61 @@ +--- +source: crates/ruff_linter/src/rules/pydoclint/mod.rs +--- +DOC501_sphinx.py:39:15: DOC501 Raised exception `FasterThanLightError` missing from docstring + | +37 | return distance / time +38 | except ZeroDivisionError as exc: +39 | raise FasterThanLightError from exc + | ^^^^^^^^^^^^^^^^^^^^ DOC501 + | + = help: Add `FasterThanLightError` to the docstring + +DOC501_sphinx.py:57:15: DOC501 Raised exception `FasterThanLightError` missing from docstring + | +55 | return distance / time +56 | except ZeroDivisionError as exc: +57 | raise FasterThanLightError from exc + | ^^^^^^^^^^^^^^^^^^^^ DOC501 +58 | except: +59 | raise ValueError + | + = help: Add `FasterThanLightError` to the docstring + +DOC501_sphinx.py:59:15: DOC501 Raised exception `ValueError` missing from docstring + | +57 | raise FasterThanLightError from exc +58 | except: +59 | raise ValueError + | ^^^^^^^^^^ DOC501 + | + = help: Add `ValueError` to the docstring + +DOC501_sphinx.py:82:9: DOC501 Raised exception `TypeError` missing from docstring + | +80 | except TypeError: +81 | print("Not a number? Shame on you!") +82 | raise + | ^^^^^ DOC501 + | + = help: Add `TypeError` to the docstring + +DOC501_sphinx.py:111:15: DOC501 Raised exception `TypeError` missing from docstring + | +109 | """ +110 | if True: +111 | raise TypeError # DOC501 + | ^^^^^^^^^ DOC501 +112 | else: +113 | raise TypeError # no DOC501 here because we already emitted a diagnostic for the earlier `raise TypeError` + | + = help: Add `TypeError` to the docstring + +DOC501_sphinx.py:114:11: DOC501 Raised exception `ValueError` missing from docstring + | +112 | else: +113 | raise TypeError # no DOC501 here because we already emitted a diagnostic for the earlier `raise TypeError` +114 | raise ValueError # DOC501 + | ^^^^^^^^^^ DOC501 +115 | return 42 + | + = help: Add `ValueError` to the docstring diff --git a/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-missing-returns_DOC201_sphinx.py.snap b/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-missing-returns_DOC201_sphinx.py.snap new file mode 100644 index 0000000000000..d5431e24058a0 --- /dev/null +++ b/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-missing-returns_DOC201_sphinx.py.snap @@ -0,0 +1,20 @@ +--- +source: crates/ruff_linter/src/rules/pydoclint/mod.rs +--- +DOC201_sphinx.py:9:5: DOC201 `return` is not documented in docstring + | +7 | :type num: int +8 | """ +9 | return 'test' + | ^^^^^^^^^^^^^ DOC201 + | + = help: Add a "Returns" section to the docstring + +DOC201_sphinx.py:48:9: DOC201 `return` is not documented in docstring + | +46 | :type num: int +47 | """ +48 | return 'test' + | ^^^^^^^^^^^^^ DOC201 + | + = help: Add a "Returns" section to the docstring diff --git a/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-missing-yields_DOC402_sphinx.py.snap b/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-missing-yields_DOC402_sphinx.py.snap new file mode 100644 index 0000000000000..e28a52f99f675 --- /dev/null +++ b/crates/ruff_linter/src/rules/pydoclint/snapshots/ruff_linter__rules__pydoclint__tests__docstring-missing-yields_DOC402_sphinx.py.snap @@ -0,0 +1,68 @@ +--- +source: crates/ruff_linter/src/rules/pydoclint/mod.rs +--- +DOC402_sphinx.py:9:5: DOC402 `yield` is not documented in docstring + | +7 | :type num: int +8 | """ +9 | yield 'test' + | ^^^^^^^^^^^^ DOC402 + | + = help: Add a "Yields" section to the docstring + +DOC402_sphinx.py:48:9: DOC402 `yield` is not documented in docstring + | +46 | :type num: int +47 | """ +48 | yield 'test' + | ^^^^^^^^^^^^ DOC402 + | + = help: Add a "Yields" section to the docstring + +DOC402_sphinx.py:75:5: DOC402 `yield` is not documented in docstring + | +73 | Do something +74 | """ +75 | yield None + | ^^^^^^^^^^ DOC402 +76 | yield 1 + | + = help: Add a "Yields" section to the docstring + +DOC402_sphinx.py:84:5: DOC402 `yield` is not documented in docstring + | +82 | Do something +83 | """ +84 | yield None + | ^^^^^^^^^^ DOC402 + | + = help: Add a "Yields" section to the docstring + +DOC402_sphinx.py:108:5: DOC402 `yield` is not documented in docstring + | +106 | Do something +107 | """ +108 | yield None + | ^^^^^^^^^^ DOC402 +109 | yield 1 + | + = help: Add a "Yields" section to the docstring + +DOC402_sphinx.py:117:5: DOC402 `yield` is not documented in docstring + | +115 | Do something +116 | """ +117 | yield 1 + | ^^^^^^^ DOC402 +118 | yield + | + = help: Add a "Yields" section to the docstring + +DOC402_sphinx.py:126:5: DOC402 `yield` is not documented in docstring + | +124 | Do something +125 | """ +126 | yield + | ^^^^^ DOC402 + | + = help: Add a "Yields" section to the docstring diff --git a/crates/ruff_linter/src/rules/pydocstyle/helpers.rs b/crates/ruff_linter/src/rules/pydocstyle/helpers.rs index d802afcc69d5c..4b7ca9c03e308 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/helpers.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/helpers.rs @@ -6,10 +6,12 @@ use ruff_python_trivia::Cursor; use ruff_source_file::{Line, UniversalNewlines}; use ruff_text_size::{TextRange, TextSize}; +use crate::checkers::ast::Checker; use crate::docstrings::sections::{SectionContexts, SectionKind}; use crate::docstrings::styles::SectionStyle; use crate::docstrings::Docstring; use crate::rules::pydocstyle::settings::{Convention, Settings}; +use crate::warn_user_once; /// Return the index of the first logical line in a string. pub(super) fn logical_line(content: &str) -> Option { @@ -73,6 +75,7 @@ pub(crate) fn should_ignore_definition( pub(crate) fn get_section_contexts<'a>( docstring: &'a Docstring<'a>, convention: Option, + checker: &mut Checker, ) -> SectionContexts<'a> { match convention { Some(Convention::Google) => { @@ -81,7 +84,14 @@ pub(crate) fn get_section_contexts<'a>( Some(Convention::Numpy) => { return SectionContexts::from_docstring(docstring, SectionStyle::Numpy); } - Some(Convention::Pep257) | None => { + Some(Convention::Sphinx) if checker.settings.preview.is_enabled() => { + return SectionContexts::from_docstring(docstring, SectionStyle::Sphinx); + } + Some(Convention::Pep257 | Convention::Sphinx) | None => { + if matches!(convention, Some(Convention::Sphinx)) { + warn_user_once!("Sphinx support is currently in preview. Setting convention = \"sphinx\" will be ignored."); + } + // There are some overlapping section names, between the Google and NumPy conventions // (e.g., "Returns", "Raises"). Break ties by checking for the presence of some of the // section names that are unique to each convention. diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/sections.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/sections.rs index 10346ec19482a..f11119dd913f9 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/sections.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/sections.rs @@ -1330,9 +1330,11 @@ pub(crate) fn sections( match convention { Some(Convention::Google) => parse_google_sections(checker, docstring, section_contexts), Some(Convention::Numpy) => parse_numpy_sections(checker, docstring, section_contexts), + Some(Convention::Sphinx) => parse_sphinx_sections(checker, docstring, section_contexts), Some(Convention::Pep257) | None => match section_contexts.style() { SectionStyle::Google => parse_google_sections(checker, docstring, section_contexts), SectionStyle::Numpy => parse_numpy_sections(checker, docstring, section_contexts), + SectionStyle::Sphinx => parse_sphinx_sections(checker, docstring, section_contexts), }, } } @@ -1631,7 +1633,7 @@ fn blanks_and_section_underline( checker.diagnostics.push(diagnostic); } - if checker.enabled(Rule::EmptyDocstringSection) { + if !style.is_sphinx() && checker.enabled(Rule::EmptyDocstringSection) { checker.diagnostics.push(Diagnostic::new( EmptyDocstringSection { name: context.section_name().to_string(), @@ -1649,7 +1651,7 @@ fn common_section( next: Option<&SectionContext>, style: SectionStyle, ) { - if checker.enabled(Rule::CapitalizeSectionName) { + if !style.is_sphinx() && checker.enabled(Rule::CapitalizeSectionName) { let capitalized_section_name = context.kind().as_str(); if context.section_name() != capitalized_section_name { let section_range = context.section_name_range(); @@ -2005,6 +2007,23 @@ fn google_section( } } +fn parse_sphinx_sections( + checker: &mut Checker, + docstring: &Docstring, + section_contexts: &SectionContexts, +) { + let mut iterator = section_contexts.iter().peekable(); + while let Some(context) = iterator.next() { + common_section( + checker, + docstring, + &context, + iterator.peek(), + SectionStyle::Sphinx, + ); + } +} + fn parse_numpy_sections( checker: &mut Checker, docstring: &Docstring, diff --git a/crates/ruff_linter/src/rules/pydocstyle/settings.rs b/crates/ruff_linter/src/rules/pydocstyle/settings.rs index c8b05ba3c5012..2d883684e96ef 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/settings.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/settings.rs @@ -20,6 +20,8 @@ pub enum Convention { Google, /// Use NumPy-style docstrings. Numpy, + /// Use Sphinx-style docstrings. + Sphinx, /// Use PEP257-style docstrings. Pep257, } @@ -28,47 +30,65 @@ impl Convention { pub const fn rules_to_be_ignored(self) -> &'static [Rule] { match self { Convention::Google => &[ - Rule::OneBlankLineBeforeClass, - Rule::OneBlankLineAfterClass, - Rule::MultiLineSummarySecondLine, - Rule::SectionUnderlineNotOverIndented, - Rule::EndsInPeriod, - Rule::NonImperativeMood, + Rule::BlankLineAfterLastSection, + Rule::DashedUnderlineAfterSection, Rule::DocstringStartsWithThis, + Rule::EndsInPeriod, + Rule::MultiLineSummarySecondLine, Rule::NewLineAfterSectionName, - Rule::DashedUnderlineAfterSection, + Rule::NonImperativeMood, + Rule::OneBlankLineAfterClass, + Rule::OneBlankLineBeforeClass, Rule::SectionUnderlineAfterName, Rule::SectionUnderlineMatchesSectionLength, - Rule::BlankLineAfterLastSection, + Rule::SectionUnderlineNotOverIndented, ], Convention::Numpy => &[ - Rule::UndocumentedPublicInit, - Rule::OneBlankLineBeforeClass, + Rule::BlankLineAfterLastSection, + Rule::EndsInPunctuation, Rule::MultiLineSummaryFirstLine, Rule::MultiLineSummarySecondLine, Rule::NoSignature, + Rule::OneBlankLineBeforeClass, + Rule::SectionNameEndsInColon, + Rule::UndocumentedParam, + Rule::UndocumentedPublicInit, + ], + Convention::Sphinx => &[ Rule::BlankLineAfterLastSection, + Rule::CapitalizeSectionName, + Rule::DashedUnderlineAfterSection, + Rule::DocstringStartsWithThis, Rule::EndsInPunctuation, + Rule::MultiLineSummaryFirstLine, + Rule::MultiLineSummarySecondLine, + Rule::NewLineAfterSectionName, + Rule::NoBlankLineAfterSection, + Rule::NoBlankLineBeforeSection, + Rule::OneBlankLineBeforeClass, Rule::SectionNameEndsInColon, + Rule::SectionUnderlineAfterName, + Rule::SectionUnderlineMatchesSectionLength, + Rule::SectionUnderlineNotOverIndented, Rule::UndocumentedParam, ], Convention::Pep257 => &[ - Rule::OneBlankLineBeforeClass, + Rule::BlankLineAfterLastSection, + Rule::CapitalizeSectionName, + Rule::DashedUnderlineAfterSection, + Rule::DocstringStartsWithThis, + Rule::EndsInPunctuation, Rule::MultiLineSummaryFirstLine, Rule::MultiLineSummarySecondLine, - Rule::SectionNotOverIndented, - Rule::SectionUnderlineNotOverIndented, - Rule::DocstringStartsWithThis, - Rule::CapitalizeSectionName, Rule::NewLineAfterSectionName, - Rule::DashedUnderlineAfterSection, - Rule::SectionUnderlineAfterName, - Rule::SectionUnderlineMatchesSectionLength, Rule::NoBlankLineAfterSection, Rule::NoBlankLineBeforeSection, - Rule::BlankLineAfterLastSection, - Rule::EndsInPunctuation, + Rule::OneBlankLineBeforeClass, Rule::SectionNameEndsInColon, + Rule::SectionNotOverIndented, + Rule::SectionUnderlineAfterName, + Rule::SectionUnderlineMatchesSectionLength, + Rule::SectionUnderlineNotOverIndented, Rule::UndocumentedParam, ], } @@ -80,6 +100,7 @@ impl fmt::Display for Convention { match self { Self::Google => write!(f, "google"), Self::Numpy => write!(f, "numpy"), + Self::Sphinx => write!(f, "sphinx"), Self::Pep257 => write!(f, "pep257"), } } diff --git a/crates/ruff_python_ast/src/docstrings.rs b/crates/ruff_python_ast/src/docstrings.rs index 3722afe636b4e..303b98530055d 100644 --- a/crates/ruff_python_ast/src/docstrings.rs +++ b/crates/ruff_python_ast/src/docstrings.rs @@ -20,3 +20,18 @@ pub fn clean_space(indentation: &str) -> String { .map(|char| if char.is_whitespace() { char } else { ' ' }) .collect() } + +/// Extract the leading whitespace and colon from a line of text within a Python docstring. +pub fn leading_space_and_colon(line: &str) -> &str { + line.find(|char: char| !char.is_whitespace() && char != ':') + .map_or(line, |index| &line[..index]) +} + +/// Sphinx section section name. +pub fn sphinx_section_name(line: &str) -> Option<&str> { + let mut spans = line.split(':'); + let _indentation = spans.next()?; + let header = spans.next()?; + let _after_header = spans.next()?; + header.split(' ').next() +} diff --git a/ruff.schema.json b/ruff.schema.json index c39a68ebd6aa7..f6b56597dfb01 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -791,6 +791,13 @@ "numpy" ] }, + { + "description": "Use Sphinx-style docstrings.", + "type": "string", + "enum": [ + "sphinx" + ] + }, { "description": "Use PEP257-style docstrings.", "type": "string",