Skip to main content

pydocstring-rs

PyPI - Version PyPI - Python Version Crates.io Version Crates.io MSRV

Python bindings for pydocstring — a zero-dependency Rust parser for Python docstrings (Google and NumPy styles).

Produces a unified syntax tree with byte-precise source locations on every token — designed as infrastructure for linters and formatters.

Features

  • One code path for every style — the unified view (DocumentSectionEntry) reads Google and NumPy docstrings with no style branching
  • Full syntax tree — builds a complete AST, not just extracted fields; traverse it with walk()
  • Byte-precise source locations — every view carries its exact byte range, for pinpoint diagnostics and as an anchor for edits
  • Powered by Rust — native extension with no Python runtime overhead
  • Error-resilient parsingparse*() never raises; malformed input still yields a best-effort tree
  • Style auto-detection — hand it a docstring, get back Style.GOOGLE, Style.NUMPY, or Style.PLAIN

Installation

pip install pydocstring-rs

Usage

Reading a docstring (the unified view)

parse() auto-detects the style; Document gives you a style-independent view of the result. This is the recommended way to read a docstring:

from pydocstring import Document, SectionKind, parse

doc = Document(parse(source))

for section in doc.sections:
    if section.kind == SectionKind.PARAMETERS:
        for entry in section.entries:
            print(entry.name.text, entry.description.logical_text)

The same loop reads both of these, unchanged — Args: and Parameters both resolve to SectionKind.PARAMETERS, so the role of a section is data, not a type you have to dispatch on:

"""Summary.              """Summary.

Args:                    Parameters
    x (int): The value.  ----------
"""                      x : int
                             The value.
                         """

Every view keeps its range, so the results double as edit anchors:

entry = doc.sections[0].entries[0]
r = entry.description.range

r.source_text(source)          # -> "The value."   (what's there now)

edits = parsed.edit()
edits.replace(r, "A better description.")
edited = edits.apply()

Everything outside that range is preserved byte-for-byte — the NumPy version keeps its indentation, the Google version keeps its x (int): prefix and the space after it.

A range is a byte range, and a Python str indexes by code point. So source[r.start:r.end] cuts in the wrong place as soon as anything upstream of the range is non-ASCII. Use r.source_text(source) to read and an Edits to write; neither can get this wrong.

Accessors on Entry are all optional (name, type_annotation, description, …), so reading an entry never raises for a role that does not carry that piece: a Raises: entry simply has name is None and its exception type in type_annotation.

Every parser returns the same Parsed — there is no per-style result type to dispatch on. Use parse_google() / parse_numpy() / parse_plain() when you want to force a style instead of detecting one.

Editing

edit() starts a list of anchored splices. Everything an edit does not touch is preserved byte-for-byte — this is not a re-render:

from pydocstring import Document, SectionKind, parse

parsed = parse(source)
doc = Document(parsed)
edits = parsed.edit()

for section in doc.sections:
    if section.kind == SectionKind.PARAMETERS:
        for entry in section.entries:
            if entry.name.text == "y":
                edits.replace(entry.description.range, "The other value.")

result = edits.apply()

Scoping a rewrite to one section is the if in that loop. The same code runs over a Google or a NumPy docstring, and each keeps its own layout:

 Summary.                        Summary.

 Args:                           Parameters
     x (int): The value.         ----------
-    y: Another.                 x : int
+    y: The other value.             The value.
                                 y
                                -    Another.
                                +    The other value.
Method Effect
replace(range, text) Replace the bytes of range. A zero-length range inserts.
insert(at, text) Insert at byte offset at.
delete(range) Delete the bytes of range.
remove_lines(range) Delete range with its whole line(s): indentation, newline, and one adjacent trailing blank line.
apply() Validate and splice; returns the new source. Non-consuming.
apply_reparsed() apply(), then re-parse — with the same style, never re-detected.

Two laws hold, and are property-tested over the corpus: an empty edit list reproduces the source exactly, and replacing an element with its own text is the identity. apply() raises EditError (a ValueError) if a range is out of bounds or two edits overlap.

Editing must not silently reinterpret a docstring as another style, so apply_reparsed() re-parses with the original style even if the edited text would auto-detect differently.

Scoped pattern rewrites

replace() rewrites every match in the document, which is often too much — the pattern $NAME: $DESC matches an Args: entry and a Raises: one. replace_in() scopes the rewrite to a view's subtree:

from pydocstring import Document, SectionKind, parse_google

parsed = parse_google(source)
doc = Document(parsed)
args = next(s for s in doc.sections if s.kind == SectionKind.PARAMETERS)

parsed.replace_in(args, "$NAME: $DESC", "$NAME: TODO")   # Raises: is untouched

The anchor also selects the reading: the same shape is a $NAME under Args: and a $TYPE under Raises:. findall_in() scopes a search the same way. Any Document, Section, or Entry of the same parse result works as an anchor.

The raw CST

The unified view is a semantic lens: it answers "is there a type?", and folds away punctuation, whitespace, and the parser's zero-length placeholders. When you need the tree exactly as parsed, go down to the CST with .syntax. It is on the parse result and on every node-backed view — Document, Section, Entry, DefaultMarker, Directive, Citation — but not on TextBlock or Token, which are already leaves of the tree:

from pydocstring import Document, SyntaxKind, parse

entry = Document(parse(source)).sections[0].entries[0]
node = entry.syntax                       # -> Node(ENTRY, ...)

node.kind                                 # SyntaxKind.ENTRY
node.children                             # [Token(NAME), Token(WHITESPACE), ..., Node(DESCRIPTION)]
node.find_token(SyntaxKind.TYPE)          # the type token, if written

The tree's vocabulary is style-independent — a Google entry and a NumPy entry are both SyntaxKind.ENTRY — so one traversal walks any docstring.

The CST is what tells apart cases the semantic lens equates. Both of these report entry.type_annotation is None, but they are not the same docstring:

node.find_missing(SyntaxKind.TYPE)   # x ():  -> a zero-length placeholder
node.find_missing(SyntaxKind.TYPE)   # x:     -> None; no type token at all

A missing placeholder's range is an insertion anchor: edits.replace(placeholder.range, "int") writes the type exactly where it belongs.

Every byte of the source is covered by exactly one token, so concatenating the tree's non-missing leaves reproduces the input.

Style Detection

from pydocstring import detect_style, Style

detect_style("Summary.\n\nArgs:\n    x: Desc.")       # Style.GOOGLE
detect_style("Summary.\n\nParameters\n----------\n")  # Style.NUMPY
detect_style("Just a summary.")                       # Style.PLAIN

Style.PLAIN covers docstrings with no recognised section markers: summary-only, summary + extended, and unrecognised styles such as Sphinx.

Forcing a style

parse() auto-detects. When you know the style — or want to force it — use the explicit parsers. They all return the same Parsed:

from pydocstring import parse_google, parse_numpy, parse_plain

parse_google(source)   # read as Google, whatever it looks like
parse_numpy(source)
parse_plain(source)    # no section markers; everything after the summary is extended_summary

Docstrings with no recognised section markers parse as plain. Unrecognised styles such as Sphinx are treated the same way for now: :param: lines are preserved verbatim in extended_summary.

The syntax tree

pretty_print() visualises the whole tree:

print(parse_google("Summary.\n\nArgs:\n    x (int): Value.").pretty_print())
DOCUMENT@0..35 {
  SUMMARY@0..8 {
    TEXT_LINE: "Summary."@0..8
  }
  NEWLINE: "\n"@8..9
  BLANK_LINE: "\n"@9..10
  SECTION@10..35 {
    SECTION_HEADER@10..15 {
      NAME: "Args"@10..14
      COLON: ":"@14..15
    }
    NEWLINE: "\n"@15..16
    WHITESPACE: "    "@16..20
    ENTRY@20..35 {
      NAME: "x"@20..21
      WHITESPACE: " "@21..22
      OPEN_BRACKET: "("@22..23
      TYPE: "int"@23..26
      CLOSE_BRACKET: ")"@26..27
      COLON: ":"@27..28
      WHITESPACE: " "@28..29
      DESCRIPTION@29..35 {
        TEXT_LINE: "Value."@29..35
      }
    }
  }
}

Note the node kinds: SECTION, ENTRY, NAME — nothing in the tree is Google-specific. The same NumPy docstring produces the same kinds, which is why one traversal reads both.

Tree traversal

walk() takes a Visitor subclass and returns it, so results can be read inline. Override any of enter_node, leave_node, visit_token — the hooks you leave alone are never called. Dispatch on kind:

from pydocstring import SyntaxKind, Visitor, parse, walk

class NameCollector(Visitor):
    def __init__(self):
        self.names = []

    def visit_token(self, token, ctx):
        if token.kind == SyntaxKind.NAME:
            self.names.append(token.text)

print(walk(parse(source), NameCollector()).names)

walk() also accepts a Node, so you can walk a subtree:

section = parse(source).syntax.find_node(SyntaxKind.SECTION)
walk(section, NameCollector())

WalkContext is the second argument to every hook, and converts a byte offset to a line/column in O(log n):

class LocPrinter(Visitor):
    def visit_token(self, token, ctx):
        if token.kind == SyntaxKind.NAME:
            lc = ctx.line_col(token.range.start)
            print(f"{token.text} at line {lc.lineno}, col {lc.col}")

Source Locations

Every view carries a byte-precise source range, so a read result is also an edit anchor. Ranges are values: they compare and hash by (start, end).

doc = Document(parse_google("Summary.\n\nArgs:\n    x (int): Value."))
summary = doc.summary
print(summary.range.start, summary.range.end)  # 0 8

The range is a byte range, not a code-point range — splice it with Edits rather than slicing it into a str, which cuts in the wrong place on non-ASCII input.

Model IR (pydocstring.model)

to_model() produces the model IR: owned, interpreted data with the source positions dropped. It lives in its own namespace, mirroring the Rust crate:

from pydocstring import SectionKind, parse_google
from pydocstring.model import Block

parsed = parse_google("Summary.\n\nArgs:\n    x (int): The value.\n")
doc = parsed.to_model()

print(doc.summary)  # "Summary."

for section in doc.sections:
    if section.kind == SectionKind.PARAMETERS:
        for block in section.blocks:
            if isinstance(block, Block.Parameter):
                param = block.value
                print(param.names)            # ["x"]
                print(param.type_annotation)  # "int"
                print(param.description)      # "The value."

A section body is a flat sequence of Blocks in source order: prose Block.Paragraphs interleaved with typed entries (Block.Parameter, Block.Return, Block.Exception, Block.Attribute, Block.Method, Block.SeeAlso, Block.Reference).

Model or unified view? The dividing line is byte positions. The model drops them, which is what lets it apply semantics the tree cannot express (merging consecutive lines into one paragraph, for instance) — and it is why the model is a one-way projection: use it to inspect, transform, and re-emit. To edit a docstring in place, use the position-preserving Document view above; re-emitting from the model rewrites the whole docstring, including the parts you did not touch.

Emitting (Code Generation)

Re-emit a model Docstring in any style — useful for style conversion or formatting:

from pydocstring import SectionKind, emit_google, emit_numpy
from pydocstring.model import Block, Docstring, Parameter, Section

doc = Docstring(
    summary="Brief summary.",
    sections=[
        Section(
            SectionKind.PARAMETERS,
            [
                Block.Parameter(
                    Parameter(
                        ["x"],
                        type_annotation="int",
                        description="The value.",
                    ),
                ),
            ],
        ),
    ],
)

google = emit_google(doc)
print(google)  # Contains "Args:"

numpy = emit_numpy(doc)
print(numpy)  # Contains "Parameters\n----------"

Combine parsing and emitting to convert between styles:

from pydocstring import parse_google, emit_numpy

parsed = parse_google("Summary.\n\nArgs:\n    x (int): The value.\n")
doc = parsed.to_model()
numpy_text = emit_numpy(doc)
print(numpy_text)  # Contains "Parameters\n----------"

API Reference

Functions

Function Returns Description
parse(text) Parsed Auto-detect style and parse; check .style for the result
parse_google(text) Parsed Parse as Google style
parse_numpy(text) Parsed Parse as NumPy style
parse_plain(text) Parsed Parse as plain (no section markers)
detect_style(text) Style Detect style: Style.GOOGLE, Style.NUMPY, or Style.PLAIN
walk(parsed_or_node, visitor) the visitor Depth-first CST traversal
emit_google(doc) str Emit a model Docstring as Google-style text
emit_numpy(doc) str Emit a model Docstring as NumPy-style text
emit_sphinx(doc) str Emit a model Docstring as Sphinx (reStructuredText) text

Objects

Unified views — the style-independent read lens

