[pydoclint] Implement docstring-missing-exception and docstring-extraneous-exception (DOC501, DOC502) (#11471)

## Summary

These are the first rules implemented as part of #458, but I plan to
implement more.

Specifically, this implements `docstring-missing-exception` which checks
for raised exceptions not documented in the docstring, and
`docstring-extraneous-exception` which checks for exceptions in the
docstring not present in the body.

## Test Plan

Test fixtures added for both google and numpy style.
This commit is contained in:
Auguste Lalande 2024-07-20 15:41:51 -04:00 committed by GitHub
parent 53b84ab054
commit 4bc73dd87e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1161 additions and 67 deletions

25
LICENSE
View file

@ -1371,3 +1371,28 @@ are:
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
- pydoclint, licensed as follows:
"""
MIT License
Copyright (c) 2023 jsh9
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""

View file

@ -0,0 +1,192 @@
import something
from somewhere import AnotherError
class FasterThanLightError(Exception):
...
_some_error = Exception
# OK
def calculate_speed(distance: float, time: float) -> float:
"""Calculate speed as distance divided by time.
Args:
distance: Distance traveled.
time: Time spent traveling.
Returns:
Speed as distance divided by time.
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.
Args:
distance: Distance traveled.
time: Time spent traveling.
Returns:
Speed as distance divided by time.
"""
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.
Args:
distance: Distance traveled.
time: Time spent traveling.
Returns:
Speed as distance divided by time.
"""
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.
Args:
distance: Distance traveled.
time: Time spent traveling.
Returns:
Speed as distance divided by time.
"""
try:
return distance / time
except ZeroDivisionError as exc:
print('oops')
raise exc
# DOC501
def calculate_speed(distance: float, time: float) -> float:
"""Calculate speed as distance divided by time.
Args:
distance: Distance traveled.
time: Time spent traveling.
Returns:
Speed as distance divided by time.
"""
try:
return distance / time
except (ZeroDivisionError, ValueError) as exc:
print('oops')
raise exc
# DOC501
def calculate_speed(distance: float, time: float) -> float:
"""Calculate speed as distance divided by time.
Args:
distance: Distance traveled.
time: Time spent traveling.
Returns:
Speed as distance divided by time.
"""
raise AnotherError
# DOC501
def calculate_speed(distance: float, time: float) -> float:
"""Calculate speed as distance divided by time.
Args:
distance: Distance traveled.
time: Time spent traveling.
Returns:
Speed as distance divided by time.
"""
raise AnotherError()
# DOC501
def foo(bar: int):
"""Foo.
Args:
bar: Bar.
"""
raise something.SomeError
# DOC501, but can't resolve the error
def calculate_speed(distance: float, time: float) -> float:
"""Calculate speed as distance divided by time.
Args:
distance: Distance traveled.
time: Time spent traveling.
Returns:
Speed as distance divided by time.
"""
raise _some_error
# OK
def calculate_speed(distance: float, time: float) -> float:
try:
return distance / time
except ZeroDivisionError as exc:
raise FasterThanLightError from exc
# OK
def calculate_speed(distance: float, time: float) -> float:
raise NotImplementedError
# OK
def foo(bar: int):
"""Foo.
Args:
bar: Bar.
Raises:
SomeError: Wow.
"""
raise something.SomeError
# OK
def foo(bar: int):
"""Foo.
Args:
bar: Bar.
Raises:
something.SomeError: Wow.
"""
raise something.SomeError

View file

@ -0,0 +1,78 @@
class FasterThanLightError(Exception):
...
# OK
def calculate_speed(distance: float, time: float) -> float:
"""
Calculate speed as distance divided by time.
Parameters
----------
distance : float
Distance traveled.
time : float
Time spent traveling.
Returns
-------
float
Speed as distance divided by time.
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.
Parameters
----------
distance : float
Distance traveled.
time : float
Time spent traveling.
Returns
-------
float
Speed as distance divided by time.
"""
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.
Parameters
----------
distance : float
Distance traveled.
time : float
Time spent traveling.
Returns
-------
float
Speed as distance divided by time.
"""
try:
return distance / time
except ZeroDivisionError as exc:
raise FasterThanLightError from exc
except:
raise ValueError

View file

@ -0,0 +1,58 @@
class FasterThanLightError(Exception):
...
# DOC502
def calculate_speed(distance: float, time: float) -> float:
"""Calculate speed as distance divided by time.
Args:
distance: Distance traveled.
time: Time spent traveling.
Returns:
Speed as distance divided by time.
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.
Args:
distance: Distance traveled.
time: Time spent traveling.
Returns:
Speed as distance divided by time.
Raises:
FasterThanLightError: If speed is greater than the speed of light.
DivisionByZero: Divide by zero.
"""
return distance / time
# DOC502
def calculate_speed(distance: float, time: float) -> float:
"""Calculate speed as distance divided by time.
Args:
distance: Distance traveled.
time: Time spent traveling.
Returns:
Speed as distance divided by time.
Raises:
FasterThanLightError: If speed is greater than the speed of light.
DivisionByZero: Divide by zero.
"""
try:
return distance / time
except ZeroDivisionError as exc:
raise FasterThanLightError from exc

View file

@ -0,0 +1,84 @@
class FasterThanLightError(Exception):
...
# DOC502
def calculate_speed(distance: float, time: float) -> float:
"""
Calculate speed as distance divided by time.
Parameters
----------
distance : float
Distance traveled.
time : float
Time spent traveling.
Returns
-------
float
Speed as distance divided by time.
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.
Parameters
----------
distance : float
Distance traveled.
time : float
Time spent traveling.
Returns
-------
float
Speed as distance divided by time.
Raises
------
FasterThanLightError
If speed is greater than the speed of light.
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.
Parameters
----------
distance : float
Distance traveled.
time : float
Time spent traveling.
Returns
-------
float
Speed as distance divided by time.
Raises
------
FasterThanLightError
If speed is greater than the speed of light.
DivisionByZero
If attempting to divide by zero.
"""
try:
return distance / time
except ZeroDivisionError as exc:
raise FasterThanLightError from exc

View file

@ -10,7 +10,7 @@ use crate::checkers::ast::Checker;
use crate::codes::Rule;
use crate::docstrings::Docstring;
use crate::fs::relativize_path;
use crate::rules::{flake8_annotations, flake8_pyi, pydocstyle, pylint};
use crate::rules::{flake8_annotations, flake8_pyi, pydoclint, pydocstyle, pylint};
use crate::{docstrings, warn_user};
/// Run lint rules over all [`Definition`] nodes in the [`SemanticModel`].
@ -83,12 +83,17 @@ pub(crate) fn definitions(checker: &mut Checker) {
Rule::UndocumentedPublicNestedClass,
Rule::UndocumentedPublicPackage,
]);
let enforce_pydoclint = checker.any_enabled(&[
Rule::DocstringMissingException,
Rule::DocstringExtraneousException,
]);
if !enforce_annotations
&& !enforce_docstrings
&& !enforce_stubs
&& !enforce_stubs_and_runtime
&& !enforce_dunder_method
&& !enforce_pydoclint
{
return;
}
@ -163,8 +168,8 @@ pub(crate) fn definitions(checker: &mut Checker) {
}
}
// pydocstyle
if enforce_docstrings {
// pydocstyle, pydoclint
if enforce_docstrings || enforce_pydoclint {
if pydocstyle::helpers::should_ignore_definition(
definition,
&checker.settings.pydocstyle.ignore_decorators,
@ -282,7 +287,8 @@ pub(crate) fn definitions(checker: &mut Checker) {
if checker.enabled(Rule::OverloadWithDocstring) {
pydocstyle::rules::if_needed(checker, &docstring);
}
if checker.any_enabled(&[
let enforce_sections = checker.any_enabled(&[
Rule::BlankLineAfterLastSection,
Rule::BlankLinesBetweenHeaderAndContent,
Rule::CapitalizeSectionName,
@ -298,13 +304,31 @@ pub(crate) fn definitions(checker: &mut Checker) {
Rule::SectionUnderlineMatchesSectionLength,
Rule::SectionUnderlineNotOverIndented,
Rule::UndocumentedParam,
]) {
]);
if enforce_sections || enforce_pydoclint {
let section_contexts = pydocstyle::helpers::get_section_contexts(
&docstring,
checker.settings.pydocstyle.convention.as_ref(),
);
if enforce_sections {
pydocstyle::rules::sections(
checker,
&docstring,
&section_contexts,
checker.settings.pydocstyle.convention.as_ref(),
);
}
if enforce_pydoclint {
pydoclint::rules::check_docstring(
checker,
definition,
&section_contexts,
checker.settings.pydocstyle.convention.as_ref(),
);
}
}
}
}
}

View file

@ -912,6 +912,10 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Numpy, "003") => (RuleGroup::Stable, rules::numpy::rules::NumpyDeprecatedFunction),
(Numpy, "201") => (RuleGroup::Stable, rules::numpy::rules::Numpy2Deprecation),
// pydoclint
(Pydoclint, "501") => (RuleGroup::Preview, rules::pydoclint::rules::DocstringMissingException),
(Pydoclint, "502") => (RuleGroup::Preview, rules::pydoclint::rules::DocstringExtraneousException),
// ruff
(Ruff, "001") => (RuleGroup::Stable, rules::ruff::rules::AmbiguousUnicodeCharacterString),
(Ruff, "002") => (RuleGroup::Stable, rules::ruff::rules::AmbiguousUnicodeCharacterDocstring),

View file

@ -163,6 +163,7 @@ impl SectionKind {
pub(crate) struct SectionContexts<'a> {
contexts: Vec<SectionContextData>,
docstring: &'a Docstring<'a>,
style: SectionStyle,
}
impl<'a> SectionContexts<'a> {
@ -221,9 +222,14 @@ impl<'a> SectionContexts<'a> {
Self {
contexts,
docstring,
style,
}
}
pub(crate) fn style(&self) -> SectionStyle {
self.style
}
pub(crate) fn len(&self) -> usize {
self.contexts.len()
}
@ -396,7 +402,7 @@ impl<'a> SectionContext<'a> {
NewlineWithTrailingNewline::with_offset(lines, self.offset() + self.data.summary_full_end)
}
fn following_lines_str(&self) -> &'a str {
pub(crate) fn following_lines_str(&self) -> &'a str {
&self.docstring_body.as_str()[self.following_range_relative()]
}

View file

@ -202,6 +202,9 @@ pub enum Linter {
/// [refurb](https://pypi.org/project/refurb/)
#[prefix = "FURB"]
Refurb,
/// [pydoclint](https://pypi.org/project/pydoclint/)
#[prefix = "DOC"]
Pydoclint,
/// Ruff-specific rules
#[prefix = "RUF"]
Ruff,

View file

@ -48,6 +48,7 @@ pub mod pandas_vet;
pub mod pep8_naming;
pub mod perflint;
pub mod pycodestyle;
pub mod pydoclint;
pub mod pydocstyle;
pub mod pyflakes;
pub mod pygrep_hooks;

View file

@ -0,0 +1,55 @@
//! Rules from [pydoclint](https://pypi.org/project/pydoclint/).
pub(crate) mod rules;
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::convert::AsRef;
use std::path::Path;
use anyhow::Result;
use test_case::test_case;
use crate::registry::Rule;
use crate::rules::pydocstyle::settings::{Convention, Settings};
use crate::test::test_path;
use crate::{assert_messages, settings};
#[test_case(Rule::DocstringMissingException, Path::new("DOC501_google.py"))]
#[test_case(Rule::DocstringExtraneousException, Path::new("DOC502_google.py"))]
fn rules_google_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 {
pydocstyle: Settings {
convention: Some(Convention::Google),
ignore_decorators: BTreeSet::new(),
property_decorators: BTreeSet::new(),
},
..settings::LinterSettings::for_rule(rule_code)
},
)?;
assert_messages!(snapshot, diagnostics);
Ok(())
}
#[test_case(Rule::DocstringMissingException, Path::new("DOC501_numpy.py"))]
#[test_case(Rule::DocstringExtraneousException, Path::new("DOC502_numpy.py"))]
fn rules_numpy_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 {
pydocstyle: Settings {
convention: Some(Convention::Numpy),
ignore_decorators: BTreeSet::new(),
property_decorators: BTreeSet::new(),
},
..settings::LinterSettings::for_rule(rule_code)
},
)?;
assert_messages!(snapshot, diagnostics);
Ok(())
}
}

