ruff/crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs

1367 lines
44 KiB
Rust

use itertools::Itertools;
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::helpers::{map_callable, map_subscript};
use ruff_python_ast::name::QualifiedName;
use ruff_python_ast::visitor::Visitor;
use ruff_python_ast::{self as ast, Expr, Stmt, visitor};
use ruff_python_semantic::analyze::{function_type, visibility};
use ruff_python_semantic::{Definition, SemanticModel};
use ruff_python_stdlib::identifiers::is_identifier;
use ruff_source_file::{LineRanges, NewlineWithTrailingNewline};
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
use rustc_hash::FxHashMap;
use crate::Violation;
use crate::checkers::ast::Checker;
use crate::docstrings::Docstring;
use crate::docstrings::sections::{SectionContext, 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 include parameters which are not
/// in the function signature.
///
/// ## Why is this bad?
/// If a docstring documents a parameter which is not in the function signature,
/// 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.
/// acceleration: Rate of change of speed.
///
/// Returns:
/// Speed as distance divided by time.
/// """
/// 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
/// ```
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.14.1")]
pub(crate) struct DocstringExtraneousParameter {
id: String,
}
impl Violation for DocstringExtraneousParameter {
#[derive_message_formats]
fn message(&self) -> String {
let DocstringExtraneousParameter { id } = self;
format!("Documented parameter `{id}` is not in the function's signature")
}
fn fix_title(&self) -> Option<String> {
Some("Remove the extraneous parameter from the docstring".to_string())
}
}
/// ## What it does
/// Checks for functions with `return` statements that do not have "Returns"
/// sections in their docstrings.
///
/// ## Why is this bad?
/// A missing "Returns" section is a sign of incomplete documentation.
///
/// This rule is not enforced for abstract methods or functions that only return
/// `None`. It is also ignored for "stub functions": functions where the body only
/// consists of `pass`, `...`, `raise NotImplementedError`, or similar.
///
/// ## 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.
/// """
/// 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
/// ```
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.5.6")]
pub(crate) struct DocstringMissingReturns;
impl Violation for DocstringMissingReturns {
#[derive_message_formats]
fn message(&self) -> String {
"`return` is not documented in docstring".to_string()
}
fn fix_title(&self) -> Option<String> {
Some("Add a \"Returns\" section to the docstring".to_string())
}
}
/// ## What it does
/// Checks for function docstrings with unnecessary "Returns" sections.
///
/// ## Why is this bad?
/// A function without an explicit `return` statement should not have a
/// "Returns" section in its docstring.
///
/// This rule is not enforced for abstract methods. It is also ignored for
/// "stub functions": functions where the body only consists of `pass`, `...`,
/// `raise NotImplementedError`, or similar.
///
/// ## Example
/// ```python
/// def say_hello(n: int) -> None:
/// """Says hello to the user.
///
/// Args:
/// n: Number of times to say hello.
///
/// Returns:
/// Doesn't return anything.
/// """
/// for _ in range(n):
/// print("Hello!")
/// ```
///
/// Use instead:
/// ```python
/// def say_hello(n: int) -> None:
/// """Says hello to the user.
///
/// Args:
/// n: Number of times to say hello.
/// """
/// for _ in range(n):
/// print("Hello!")
/// ```
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.5.6")]
pub(crate) struct DocstringExtraneousReturns;
impl Violation for DocstringExtraneousReturns {
#[derive_message_formats]
fn message(&self) -> String {
"Docstring should not have a returns section because the function doesn't return anything"
.to_string()
}
fn fix_title(&self) -> Option<String> {
Some("Remove the \"Returns\" section".to_string())
}
}
/// ## What it does
/// Checks for functions with `yield` statements that do not have "Yields" sections in
/// their docstrings.
///
/// ## Why is this bad?
/// A missing "Yields" section is a sign of incomplete documentation.
///
/// This rule is not enforced for abstract methods or functions that only yield `None`.
/// It is also ignored for "stub functions": functions where the body only consists
/// of `pass`, `...`, `raise NotImplementedError`, or similar.
///
/// ## Example
/// ```python
/// def count_to_n(n: int) -> int:
/// """Generate integers up to *n*.
///
/// Args:
/// n: The number at which to stop counting.
/// """
/// for i in range(1, n + 1):
/// yield i
/// ```
///
/// Use instead:
/// ```python
/// def count_to_n(n: int) -> int:
/// """Generate integers up to *n*.
///
/// Args:
/// n: The number at which to stop counting.
///
/// Yields:
/// int: The number we're at in the count.
/// """
/// for i in range(1, n + 1):
/// yield i
/// ```
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.5.7")]
pub(crate) struct DocstringMissingYields;
impl Violation for DocstringMissingYields {
#[derive_message_formats]
fn message(&self) -> String {
"`yield` is not documented in docstring".to_string()
}
fn fix_title(&self) -> Option<String> {
Some("Add a \"Yields\" section to the docstring".to_string())
}
}
/// ## What it does
/// Checks for function docstrings with unnecessary "Yields" sections.
///
/// ## Why is this bad?
/// A function that doesn't yield anything should not have a "Yields" section
/// in its docstring.
///
/// This rule is not enforced for abstract methods. It is also ignored for
/// "stub functions": functions where the body only consists of `pass`, `...`,
/// `raise NotImplementedError`, or similar.
///
/// ## Example
/// ```python
/// def say_hello(n: int) -> None:
/// """Says hello to the user.
///
/// Args:
/// n: Number of times to say hello.
///
/// Yields:
/// Doesn't yield anything.
/// """
/// for _ in range(n):
/// print("Hello!")
/// ```
///
/// Use instead:
/// ```python
/// def say_hello(n: int) -> None:
/// """Says hello to the user.
///
/// Args:
/// n: Number of times to say hello.
/// """
/// for _ in range(n):
/// print("Hello!")
/// ```
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.5.7")]
pub(crate) struct DocstringExtraneousYields;
impl Violation for DocstringExtraneousYields {
#[derive_message_formats]
fn message(&self) -> String {
"Docstring has a \"Yields\" section but the function doesn't yield anything".to_string()
}
fn fix_title(&self) -> Option<String> {
Some("Remove the \"Yields\" section".to_string())
}
}
/// ## What it does
/// Checks for function docstrings that do not document all explicitly raised
/// exceptions.
///
/// ## Why is this bad?
/// A function should document all exceptions that are directly raised in some
/// circumstances. Failing to document an exception that could be raised
/// can be misleading to users and/or a sign of incomplete documentation.
///
/// This rule is not enforced for abstract methods. It is also ignored for
/// "stub functions": functions where the body only consists of `pass`, `...`,
/// `raise NotImplementedError`, or similar.
///
/// ## Example
/// ```python
/// class FasterThanLightError(ArithmeticError): ...
///
///
/// 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
/// class FasterThanLightError(ArithmeticError): ...
///
///
/// 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
/// ```
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.5.5")]
pub(crate) 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")
}
fn fix_title(&self) -> Option<String> {
let DocstringMissingException { id } = self;
Some(format!("Add `{id}` to the docstring"))
}
}
/// ## What it does
/// Checks for function docstrings that state that exceptions could be raised
/// even though they are not directly raised in the function body.
///
/// ## Why is this bad?
/// Some conventions prefer non-explicit exceptions be omitted from the
/// docstring.
///
/// This rule is not enforced for abstract methods. It is also ignored for
/// "stub functions": functions where the body only consists of `pass`, `...`,
/// `raise NotImplementedError`, or similar.
///
/// ## 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
/// ```
///
/// ## Known issues
/// It may often be desirable to document *all* exceptions that a function
/// could possibly raise, even those which are not explicitly raised using
/// `raise` statements in the function body.
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.5.5")]
pub(crate) 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(", ")
)
}
}
fn fix_title(&self) -> Option<String> {
let DocstringExtraneousException { ids } = self;
Some(format!(
"Remove {} from the docstring",
ids.iter().map(|id| format!("`{id}`")).join(", ")
))
}
}
/// A generic docstring section.
#[derive(Debug)]
struct GenericSection {
range: TextRange,
}
impl Ranged for GenericSection {
fn range(&self) -> TextRange {
self.range
}
}
impl GenericSection {
fn from_section(section: &SectionContext) -> Self {
Self {
range: section.range(),
}
}
}
/// A parameter in a docstring with its text range.
#[derive(Debug, Clone)]
struct ParameterEntry<'a> {
name: &'a str,
range: TextRange,
}
impl Ranged for ParameterEntry<'_> {
fn range(&self) -> TextRange {
self.range
}
}
/// A "Raises" section in a docstring.
#[derive(Debug)]
struct RaisesSection<'a> {
raised_exceptions: Vec<QualifiedName<'a>>,
range: TextRange,
}
impl Ranged for RaisesSection<'_> {
fn range(&self) -> TextRange {
self.range
}
}
impl<'a> RaisesSection<'a> {
/// Return the raised exceptions for the docstring, or `None` if the docstring does not contain
/// a "Raises" section.
fn from_section(section: &SectionContext<'a>, style: Option<SectionStyle>) -> Self {
Self {
raised_exceptions: parse_raises(section.following_lines_str(), style),
range: section.range(),
}
}
}
/// An "Args" or "Parameters" section in a docstring.
#[derive(Debug)]
struct ParametersSection<'a> {
parameters: Vec<ParameterEntry<'a>>,
range: TextRange,
}
impl Ranged for ParametersSection<'_> {
fn range(&self) -> TextRange {
self.range
}
}
impl<'a> ParametersSection<'a> {
/// Return the parameters for the docstring, or `None` if the docstring does not contain
/// an "Args" or "Parameters" section.
fn from_section(section: &SectionContext<'a>, style: Option<SectionStyle>) -> Self {
Self {
parameters: parse_parameters(
section.following_lines_str(),
section.following_range().start(),
style,
),
range: section.section_name_range(),
}
}
}
#[derive(Debug, Default)]
struct DocstringSections<'a> {
returns: Option<GenericSection>,
yields: Option<GenericSection>,
raises: Option<RaisesSection<'a>>,
parameters: Option<ParametersSection<'a>>,
}
impl<'a> DocstringSections<'a> {
fn from_sections(sections: &'a SectionContexts, style: Option<SectionStyle>) -> Self {
let mut docstring_sections = Self::default();
for section in sections {
match section.kind() {
SectionKind::Args | SectionKind::Arguments | SectionKind::Parameters => {
docstring_sections.parameters =
Some(ParametersSection::from_section(&section, style));
}
SectionKind::Raises => {
docstring_sections.raises = Some(RaisesSection::from_section(&section, style));
}
SectionKind::Returns => {
docstring_sections.returns = Some(GenericSection::from_section(&section));
}
SectionKind::Yields => {
docstring_sections.yields = Some(GenericSection::from_section(&section));
}
_ => continue,
}
}
docstring_sections
}
}
/// Parse the entries in a "Parameters" section of a docstring.
///
/// Attempts to parse using the specified [`SectionStyle`], falling back to the other style if no
/// entries are found.
fn parse_parameters(
content: &str,
content_start: TextSize,
style: Option<SectionStyle>,
) -> Vec<ParameterEntry<'_>> {
match style {
Some(SectionStyle::Google) => parse_parameters_google(content, content_start),
Some(SectionStyle::Numpy) => parse_parameters_numpy(content, content_start),
None => {
let entries = parse_parameters_google(content, content_start);
if entries.is_empty() {
parse_parameters_numpy(content, content_start)
} else {
entries
}
}
}
}
/// Parses Google-style "Args" sections of the form:
///
/// ```python
/// Args:
/// a (int): The first number to add.
/// b (int): The second number to add.
/// ```
fn parse_parameters_google(content: &str, content_start: TextSize) -> Vec<ParameterEntry<'_>> {
let mut entries: Vec<ParameterEntry> = Vec::new();
// Find first entry to determine indentation
let Some(first_arg) = content.lines().next() else {
return entries;
};
let indentation = &first_arg[..first_arg.len() - first_arg.trim_start().len()];
let mut current_pos = TextSize::ZERO;
for line in content.lines() {
let line_start = current_pos;
current_pos = content.full_line_end(line_start);
if let Some(entry) = line.strip_prefix(indentation) {
if entry
.chars()
.next()
.is_some_and(|first_char| !first_char.is_whitespace())
{
let Some((before_colon, _)) = entry.split_once(':') else {
continue;
};
if let Some(param) = before_colon.split_whitespace().next() {
let param_name = param.trim_start_matches('*');
if is_identifier(param_name) {
let param_start = line_start + indentation.text_len();
let param_end = param_start + param.text_len();
entries.push(ParameterEntry {
name: param_name,
range: TextRange::new(
content_start + param_start,
content_start + param_end,
),
});
}
}
}
}
}
entries
}
/// Parses NumPy-style "Parameters" sections of the form:
///
/// ```python
/// Parameters
/// ----------
/// a : int
/// The first number to add.
/// b : int
/// The second number to add.
/// ```
fn parse_parameters_numpy(content: &str, content_start: TextSize) -> Vec<ParameterEntry<'_>> {
let mut entries: Vec<ParameterEntry> = Vec::new();
let mut lines = content.lines();
let Some(dashes) = lines.next() else {
return entries;
};
let indentation = &dashes[..dashes.len() - dashes.trim_start().len()];
let mut current_pos = content.full_line_end(dashes.text_len());
for potential in lines {
let line_start = current_pos;
current_pos = content.full_line_end(line_start);
if let Some(entry) = potential.strip_prefix(indentation) {
if entry
.chars()
.next()
.is_some_and(|first_char| !first_char.is_whitespace())
{
if let Some(before_colon) = entry.split(':').next() {
let param_line = before_colon.trim_end();
// Split on commas to handle comma-separated parameters
let mut current_offset = TextSize::from(0);
for param_part in param_line.split(',') {
let param_part_trimmed = param_part.trim();
let param_name = param_part_trimmed.trim_start_matches('*');
if is_identifier(param_name) {
// Calculate the position of this specific parameter part within the line
// Account for leading whitespace that gets trimmed
let param_start_in_line = current_offset
+ (param_part.text_len() - param_part_trimmed.text_len());
let param_start =
line_start + indentation.text_len() + param_start_in_line;
entries.push(ParameterEntry {
name: param_name,
range: TextRange::at(
content_start + param_start,
param_part_trimmed.text_len(),
),
});
}
// Update offset for next iteration: add the part length plus comma length
current_offset = current_offset + param_part.text_len() + ','.text_len();
}
}
}
}
}
entries
}
/// Parse the entries in a "Raises" section of a docstring.
///
/// Attempts to parse using the specified [`SectionStyle`], falling back to the other style if no
/// entries are found.
fn parse_raises(content: &str, style: Option<SectionStyle>) -> Vec<QualifiedName<'_>> {
match style {
Some(SectionStyle::Google) => parse_raises_google(content),
Some(SectionStyle::Numpy) => parse_raises_numpy(content),
None => {
let entries = parse_raises_google(content);
if entries.is_empty() {
parse_raises_numpy(content)
} else {
entries
}
}
}
}
/// Parses Google-style "Raises" section of the form:
///
/// ```python
/// Raises:
/// FasterThanLightError: If speed is greater than the speed of light.
/// DivisionByZero: If attempting to divide by zero.
/// ```
fn parse_raises_google(content: &str) -> Vec<QualifiedName<'_>> {
let mut entries: Vec<QualifiedName> = Vec::new();
for potential in content.lines() {
let Some(colon_idx) = potential.find(':') else {
continue;
};
let entry = potential[..colon_idx].trim();
entries.push(QualifiedName::user_defined(entry));
}
entries
}
/// Parses NumPy-style "Raises" section of the form:
///
/// ```python
/// Raises
/// ------
/// FasterThanLightError
/// If speed is greater than the speed of light.
/// DivisionByZero
/// If attempting to divide by zero.
/// ```
fn parse_raises_numpy(content: &str) -> Vec<QualifiedName<'_>> {
let mut entries: Vec<QualifiedName> = Vec::new();
let mut lines = content.lines();
let Some(dashes) = lines.next() else {
return entries;
};
let indentation = &dashes[..dashes.len() - dashes.trim_start().len()];
for potential in lines {
if let Some(entry) = potential.strip_prefix(indentation) {
if let Some(first_char) = entry.chars().next() {
if !first_char.is_whitespace() {
entries.push(QualifiedName::user_defined(entry.trim_end()));
}
}
}
}
entries
}
/// An individual `yield` expression in a function body.
#[derive(Debug)]
struct YieldEntry {
range: TextRange,
is_none_yield: bool,
}
impl Ranged for YieldEntry {
fn range(&self) -> TextRange {
self.range
}
}
#[expect(clippy::enum_variant_names)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReturnEntryKind {
NotNone,
ImplicitNone,
ExplicitNone,
}
/// An individual `return` statement in a function body.
#[derive(Debug)]
struct ReturnEntry {
range: TextRange,
kind: ReturnEntryKind,
}
impl ReturnEntry {
const fn is_none_return(&self) -> bool {
matches!(
&self.kind,
ReturnEntryKind::ExplicitNone | ReturnEntryKind::ImplicitNone
)
}
const fn is_implicit(&self) -> bool {
matches!(&self.kind, ReturnEntryKind::ImplicitNone)
}
}
impl Ranged for ReturnEntry {
fn range(&self) -> TextRange {
self.range
}
}
/// An individual exception raised in a function body.
#[derive(Debug)]
struct ExceptionEntry<'a> {
qualified_name: QualifiedName<'a>,
range: TextRange,
}
impl Ranged for ExceptionEntry<'_> {
fn range(&self) -> TextRange {
self.range
}
}
/// A summary of documentable statements from the function body
#[derive(Debug)]
struct BodyEntries<'a> {
returns: Vec<ReturnEntry>,
yields: Vec<YieldEntry>,
raised_exceptions: Vec<ExceptionEntry<'a>>,
}
/// An AST visitor to extract a summary of documentable statements from a function body.
struct BodyVisitor<'a> {
returns: Vec<ReturnEntry>,
yields: Vec<YieldEntry>,
currently_suspended_exceptions: Option<&'a ast::Expr>,
raised_exceptions: Vec<ExceptionEntry<'a>>,
semantic: &'a SemanticModel<'a>,
/// Maps exception variable names to their exception expressions in the current except clause
exception_variables: FxHashMap<&'a str, &'a ast::Expr>,
}
impl<'a> BodyVisitor<'a> {
fn new(semantic: &'a SemanticModel) -> Self {
Self {
returns: Vec::new(),
yields: Vec::new(),
currently_suspended_exceptions: None,
raised_exceptions: Vec::new(),
semantic,
exception_variables: FxHashMap::default(),
}
}
fn finish(self) -> BodyEntries<'a> {
let BodyVisitor {
returns,
yields,
mut raised_exceptions,
..
} = self;
// Deduplicate exceptions collected:
// no need to complain twice about `raise TypeError` not being documented
// just because there are two separate `raise TypeError` statements in the function
raised_exceptions.sort_unstable_by(|left, right| {
left.qualified_name
.segments()
.cmp(right.qualified_name.segments())
.then_with(|| left.start().cmp(&right.start()))
.then_with(|| left.end().cmp(&right.end()))
});
raised_exceptions.dedup_by(|left, right| {
left.qualified_name.segments() == right.qualified_name.segments()
});
BodyEntries {
returns,
yields,
raised_exceptions,
}
}
/// Store `exception` if its qualified name does not correspond to one of the exempt types.
fn maybe_store_exception(&mut self, exception: &'a Expr, range: TextRange) {
let Some(qualified_name) = self.semantic.resolve_qualified_name(exception) else {
return;
};
if is_exception_or_base_exception(&qualified_name) {
return;
}
self.raised_exceptions.push(ExceptionEntry {
qualified_name,
range,
});
}
}
impl<'a> Visitor<'a> for BodyVisitor<'a> {
fn visit_except_handler(&mut self, handler: &'a ast::ExceptHandler) {
let ast::ExceptHandler::ExceptHandler(handler_inner) = handler;
self.currently_suspended_exceptions = handler_inner.type_.as_deref();
// Track exception variable bindings
if let Some(name) = handler_inner.name.as_ref() {
if let Some(exceptions) = self.currently_suspended_exceptions {
// Store the exception expression(s) for later resolution
self.exception_variables
.insert(name.id.as_str(), exceptions);
}
}
visitor::walk_except_handler(self, handler);
self.currently_suspended_exceptions = None;
// Clear exception variables when leaving the except handler
self.exception_variables.clear();
}
fn visit_stmt(&mut self, stmt: &'a Stmt) {
match stmt {
Stmt::Raise(ast::StmtRaise { exc, .. }) => {
if let Some(exc) = exc.as_ref() {
// First try to resolve the exception directly
if let Some(qualified_name) =
self.semantic.resolve_qualified_name(map_callable(exc))
{
self.raised_exceptions.push(ExceptionEntry {
qualified_name,
range: exc.range(),
});
} else if let ast::Expr::Name(name) = exc.as_ref() {
// If it's a variable name, check if it's bound to an exception in the
// current except clause
if let Some(exception_expr) = self.exception_variables.get(name.id.as_str())
{
if let ast::Expr::Tuple(tuple) = exception_expr {
for exception in tuple {
self.maybe_store_exception(exception, stmt.range());
}
} else {
self.maybe_store_exception(exception_expr, stmt.range());
}
}
}
} else if let Some(exceptions) = self.currently_suspended_exceptions {
if let ast::Expr::Tuple(tuple) = exceptions {
for exception in tuple {
self.maybe_store_exception(exception, stmt.range());
}
} else {
self.maybe_store_exception(exceptions, stmt.range());
}
}
}
Stmt::Return(ast::StmtReturn {
range,
node_index: _,
value: Some(value),
}) => {
self.returns.push(ReturnEntry {
range: *range,
kind: if value.is_none_literal_expr() {
ReturnEntryKind::ExplicitNone
} else {
ReturnEntryKind::NotNone
},
});
}
Stmt::Return(ast::StmtReturn {
range,
node_index: _,
value: None,
}) => {
self.returns.push(ReturnEntry {
range: *range,
kind: ReturnEntryKind::ImplicitNone,
});
}
Stmt::FunctionDef(_) | Stmt::ClassDef(_) => return,
_ => {}
}
visitor::walk_stmt(self, stmt);
}
fn visit_expr(&mut self, expr: &'a Expr) {
match expr {
Expr::Yield(ast::ExprYield {
range,
node_index: _,
value: Some(value),
}) => {
self.yields.push(YieldEntry {
range: *range,
is_none_yield: value.is_none_literal_expr(),
});
}
Expr::Yield(ast::ExprYield {
range,
node_index: _,
value: None,
}) => {
self.yields.push(YieldEntry {
range: *range,
is_none_yield: true,
});
}
Expr::YieldFrom(ast::ExprYieldFrom { range, .. }) => {
self.yields.push(YieldEntry {
range: *range,
is_none_yield: false,
});
}
Expr::Lambda(_) => return,
_ => {}
}
visitor::walk_expr(self, expr);
}
}
fn is_exception_or_base_exception(qualified_name: &QualifiedName) -> bool {
matches!(
qualified_name.segments(),
[
"" | "builtins",
"BaseException" | "Exception" | "BaseExceptionGroup" | "ExceptionGroup"
]
)
}
fn starts_with_returns(docstring: &Docstring) -> bool {
if let Some(first_word) = docstring.body().as_str().split(' ').next() {
return matches!(first_word, "Return" | "Returns");
}
false
}
fn returns_documented(
docstring: &Docstring,
docstring_sections: &DocstringSections,
convention: Option<Convention>,
) -> bool {
docstring_sections.returns.is_some()
|| (matches!(convention, Some(Convention::Google)) && starts_with_returns(docstring))
}
fn should_document_returns(function_def: &ast::StmtFunctionDef) -> bool {
!matches!(function_def.name.as_str(), "__new__")
}
fn starts_with_yields(docstring: &Docstring) -> bool {
if let Some(first_word) = docstring.body().as_str().split(' ').next() {
return matches!(first_word, "Yield" | "Yields");
}
false
}
fn yields_documented(
docstring: &Docstring,
docstring_sections: &DocstringSections,
convention: Option<Convention>,
) -> bool {
docstring_sections.yields.is_some()
|| (matches!(convention, Some(Convention::Google)) && starts_with_yields(docstring))
}
#[derive(Debug, Copy, Clone)]
enum GeneratorOrIteratorArguments<'a> {
Unparameterized,
Single(&'a Expr),
Several(&'a [Expr]),
}
impl<'a> GeneratorOrIteratorArguments<'a> {
fn first(self) -> Option<&'a Expr> {
match self {
Self::Unparameterized => None,
Self::Single(element) => Some(element),
Self::Several(elements) => elements.first(),
}
}
fn indicates_none_returned(self) -> bool {
match self {
Self::Unparameterized => true,
Self::Single(_) => true,
Self::Several(elements) => elements.get(2).is_none_or(Expr::is_none_literal_expr),
}
}
}
/// Returns the arguments to a generator annotation, if it exists.
fn generator_annotation_arguments<'a>(
expr: &'a Expr,
semantic: &'a SemanticModel,
) -> Option<GeneratorOrIteratorArguments<'a>> {
let qualified_name = semantic.resolve_qualified_name(map_subscript(expr))?;
match qualified_name.segments() {
[
"typing" | "typing_extensions",
"Iterable" | "AsyncIterable" | "Iterator" | "AsyncIterator",
]
| [
"collections",
"abc",
"Iterable" | "AsyncIterable" | "Iterator" | "AsyncIterator",
] => match expr {
Expr::Subscript(ast::ExprSubscript { slice, .. }) => {
Some(GeneratorOrIteratorArguments::Single(slice))
}
_ => Some(GeneratorOrIteratorArguments::Unparameterized),
},
[
"typing" | "typing_extensions",
"Generator" | "AsyncGenerator",
]
| ["collections", "abc", "Generator" | "AsyncGenerator"] => match expr {
Expr::Subscript(ast::ExprSubscript { slice, .. }) => {
if let Expr::Tuple(tuple) = &**slice {
Some(GeneratorOrIteratorArguments::Several(tuple.elts.as_slice()))
} else {
// `Generator[int]` implies `Generator[int, None, None]`
// as it uses a PEP-696 TypeVar with default values
Some(GeneratorOrIteratorArguments::Single(slice))
}
}
_ => Some(GeneratorOrIteratorArguments::Unparameterized),
},
_ => None,
}
}
fn is_generator_function_annotated_as_returning_none(
entries: &BodyEntries,
return_annotations: &Expr,
semantic: &SemanticModel,
) -> bool {
if entries.yields.is_empty() {
return false;
}
generator_annotation_arguments(return_annotations, semantic)
.is_some_and(GeneratorOrIteratorArguments::indicates_none_returned)
}
fn parameters_from_signature<'a>(docstring: &'a Docstring) -> Vec<&'a str> {
let mut parameters = Vec::new();
let Some(function) = docstring.definition.as_function_def() else {
return parameters;
};
for param in &function.parameters {
parameters.push(param.name());
}
parameters
}
fn is_one_line(docstring: &Docstring) -> bool {
let mut non_empty_line_count = 0;
for line in NewlineWithTrailingNewline::from(docstring.body().as_str()) {
if !line.trim().is_empty() {
non_empty_line_count += 1;
}
if non_empty_line_count > 1 {
return false;
}
}
true
}
/// DOC102, DOC201, DOC202, DOC402, DOC403, DOC501, DOC502
pub(crate) fn check_docstring(
checker: &Checker,
definition: &Definition,
docstring: &Docstring,
section_contexts: &SectionContexts,
convention: Option<Convention>,
) {
// Only check function docstrings.
let Some(function_def) = definition.as_function_def() else {
return;
};
if checker.settings().pydoclint.ignore_one_line_docstrings && is_one_line(docstring) {
return;
}
let semantic = checker.semantic();
if function_type::is_stub(function_def, semantic) {
return;
}
// Prioritize the specified convention over the determined style.
let docstring_sections = match convention {
Some(Convention::Google) => {
DocstringSections::from_sections(section_contexts, Some(SectionStyle::Google))
}
Some(Convention::Numpy) => {
DocstringSections::from_sections(section_contexts, Some(SectionStyle::Numpy))
}
Some(Convention::Pep257) | None => DocstringSections::from_sections(section_contexts, None),
};
let body_entries = {
let mut visitor = BodyVisitor::new(semantic);
visitor.visit_body(&function_def.body);
visitor.finish()
};
let signature_parameters = parameters_from_signature(docstring);
// DOC201
if checker.is_rule_enabled(Rule::DocstringMissingReturns) {
if should_document_returns(function_def)
&& !returns_documented(docstring, &docstring_sections, convention)
{
let extra_property_decorators = checker.settings().pydocstyle.property_decorators();
if !definition.is_property(extra_property_decorators, semantic) {
if !body_entries.returns.is_empty() {
match function_def.returns.as_deref() {
Some(returns) => {
// Ignore it if it's annotated as returning `None`
// or it's a generator function annotated as returning `None`,
// i.e. any of `-> None`, `-> Iterator[...]` or `-> Generator[..., ..., None]`
if !returns.is_none_literal_expr()
&& !is_generator_function_annotated_as_returning_none(
&body_entries,
returns,
semantic,
)
{
checker
.report_diagnostic(DocstringMissingReturns, docstring.range());
}
}
None if body_entries
.returns
.iter()
.any(|entry| !entry.is_none_return()) =>
{
checker.report_diagnostic(DocstringMissingReturns, docstring.range());
}
_ => {}
}
}
}
}
}
// DOC402
if checker.is_rule_enabled(Rule::DocstringMissingYields) {
if !yields_documented(docstring, &docstring_sections, convention) {
if !body_entries.yields.is_empty() {
match function_def.returns.as_deref() {
Some(returns)
if !generator_annotation_arguments(returns, semantic).is_some_and(
|arguments| arguments.first().is_none_or(Expr::is_none_literal_expr),
) =>
{
checker.report_diagnostic(DocstringMissingYields, docstring.range());
}
None if body_entries.yields.iter().any(|entry| !entry.is_none_yield) => {
checker.report_diagnostic(DocstringMissingYields, docstring.range());
}
_ => {}
}
}
}
}
// DOC501
if checker.is_rule_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_sections.raises.as_ref().is_some_and(|section| {
section.raised_exceptions.iter().any(|exception| {
body_raise
.qualified_name
.segments()
.ends_with(exception.segments())
})
}) {
checker.report_diagnostic(
DocstringMissingException {
id: (*name).to_string(),
},
docstring.range(),
);
}
}
}
// DOC102
if checker.is_rule_enabled(Rule::DocstringExtraneousParameter) {
// Don't report extraneous parameters if the signature defines *args or **kwargs
if function_def.parameters.vararg.is_none() && function_def.parameters.kwarg.is_none() {
if let Some(docstring_params) = docstring_sections.parameters {
for docstring_param in &docstring_params.parameters {
if !signature_parameters.contains(&docstring_param.name) {
checker.report_diagnostic(
DocstringExtraneousParameter {
id: docstring_param.name.to_string(),
},
docstring_param.range(),
);
}
}
}
}
}
// Avoid applying "extraneous" rules to abstract methods. An abstract method's docstring _could_
// document that it raises an exception without including the exception in the implementation.
if !visibility::is_abstract(&function_def.decorator_list, semantic) {
// DOC202
if checker.is_rule_enabled(Rule::DocstringExtraneousReturns) {
if docstring_sections.returns.is_some() {
if body_entries.returns.is_empty()
|| body_entries.returns.iter().all(ReturnEntry::is_implicit)
{
checker.report_diagnostic(DocstringExtraneousReturns, docstring.range());
}
}
}
// DOC403
if checker.is_rule_enabled(Rule::DocstringExtraneousYields) {
if docstring_sections.yields.is_some() {
if body_entries.yields.is_empty() {
checker.report_diagnostic(DocstringExtraneousYields, docstring.range());
}
}
}
// DOC502
if checker.is_rule_enabled(Rule::DocstringExtraneousException) {
if let Some(docstring_raises) = docstring_sections.raises {
let mut extraneous_exceptions = Vec::new();
for docstring_raise in &docstring_raises.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() {
checker.report_diagnostic(
DocstringExtraneousException {
ids: extraneous_exceptions,
},
docstring.range(),
);
}
}
}
}
}