Class Key Properties
Document Document(parsed); style, summary, extended_summary, sections, directives, paragraphs, source, range
Section kind (SectionKind), header_name, unknown_name, entries, body, citations, range
Entry name, names, type_annotation, description, is_optional, optionals, defaults, default_value, range
DefaultMarker keyword, separator, value, range
Directive name, argument, description, range
Citation label, description, range

Every accessor is optional, so no read raises for a role that does not carry that piece. None means "not present": these views do not surface zero-length missing placeholders, so they cannot tell x (): from x:. That distinction lives in the raw CST below, which is what find_missing() is for.

Raw CST — the fidelity lens

Reached with .syntax, from a parse result or from any unified view.

Class Key members
Node kind, range, text, children, nodes(kind), tokens(kind), find_node(kind), find_token(kind), find_missing(kind)
Token kind, text, range, is_missing()
SyntaxKind ENTRY, SECTION, NAME, TYPE, DESCRIPTION, COLON, … (31 kinds, plus UNKNOWN); name, is_node(), is_token(), is_trivia()

Editing

Class Members
Edits replace(range, text), insert(at, text), delete(range), remove_lines(range), apply(), apply_reparsed(), len()
EditError Raised by apply() for an out-of-bounds or overlapping edit (a ValueError)
RewriteError Raised by replace() / replace_in() when a template names a metavariable the match does not bind (a ValueError)

Start one with parsed.edit() or doc.edit().

Core types

Class Key members
Parsed style, source, syntax, range, line_col(offset), line_indent(offset), pretty_print(), to_model(), edit(), replace(), replace_in(), findall(), findall_in()
Style GOOGLE, NUMPY, PLAIN (enum)
SectionKind PARAMETERS, RETURNS, RAISES, NOTES, … (24 variants — shared by Section.kind and the model)
Token kind, text, range, is_missing()
TextRange TextRange(start, end); start, end, is_empty(), source_text(source), len(r), offset in r — a value: compares and hashes by (start, end)
TextBlock text, logical_text, range, lines, is_missing()
LineColumn lineno (1-based), col (0-based byte column, as ast.col_offset) — from Parsed.line_col() or WalkContext.line_col()
Visitor Base class; subclass and override any of enter_node, leave_node, visit_token
WalkContext line_col(offset), line_indent(offset) — passed as the second argument to every hook

Pattern matching

Class Key members
Match range, text, captures (dict[str, Capture]) — one hit from findall() / findall_in()
Capture range, text, is_multi() — what a $NAME (or $$$NAME) bound to, byte-exact
PatternError Raised for a malformed pattern or template (a ValueError)

Model IR — pydocstring.model

Position-free. SectionKind is shared with the unified view and stays at the top level; everything else lives under pydocstring.model.

Class Key Properties
model.Docstring summary, extended_summary, directives, deprecation (computed), sections
model.Section kind, blocks, unknown_name
model.Block variants Paragraph (text), Parameter/Return/Exception/Attribute/Method/SeeAlso/Reference (value)
model.Parameter names, type_annotation, description, is_optional, default_value
model.Return name, type_annotation, description
model.ExceptionEntry type_name, description
model.Attribute names, type_annotation, description
model.Method name, type_annotation, description
model.SeeAlsoEntry names, description
model.Reference label, content
model.Directive name, argument, description

Development

Prerequisites

  • Rust (stable)
  • uv (manages the Python interpreter, venv, and dev tooling)

Build

cd bindings/python

# Create the venv and install dev tooling (maturin, pytest) into it.
# uv provisions the pinned Python from .python-version automatically.
uv sync

# Build and install the native extension in development mode
uv run maturin develop --uv

# Verify
uv run python -c "import pydocstring; print(pydocstring.detect_style('Args:\n    x: y'))"

After changing the Rust source, re-run uv run maturin develop --uv to rebuild.

Build a wheel

uv run maturin build --release
# Output: target/wheels/pydocstring_rs-*.whl

Publish to PyPI