View file

@ -0,0 +1,382 @@
use itertools::Itertools;
use ruff_diagnostics::Diagnostic;
use ruff_diagnostics::Violation;
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::name::QualifiedName;
use ruff_python_ast::visitor::{self, Visitor};
use ruff_python_ast::{self as ast, Expr, Stmt};
use ruff_python_semantic::{Definition, MemberKind, SemanticModel};
use ruff_text_size::{Ranged, TextRange};
use crate::checkers::ast::Checker;
use crate::docstrings::sections::{SectionContexts, SectionKind};
use crate::docstrings::styles::SectionStyle;
use crate::registry::Rule;
use crate::rules::pydocstyle::settings::Convention;
/// ## What it does
/// Checks for function docstrings that do not include documentation for all
/// explicitly-raised exceptions.
///
/// ## Why is this bad?
/// If a raise is mentioned in a docstring, but the function itself does not
/// explicitly raise it, it can be misleading to users and/or a sign of
/// incomplete documentation or refactors.
///
/// ## Example
/// ```python
/// def calculate_speed(distance: float, time: float) -> float:
/// """Calculate speed as distance divided by time.
///
/// Args:
/// distance: Distance traveled.
/// time: Time spent traveling.
///
/// Returns:
/// Speed as distance divided by time.
/// """
/// try:
/// return distance / time
/// except ZeroDivisionError as exc:
/// raise FasterThanLightError from exc
/// ```
///
/// Use instead:
/// ```python
/// def calculate_speed(distance: float, time: float) -> float:
/// """Calculate speed as distance divided by time.
///
/// Args:
/// distance: Distance traveled.
/// time: Time spent traveling.
///
/// Returns:
/// Speed as distance divided by time.
///
/// Raises:
/// FasterThanLightError: If speed is greater than the speed of light.
/// """
/// try:
/// return distance / time
/// except ZeroDivisionError as exc:
/// raise FasterThanLightError from exc
/// ```
#[violation]
pub struct DocstringMissingException {
id: String,
}
impl Violation for DocstringMissingException {
#[derive_message_formats]
fn message(&self) -> String {
let DocstringMissingException { id } = self;
format!("Raised exception `{id}` missing from docstring")
}
}
/// ## What it does
/// Checks for function docstrings that include exceptions which are not
/// explicitly raised.
///
/// ## Why is this bad?
/// Some conventions prefer non-explicit exceptions be omitted from the
/// docstring.
///
/// ## Example
/// ```python
/// def calculate_speed(distance: float, time: float) -> float:
/// """Calculate speed as distance divided by time.
///
/// Args:
/// distance: Distance traveled.
/// time: Time spent traveling.
///
/// Returns:
/// Speed as distance divided by time.
///
/// Raises:
/// ZeroDivisionError: Divided by zero.
/// """
/// return distance / time
/// ```
///
/// Use instead:
/// ```python
/// def calculate_speed(distance: float, time: float) -> float:
/// """Calculate speed as distance divided by time.
///
/// Args:
/// distance: Distance traveled.
/// time: Time spent traveling.
///
/// Returns:
/// Speed as distance divided by time.
/// """
/// return distance / time
/// ```
#[violation]
pub struct DocstringExtraneousException {
ids: Vec<String>,
}
impl Violation for DocstringExtraneousException {
#[derive_message_formats]
fn message(&self) -> String {
let DocstringExtraneousException { ids } = self;
if let [id] = ids.as_slice() {
format!("Raised exception is not explicitly raised: `{id}`")
} else {
format!(
"Raised exceptions are not explicitly raised: {}",
ids.iter().map(|id| format!("`{id}`")).join(", ")
)
}
}
}
#[derive(Debug)]
struct DocstringEntries<'a> {
raised_exceptions: Vec<QualifiedName<'a>>,
raised_exceptions_range: TextRange,
}
impl<'a> DocstringEntries<'a> {
/// Return the raised exceptions for the docstring, or `None` if the docstring does not contain
/// a `Raises` section.
fn from_sections(sections: &'a SectionContexts, style: SectionStyle) -> Option<Self> {
for section in sections.iter() {
if section.kind() == SectionKind::Raises {
return Some(Self {
raised_exceptions: parse_entries(section.following_lines_str(), style),
raised_exceptions_range: section.range(),
});
}
}
None
}
}
impl Ranged for DocstringEntries<'_> {
fn range(&self) -> TextRange {
self.raised_exceptions_range
}
}
/// Parse the entries in a `Raises` section of a docstring.
fn parse_entries(content: &str, style: SectionStyle) -> Vec<QualifiedName> {
match style {
SectionStyle::Google => parse_entries_google(content),
SectionStyle::Numpy => parse_entries_numpy(content),
}
}
/// Parses Google-style docstring sections of the form:
///
/// ```python
/// Raises:
/// FasterThanLightError: If speed is greater than the speed of light.
/// DivisionByZero: If attempting to divide by zero.
/// ```
fn parse_entries_google(content: &str) -> Vec<QualifiedName> {
let mut entries: Vec<QualifiedName> = Vec::new();
for potential in content.split('\n') {
let Some(colon_idx) = potential.find(':') else {
continue;
};
let entry = potential[..colon_idx].trim();
entries.push(QualifiedName::user_defined(entry));
}
entries
}
/// Parses NumPy-style docstring sections of the form:
///
/// ```python
/// Raises
/// ------
/// FasterThanLightError
/// If speed is greater than the speed of light.
/// DivisionByZero
/// If attempting to divide by zero.
/// ```
fn parse_entries_numpy(content: &str) -> Vec<QualifiedName> {
let mut entries: Vec<QualifiedName> = Vec::new();
let mut split = content.split('\n');
let Some(dashes) = split.next() else {
return entries;
};
let indentation = dashes.len() - dashes.trim_start().len();
for potential in split {
if let Some(first_char) = potential.chars().nth(indentation) {
if !first_char.is_whitespace() {
let entry = potential[indentation..].trim();
entries.push(QualifiedName::user_defined(entry));
}
}
}
entries
}
/// An individual exception raised in a function body.
#[derive(Debug)]
struct Entry<'a> {
qualified_name: QualifiedName<'a>,
range: TextRange,
}
impl Ranged for Entry<'_> {
fn range(&self) -> TextRange {
self.range
}
}
/// The exceptions raised in a function body.
#[derive(Debug)]
struct BodyEntries<'a> {
raised_exceptions: Vec<Entry<'a>>,
}
/// An AST visitor to extract the raised exceptions from a function body.
struct BodyVisitor<'a> {
raised_exceptions: Vec<Entry<'a>>,
semantic: &'a SemanticModel<'a>,
}
impl<'a> BodyVisitor<'a> {
fn new(semantic: &'a SemanticModel) -> Self {
Self {
raised_exceptions: Vec::new(),
semantic,
}
}
fn finish(self) -> BodyEntries<'a> {
BodyEntries {
raised_exceptions: self.raised_exceptions,
}
}
}
impl<'a> Visitor<'a> for BodyVisitor<'a> {
fn visit_stmt(&mut self, stmt: &'a Stmt) {
if let Stmt::Raise(ast::StmtRaise { exc: Some(exc), .. }) = stmt {
if let Some(qualified_name) = extract_raised_exception(self.semantic, exc.as_ref()) {
self.raised_exceptions.push(Entry {
qualified_name,
range: exc.as_ref().range(),
});
}
}
visitor::walk_stmt(self, stmt);
}
}
fn extract_raised_exception<'a>(
semantic: &SemanticModel<'a>,
exc: &'a Expr,
) -> Option<QualifiedName<'a>> {
if let Some(qualified_name) = semantic.resolve_qualified_name(exc) {
return Some(qualified_name);
}
if let Expr::Call(ast::ExprCall { func, .. }) = exc {
return extract_raised_exception(semantic, func.as_ref());
}
None
}
/// DOC501, DOC502
pub(crate) fn check_docstring(
checker: &mut Checker,
definition: &Definition,
section_contexts: &SectionContexts,
convention: Option<&Convention>,
) {
let mut diagnostics = Vec::new();
let Definition::Member(member) = definition else {
return;
};
// Only check function docstrings.
if matches!(
member.kind,
MemberKind::Class(_) | MemberKind::NestedClass(_)
) {
return;
}
// Prioritize the specified convention over the determined style.
let docstring_entries = match convention {
Some(Convention::Google) => {
DocstringEntries::from_sections(section_contexts, SectionStyle::Google)
}
Some(Convention::Numpy) => {
DocstringEntries::from_sections(section_contexts, SectionStyle::Numpy)
}
_ => DocstringEntries::from_sections(section_contexts, section_contexts.style()),
};
let body_entries = {
let mut visitor = BodyVisitor::new(checker.semantic());
visitor::walk_body(&mut visitor, member.body());
visitor.finish()
};
// DOC501
if checker.enabled(Rule::DocstringMissingException) {
for body_raise in &body_entries.raised_exceptions {
let Some(name) = body_raise.qualified_name.segments().last() else {
continue;
};
if *name == "NotImplementedError" {
continue;
}
if !docstring_entries.as_ref().is_some_and(|entries| {
entries.raised_exceptions.iter().any(|exception| {
body_raise
.qualified_name
.segments()
.ends_with(exception.segments())
})
}) {
let diagnostic = Diagnostic::new(
DocstringMissingException {
id: (*name).to_string(),
},
body_raise.range(),
);
diagnostics.push(diagnostic);
}
}
}
// DOC502
if checker.enabled(Rule::DocstringExtraneousException) {
if let Some(docstring_entries) = docstring_entries {
let mut extraneous_exceptions = Vec::new();
for docstring_raise in &docstring_entries.raised_exceptions {
if !body_entries.raised_exceptions.iter().any(|exception| {
exception
.qualified_name
.segments()
.ends_with(docstring_raise.segments())
}) {
extraneous_exceptions.push(docstring_raise.to_string());
}
}
if !extraneous_exceptions.is_empty() {
let diagnostic = Diagnostic::new(
DocstringExtraneousException {
ids: extraneous_exceptions,
},
docstring_entries.range(),
);
diagnostics.push(diagnostic);
}
}
}
checker.diagnostics.extend(diagnostics);
}

