Skip to main content

A fluent API for querying, viewing, and mutating source code — CSS selectors over ASTs, backed by DuckDB.

Project description

pluckit

A fluent API for querying, viewing, and mutating source code. CSS selectors over ASTs, backed by DuckDB.

pluckit lets you select code with CSS-like selectors, render it as formatted source regions, and apply structural mutations — all from a single fluent API or a compact CLI. Under the hood it's a thin Python layer over DuckDB with the sitting_duck AST extension, so queries compile to SQL and run against tree-sitter ASTs for 27 languages.

Status: v0.1-alpha. Query, view, and mutate work end-to-end. Call graph, history, and scope plugins are v0.2.

Install

pip install ast-pluckit

The distribution name is ast-pluckit (the bare pluckit name was taken on PyPI by an abandoned 2019 project). The import name and CLI are still pluckit:

pluckit view ".fn#main" src/**/*.py
from pluckit import Plucker

For local development:

pip install -e .

pluckit needs DuckDB with the sitting_duck community extension. It will auto-install on first use, but you can also run:

pluckit init

to install and verify the extensions eagerly and get clearer diagnostics if anything fails.

The CLI

Full reference lives in the CLI docs. The short version:

View — render matched code regions as markdown

# Show the full body of a function
pluckit view ".fn#parse_chain" training/chain_parser.py

# Just the signature
pluckit view ".fn#parse_chain { show: signature; }" training/chain_parser.py

# First 10 lines
pluckit view ".fn#parse_chain { show: 10; }" training/chain_parser.py

# Class outline — header + method signatures
pluckit view ".cls#ChainSampler" training/chain_sampler.py

# Multi-rule query (CSS-stylesheet style)
pluckit view ".fn { show: signature; } #main { show: body; }" training/generate.py

# Read the query from stdin
echo ".fn[name^=test_] { show: signature; }" | pluckit view - tests/*.py

# Or from a file
pluckit view --query-file audit.q src/**/*.py

# Multiple paths and glob patterns
pluckit view ".fn#parse" src/*.py lib/*.py

When a signature-mode query matches more than one function, the output collapses into a markdown table — dramatically smaller than a code fence per match:

| File               | Lines   | Signature                                  |
|---|---|---|
| src/validate.py    | 35-54   | `def _is_garbled(intent: str) -> bool:`    |
| src/validate.py    | 73-88   | `def _flatten_ops(comp: Any) -> list[str]:`|
| src/validate.py    | 102-196 | `def validate_chain(chain: str) -> Result:`|

Find — list matches for scripting

find is the terse companion to view. It emits one line per match in formats designed for shell pipelines and agent tool-use:

# Default: file:line:name, one per line — feed directly into $(...)
pluckit find ".fn:exported" src/**/*.py

# Just the names — good for set operations
pluckit find ".fn[name^=test_]" --format names tests/*.py | sort -u

# Signature table — a lightweight audit view
pluckit find ".fn:exported" --format signature src/**/*.py

# Machine-readable JSON — one object per match
pluckit find ".cls" --format json src/**/*.py

# Just the total count
pluckit find ".fn" --count src/**/*.py

Edit — apply structural changes to matched nodes

All edits are transactional: if any affected file fails syntax re-validation after splicing, every file is rolled back to its pre-edit state. Use --dry-run to see how many matches each path would affect before writing.

# Replace a function's body entirely
pluckit edit ".fn#foo" --replace-with "def foo():\n    return 1" src/*.py

# Scoped find-and-replace within matched nodes (2-arg replace)
pluckit edit ".fn#validate" --replace "return None" "raise ValueError()" src/*.py

# Add a parameter to every matched function
pluckit edit ".fn:exported" --add-param "timeout: int = 30" src/**/*.py

# Remove a parameter by name
pluckit edit ".fn#fetch_user" --remove-param "cache" src/*.py

# Add a keyword argument at every call site (paired with --add-param above)
pluckit edit ".call#fetch_user" --add-arg "timeout=timeout" src/**/*.py

# Remove a keyword argument from every call site
pluckit edit ".call#fetch_user" --remove-arg "cache" src/**/*.py