uv run maturin publish

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pydocstring_rs-0.4.1.tar.gz (267.2 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

pydocstring_rs-0.4.1-cp310-abi3-win_amd64.whl (421.4 kB view details)

Uploaded CPython 3.10+Windows x86-64

pydocstring_rs-0.4.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (589.0 kB view details)

Uploaded CPython 3.10+manylinux: glibc 2.17+ x86-64

pydocstring_rs-0.4.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (592.8 kB view details)

Uploaded CPython 3.10+manylinux: glibc 2.17+ ARM64

pydocstring_rs-0.4.1-cp310-abi3-macosx_11_0_arm64.whl (551.4 kB view details)

Uploaded CPython 3.10+macOS 11.0+ ARM64

pydocstring_rs-0.4.1-cp310-abi3-macosx_10_12_x86_64.whl (552.5 kB view details)

Uploaded CPython 3.10+macOS 10.12+ x86-64

File details

Details for the file pydocstring_rs-0.4.1.tar.gz.

File metadata

  • Download URL: pydocstring_rs-0.4.1.tar.gz
  • Upload date:
  • Size: 267.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for pydocstring_rs-0.4.1.tar.gz
Algorithm Hash digest
SHA256 4e02533991be6cd86e62b0db3f799a6cdf4d0f6ca98d4766f7b7ed7557837493
MD5 500265e3de722ce832da6f6ed57ba57f
BLAKE2b-256 778f9ae889988a6706c43777f1162ad498d80052609a8502b2be061efe5d7e2f

See more details on using hashes here.

Provenance

The following attestation bundles were made for pydocstring_rs-0.4.1.tar.gz:

Publisher: release.yml on ryumasai/pydocstring

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pydocstring_rs-0.4.1-cp310-abi3-win_amd64.whl.

File metadata

File hashes

Hashes for pydocstring_rs-0.4.1-cp310-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 689ea44293a350efc9b6741f40411ef1d92a68a6f858fecc8b0e04a00708a7f4
MD5 a89f11ea06de47f9371fd2b9d2f86132
BLAKE2b-256 f55d8c42a25d6f3d5f6ec0c05cae26b5899e043a954660143e25a0ae3c8706c4

See more details on using hashes here.

Provenance

The following attestation bundles were made for pydocstring_rs-0.4.1-cp310-abi3-win_amd64.whl:

Publisher: release.yml on ryumasai/pydocstring

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pydocstring_rs-0.4.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for pydocstring_rs-0.4.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 65c801087f81e413d390be1ca605682484f935bac2159d8dd496f0bc49de5113
MD5 6f0396449fe6dad5e823242e2b698d87
BLAKE2b-256 2c1df51948f3eb2543cf39ac0fb8a1346af0fb1be6f3581270bf07ca87de8468

See more details on using hashes here.

Provenance

The following attestation bundles were made for pydocstring_rs-0.4.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:

Publisher: release.yml on ryumasai/pydocstring

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pydocstring_rs-0.4.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for pydocstring_rs-0.4.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 2a6fce206a5ab621be3c05e9d1b6fd73733a756ec656089e24f91099603d8af9
MD5 535b84e3760d174f9448abd833522d9c
BLAKE2b-256 a50aeb908b3195a11e327621f7711b9226319d546b270dbcc4952c38d3112ec7

See more details on using hashes here.

Provenance

The following attestation bundles were made for pydocstring_rs-0.4.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl:

Publisher: release.yml on ryumasai/pydocstring

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pydocstring_rs-0.4.1-cp310-abi3-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for pydocstring_rs-0.4.1-cp310-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 5be3d7e3f92c02cf88182a8bc97660d9dcb579b9b1b0ae503ff1e78f2226f982
MD5 1cb02926954c4279aad75b9cb056002b
BLAKE2b-256 d7681eb910d99be0362e55d07febfdce35cc77a5fc0c8e70c99550e36d68bc19

See more details on using hashes here.

Provenance

The following attestation bundles were made for pydocstring_rs-0.4.1-cp310-abi3-macosx_11_0_arm64.whl:

Publisher: release.yml on ryumasai/pydocstring

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pydocstring_rs-0.4.1-cp310-abi3-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for pydocstring_rs-0.4.1-cp310-abi3-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 cb3b6a9197373b277bb3fc3724ee5987525675ebbd47fbf885a31da04da955f4
MD5 0ab8b76213eb8d6c2080c78f724044ef
BLAKE2b-256 7325a3dbcc25ea955d0e40b952c29a6aa70276c39511cff38d19943f03ce74ed

See more details on using hashes here.

Provenance

The following attestation bundles were made for pydocstring_rs-0.4.1-cp310-abi3-macosx_10_12_x86_64.whl:

Publisher: release.yml on ryumasai/pydocstring

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page