View file

@ -0,0 +1,3 @@
pub(crate) use check_docstring::*;
mod check_docstring;

View file

@ -0,0 +1,38 @@
---
source: crates/ruff_linter/src/rules/pydoclint/mod.rs
---
DOC502_google.py:16:1: DOC502 Raised exception is not explicitly raised: `FasterThanLightError`
|
14 | Speed as distance divided by time.
15 |
16 | / Raises:
17 | | FasterThanLightError: If speed is greater than the speed of light.
18 | | """
| |____^ DOC502
19 | return distance / time
|
DOC502_google.py:33:1: DOC502 Raised exceptions are not explicitly raised: `FasterThanLightError`, `DivisionByZero`
|
31 | Speed as distance divided by time.
32 |
33 | / Raises:
34 | | FasterThanLightError: If speed is greater than the speed of light.
35 | | DivisionByZero: Divide by zero.
36 | | """
| |____^ DOC502
37 | return distance / time
|
DOC502_google.py:51:1: DOC502 Raised exception is not explicitly raised: `DivisionByZero`
|
49 | Speed as distance divided by time.
50 |
51 | / Raises:
52 | | FasterThanLightError: If speed is greater than the speed of light.
53 | | DivisionByZero: Divide by zero.
54 | | """
| |____^ DOC502
55 | try:
56 | return distance / time
|

View file

@ -0,0 +1,46 @@
---
source: crates/ruff_linter/src/rules/pydoclint/mod.rs
---
DOC502_numpy.py:22:1: DOC502 Raised exception is not explicitly raised: `FasterThanLightError`
|
20 | Speed as distance divided by time.
21 |
22 | / Raises
23 | | ------
24 | | FasterThanLightError
25 | | If speed is greater than the speed of light.
26 | | """
| |____^ DOC502
27 | return distance / time
|
DOC502_numpy.py:47:1: DOC502 Raised exceptions are not explicitly raised: `FasterThanLightError`, `DivisionByZero`
|
45 | Speed as distance divided by time.
46 |
47 | / Raises
48 | | ------
49 | | FasterThanLightError
50 | | If speed is greater than the speed of light.
51 | | DivisionByZero
52 | | If attempting to divide by zero.
53 | | """
| |____^ DOC502
54 | return distance / time
|
DOC502_numpy.py:74:1: DOC502 Raised exception is not explicitly raised: `DivisionByZero`
|
72 | Speed as distance divided by time.
73 |
74 | / Raises
75 | | ------
76 | | FasterThanLightError
77 | | If speed is greater than the speed of light.
78 | | DivisionByZero
79 | | If attempting to divide by zero.
80 | | """
| |____^ DOC502
81 | try:
82 | return distance / time
|