# Remove matched nodes entirely
pluckit edit ".fn#deprecated_helper" --remove src/*.py

# Clear a function/class body to `pass` (Python) or `{}` (C-family)
pluckit edit ".fn#todo_later" --clear-body src/*.py

# Rename a definition (first name occurrence)
pluckit edit ".fn#old_name" --rename "new_name" src/*.py

# Insert lines at the top/bottom of matched function bodies
pluckit edit ".fn:exported" --prepend-lines "logger.debug('entered')" src/*.py
pluckit edit ".fn:exported" --append-lines  "logger.debug('exited')"  src/*.py

# Insert at a specific sibling position — anchor is a CSS selector
# resolved against each matched node's subtree (exact, not heuristic)
pluckit edit ".cls#Foo" --insert-lines before ".fn#bar" "def pre_bar(self): pass" src/*.py
pluckit edit ".cls#Foo" --insert-lines after  ".fn#bar" "def post_bar(self): pass" src/*.py
pluckit edit ".fn#main" --insert-lines before ".ret" "cleanup()" src/*.py

# Wrap matched nodes
pluckit edit ".call#query" --wrap "try:" "except DatabaseError:\n    raise" src/*.py

# See what would change without writing — prints a real unified diff
pluckit edit ".fn#foo" --remove --dry-run src/*.py

Chaining edits. Multiple operations can share a single group, and multiple (selector, operations) groups can run in one invocation, separated by --. This is how you keep an API and its call sites in sync atomically:

pluckit edit \
    ".cls#Foo .fn#__init__" --add-param "foo: int = 30" \
                            --append-lines "self.foo = foo" \
    -- \
    ".call#Foo"             --add-arg "foo=10" \
    src/**/*.py

The whole command is one transaction: if any group produces invalid syntax in any file, every affected file rolls back to its pre-edit state.

Line-level vs character-level edits. --prepend-lines, --append-lines, and --insert-lines all insert whole new lines at indentation-matched positions. The 2-arg --replace OLD NEW is the character-level equivalent — it does a string-level replace within the matched node's text, preserving the rest of each line verbatim. --replace-with replaces the entire node. Character-level --insert-chars for inline positional insertions is reserved for v0.2.

The Python API

from pluckit import Plucker, AstViewer

pluck = Plucker(code="src/**/*.py", plugins=[AstViewer])

# Query
fns = pluck.find(".fn:exported")
print(fns.count())                  # 47
print(fns.names()[:5])              # ['authenticate', 'decode_jwt', ...]

# View
print(pluck.view(".fn#validate_token { show: signature; }"))

# Mutate (v0.1)
pluck.find(".fn#validate_token").replaceWith(
    "return None",
    "raise ValueError('token required')",
)
pluck.find(".fn:exported").addParam("timeout: int = 30")

Module-level shortcuts

from pluckit import view

# One-shot viewer query — creates an ephemeral Plucker
print(view(".fn#main { show: outline; }", code="src/**/*.py"))

Selector syntax

Selectors mirror CSS but address AST nodes:

Syntax Meaning
.fn All function definitions (cross-language alias)
.cls, .class All class definitions
.call All call expressions
.fn#name Function named name
.fn:exported Public (non-underscore) functions
.fn[name^=test_] Functions whose name starts with test_
.fn[name*=auth] Functions whose name contains auth
.cls#Foo .fn Functions inside class Foo
.fn:has(.call#execute) Functions that call execute()
.fn:not(:has(.try)) Functions with no try block

sitting_duck's full selector language is richer than what pluckit currently compiles — see its docs for :calls(), :matches(), :scope(), and the call graph pseudo-elements. These work when you call ast_select directly against the underlying DuckDB connection; pluckit's fluent layer supports a growing subset.

Viewer show modes

The viewer supports a small declaration language — CSS declaration blocks attached to selectors:

Show value Behavior
body Full matched node text (default for functions, calls, statements)
signature Declaration line only (synthesized from native AST metadata)
outline Class header + method signatures + dataclass fields (default for classes)
enclosing Walk up to the nearest scope and render that as body
N (number) First N lines of the body with ... truncation marker

Rules compose like a stylesheet:

.fn { show: signature; }            /* default most functions to signature */
.fn#main { show: body; }            /* except main — show its full body    */
.cls#Config { show: outline; }      /* Config class with methods listed   */

Plugins

pluckit is composable. Core capabilities stay in Selection; anything that depends on extra data or infrastructure moves to a plugin:

from pluckit import Plucker, AstViewer

pluck = Plucker(
    code="src/**/*.py",
    plugins=[AstViewer],     # viewer with `show:` declarations
    # plugins=[Calls],       # call graph (v0.2)
    # plugins=[History],     # git history (v0.2)
    # plugins=[Scope],       # scope analysis (v0.2)
)

Plugins register new methods on Selection, new pseudo-classes for the selector compiler, and optional upgrades for existing methods (e.g., fledgling-python can upgrade callers() with import-resolved results).

Training data

The training/ directory contains a synthetic training data generator that produces (intent, chain) pairs for fine-tuning a small code model. It works entirely from the API spec in reference/api.yaml — no pluckit runtime needed. A ~40k-pair corpus (19% error-driven, 19% context-bearing) is committed via git LFS under training/.

See training/README.md for generation and formatting.

Architecture

   pluckit.Plucker              entry point, plugin registry, DuckDB context
        │
   pluckit.Selection            lazy DuckDB relation chain
        │                       query, filter, navigate, read, mutate
        ├── pluckit._sql        selector → SQL WHERE fragments
        │
   pluckit.pluckins             optional capabilities
        ├── AstViewer           CSS-style viewer with `show:` declarations
        ├── Calls (v0.2)        call graph via name-join + plugin upgrades
        ├── History (v0.2)      git history via duck_tails
        └── Scope (v0.2)        read/write interface via flags byte
        │
   pluckit.mutation             byte-splice engine with transaction rollback
        └── pluckit.mutations   ReplaceWith, AddParam, Wrap, Rename, ...

All queries ultimately compile to SQL over sitting_duck's read_ast() table. Mutations read files, apply string-level splices at line granularity, re-parse to validate, and roll back on any syntax error.

Contributing

Run tests:

pip install -e ".[dev]"
pytest

189 tests covering selectors, the Selection API, the plugin system, the viewer, the CLI, and the mutation engine.

Project details


Download files

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

Source Distribution

ast_pluckit-0.12.0.tar.gz (108.6 kB view details)

Uploaded Source

Built Distribution

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

ast_pluckit-0.12.0-py3-none-any.whl (82.4 kB view details)

Uploaded Python 3

File details

Details for the file ast_pluckit-0.12.0.tar.gz.

File metadata

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

File hashes

Hashes for ast_pluckit-0.12.0.tar.gz
Algorithm Hash digest
SHA256 2218e2abc29a2230bb799d7ff2419f00cb631f619bcd496a236152105b86c7c5
MD5 d51c226f29ee0dd15de2ff2c526e0ad5
BLAKE2b-256 a77d411563b533ef5c3f2a4892b218917a11397109b330fe81ca2b80e2b0042f

See more details on using hashes here.

Provenance

The following attestation bundles were made for ast_pluckit-0.12.0.tar.gz:

Publisher: release.yml on teaguesterling/pluckit

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

File details

Details for the file ast_pluckit-0.12.0-py3-none-any.whl.

File metadata

  • Download URL: ast_pluckit-0.12.0-py3-none-any.whl
  • Upload date:
  • Size: 82.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for ast_pluckit-0.12.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8e2a083766f6c4470ee528235f2635b67226d552ee65d948bff7d397a0db6289
MD5 33a556a3e047327a8a37b2ffb0987041
BLAKE2b-256 83f451eb9bee92cc0568797ced5ec87ae1e300fd13cfd8bc682046ee135d4d31

See more details on using hashes here.

Provenance

The following attestation bundles were made for ast_pluckit-0.12.0-py3-none-any.whl:

Publisher: release.yml on teaguesterling/pluckit

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 Pingdom Monitoring Sentry Error logging StatusPage Status page