View file

@ -0,0 +1,52 @@
---
source: crates/ruff_linter/src/rules/pydoclint/mod.rs
---
DOC501_google.py:46:15: DOC501 Raised exception `FasterThanLightError` missing from docstring
|
44 | return distance / time
45 | except ZeroDivisionError as exc:
46 | raise FasterThanLightError from exc
| ^^^^^^^^^^^^^^^^^^^^ DOC501
|
DOC501_google.py:63:15: DOC501 Raised exception `FasterThanLightError` missing from docstring
|
61 | return distance / time
62 | except ZeroDivisionError as exc:
63 | raise FasterThanLightError from exc
| ^^^^^^^^^^^^^^^^^^^^ DOC501
64 | except:
65 | raise ValueError
|
DOC501_google.py:65:15: DOC501 Raised exception `ValueError` missing from docstring
|
63 | raise FasterThanLightError from exc
64 | except:
65 | raise ValueError
| ^^^^^^^^^^ DOC501
|
DOC501_google.py:115:11: DOC501 Raised exception `AnotherError` missing from docstring
|
113 | Speed as distance divided by time.
114 | """
115 | raise AnotherError
| ^^^^^^^^^^^^ DOC501
|
DOC501_google.py:129:11: DOC501 Raised exception `AnotherError` missing from docstring
|
127 | Speed as distance divided by time.
128 | """
129 | raise AnotherError()
| ^^^^^^^^^^^^^^ DOC501
|
DOC501_google.py:139:11: DOC501 Raised exception `SomeError` missing from docstring
|
137 | bar: Bar.
138 | """
139 | raise something.SomeError
| ^^^^^^^^^^^^^^^^^^^ DOC501
|

View file

@ -0,0 +1,28 @@
---
source: crates/ruff_linter/src/rules/pydoclint/mod.rs
---
DOC501_numpy.py:53:15: DOC501 Raised exception `FasterThanLightError` missing from docstring
|
51 | return distance / time
52 | except ZeroDivisionError as exc:
53 | raise FasterThanLightError from exc
| ^^^^^^^^^^^^^^^^^^^^ DOC501
|
DOC501_numpy.py:76:15: DOC501 Raised exception `FasterThanLightError` missing from docstring
|
74 | return distance / time
75 | except ZeroDivisionError as exc:
76 | raise FasterThanLightError from exc
| ^^^^^^^^^^^^^^^^^^^^ DOC501
77 | except:
78 | raise ValueError
|
DOC501_numpy.py:78:15: DOC501 Raised exception `ValueError` missing from docstring
|
76 | raise FasterThanLightError from exc
77 | except:
78 | raise ValueError
| ^^^^^^^^^^ DOC501
|

View file

@ -5,6 +5,11 @@ use ruff_python_ast::name::QualifiedName;
use ruff_python_semantic::{Definition, SemanticModel};
use ruff_source_file::UniversalNewlines;
use crate::docstrings::sections::{SectionContexts, SectionKind};
use crate::docstrings::styles::SectionStyle;
use crate::docstrings::Docstring;
use crate::rules::pydocstyle::settings::Convention;
/// Return the index of the first logical line in a string.
pub(super) fn logical_line(content: &str) -> Option<usize> {
// Find the first logical line.
@ -61,3 +66,59 @@ pub(crate) fn should_ignore_definition(
})
})
}
pub(crate) fn get_section_contexts<'a>(
docstring: &'a Docstring<'a>,
convention: Option<&'a Convention>,
) -> SectionContexts<'a> {
match convention {
Some(Convention::Google) => {
return SectionContexts::from_docstring(docstring, SectionStyle::Google);
}
Some(Convention::Numpy) => {
return SectionContexts::from_docstring(docstring, SectionStyle::Numpy);
}
Some(Convention::Pep257) | None => {
// 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.
// If the docstring contains `Parameters:` or `Other Parameters:`, use the NumPy
// convention.
let numpy_sections = SectionContexts::from_docstring(docstring, SectionStyle::Numpy);
if numpy_sections.iter().any(|context| {
matches!(
context.kind(),
SectionKind::Parameters
| SectionKind::OtherParams
| SectionKind::OtherParameters
)
}) {
return numpy_sections;
}
// If the docstring contains any argument specifier, use the Google convention.
let google_sections = SectionContexts::from_docstring(docstring, SectionStyle::Google);
if google_sections.iter().any(|context| {
matches!(
context.kind(),
SectionKind::Args
| SectionKind::Arguments
| SectionKind::KeywordArgs
| SectionKind::KeywordArguments
| SectionKind::OtherArgs
| SectionKind::OtherArguments
)
}) {
return google_sections;
}
// Otherwise, use whichever convention matched more sections.
if google_sections.len() > numpy_sections.len() {
google_sections
} else {
numpy_sections
}
}
}
}

View file

@ -1324,67 +1324,16 @@ impl AlwaysFixableViolation for BlankLinesBetweenHeaderAndContent {
pub(crate) fn sections(
checker: &mut Checker,
docstring: &Docstring,
section_contexts: &SectionContexts,
convention: Option<&Convention>,
) {
match convention {
Some(Convention::Google) => {
parse_google_sections(
checker,
docstring,
&SectionContexts::from_docstring(docstring, SectionStyle::Google),
);
}
Some(Convention::Numpy) => {
parse_numpy_sections(
checker,
docstring,
&SectionContexts::from_docstring(docstring, SectionStyle::Numpy),
);
}
Some(Convention::Pep257) | None => {
// 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.
// If the docstring contains `Parameters:` or `Other Parameters:`, use the NumPy
// convention.
let numpy_sections = SectionContexts::from_docstring(docstring, SectionStyle::Numpy);
if numpy_sections.iter().any(|context| {
matches!(
context.kind(),
SectionKind::Parameters
| SectionKind::OtherParams
| SectionKind::OtherParameters
)
}) {
parse_numpy_sections(checker, docstring, &numpy_sections);
return;
}
// If the docstring contains any argument specifier, use the Google convention.
let google_sections = SectionContexts::from_docstring(docstring, SectionStyle::Google);
if google_sections.iter().any(|context| {
matches!(
context.kind(),
SectionKind::Args
| SectionKind::Arguments
| SectionKind::KeywordArgs
| SectionKind::KeywordArguments
| SectionKind::OtherArgs
| SectionKind::OtherArguments
)
}) {
parse_google_sections(checker, docstring, &google_sections);
return;
}
// Otherwise, use whichever convention matched more sections.
if google_sections.len() > numpy_sections.len() {
parse_google_sections(checker, docstring, &google_sections);
} else {
parse_numpy_sections(checker, docstring, &numpy_sections);
}
}
Some(Convention::Google) => parse_google_sections(checker, docstring, section_contexts),
Some(Convention::Numpy) => parse_numpy_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),
},
}
}

5
ruff.schema.json generated
View file

@ -2874,6 +2874,11 @@
"DJ01",
"DJ012",
"DJ013",
"DOC",
"DOC5",
"DOC50",
"DOC501",
"DOC502",
"DTZ",
"DTZ0",
"DTZ00",

View file

@ -48,7 +48,7 @@ mod tests {
let snapshot = format!("{}_{}", rule_code.as_ref(), path.to_string_lossy());
let diagnostics = test_path(
Path::new("%s").join(path).as_path(),
&settings::Settings::for_rule(rule_code),
&settings::LinterSettings::for_rule(rule_code),
)?;
assert_messages!(snapshot, diagnostics);
Ok(())