[pitboss] sweep after phase 12: 3 deferred items resolved

This commit is contained in:
pitboss 2026-05-18 11:30:24 -05:00
parent df9fd2bb17
commit 9ed837be9b
5 changed files with 158 additions and 153 deletions

View file

@ -22,7 +22,8 @@ use crate::symbol::Lang;
use tree_sitter::Node;
use super::python_routes::{
bind_path_params, find_python_function, function_formal_names, source_imports_django,
bind_path_params, find_python_function, first_string_arg, function_formal_names,
source_imports_django,
};
pub struct PythonDjangoAdapter;
@ -121,25 +122,6 @@ fn positional_args(args: Node<'_>) -> Vec<Node<'_>> {
out
}
fn first_string_arg(args: Node<'_>, bytes: &[u8]) -> Option<String> {
let mut cur = args.walk();
for c in args.named_children(&mut cur) {
if c.kind() == "string" {
let raw = c.utf8_text(bytes).ok()?;
return Some(strip_quotes(raw).to_owned());
}
}
None
}
fn strip_quotes(raw: &str) -> &str {
let t = raw.trim();
let t = t.strip_prefix("b").unwrap_or(t);
let t = t.strip_prefix("r").unwrap_or(t);
let t = t.strip_prefix("u").unwrap_or(t);
t.trim_matches(['\'', '"'])
}
fn view_arg_references(
node: Node<'_>,
bytes: &[u8],
@ -213,7 +195,6 @@ impl FrameworkAdapter for PythonDjangoAdapter {
// - urls.py registration referencing the function
// - urls.py `ClassName.as_view()` registration referencing the enclosing class
// - class-based view method name (path falls back to `/`)
// - function-based view with `def name(request, ...):` signature
let url_template = url_template_for(
ast,
file_bytes,
@ -223,18 +204,10 @@ impl FrameworkAdapter for PythonDjangoAdapter {
let (method, path) = if let Some(m) = cbv_method {
(m, url_template.unwrap_or_else(|| "/".to_owned()))
} else if url_template.is_some() {
(HttpMethod::GET, url_template.unwrap())
} else if let Some(template) = url_template {
(HttpMethod::GET, template)
} else {
// Last-resort: treat any function whose first formal is
// `request` as a function-based view. This catches the
// common Django pattern in files without an inlined
// urls.py snippet.
let formals = function_formal_names(func_node, file_bytes);
if formals.first().map(String::as_str) != Some("request") {
return None;
}
(HttpMethod::GET, "/".to_owned())
return None;
};
let formals = function_formal_names(func_node, file_bytes);
@ -332,4 +305,21 @@ mod tests {
.detect(&summary("helper"), tree.root_node(), src)
.is_none());
}
#[test]
fn skips_request_first_formal_without_url_registration() {
// Regression guard: an earlier revision stamped any function
// whose first formal was `request` as `(GET, "/")`. The
// brief never prescribed that fallback and it fires on
// utility helpers (`def authenticated(request, perm): ...`,
// decorator wrappers, middleware-shaped helpers) that are not
// routes. Without a matching `urls.py` registration or a
// CBV-method shape, the adapter must return `None` so the
// pipeline surfaces `SpecDerivationFailed`.
let src: &[u8] = b"from django.http import HttpResponse\ndef authenticated(request, perm):\n return HttpResponse(perm)\n";
let tree = parse(src);
assert!(PythonDjangoAdapter
.detect(&summary("authenticated"), tree.root_node(), src)
.is_none());
}
}

View file

@ -20,7 +20,8 @@ use crate::symbol::Lang;
use tree_sitter::Node;
use super::python_routes::{
bind_path_params, find_python_function, function_formal_names, source_imports_fastapi,
bind_path_params, find_python_function, first_string_arg, function_formal_names,
source_imports_fastapi,
};
pub struct PythonFastApiAdapter;
@ -71,25 +72,6 @@ fn decorator_route_shape(decorator: Node<'_>, bytes: &[u8]) -> Option<(HttpMetho
Some((method, path))
}
fn first_string_arg(args: Node<'_>, bytes: &[u8]) -> Option<String> {
let mut cur = args.walk();
for c in args.named_children(&mut cur) {
if c.kind() == "string" {
let raw = c.utf8_text(bytes).ok()?;
return Some(strip_quotes(raw).to_owned());
}
}
None
}
fn strip_quotes(raw: &str) -> &str {
let t = raw.trim();
let t = t.strip_prefix("b").unwrap_or(t);
let t = t.strip_prefix("r").unwrap_or(t);
let t = t.strip_prefix("u").unwrap_or(t);
t.trim_matches(['\'', '"'])
}
/// Refine per-formal bindings by inspecting the parameter list for
/// Pydantic body models and `Depends(...)` declarations. An
/// annotation pointing at a class declared in the same file is
@ -188,17 +170,23 @@ fn call_callee_text(node: Node<'_>, bytes: &[u8]) -> Option<String> {
.map(str::to_owned)
}
/// Enumerate top-level class names so [`refine_for_fastapi`] can spot
/// Pydantic body models. Conservative: walks the file once and
/// records every `class_definition`'s name.
/// Enumerate class names whose superclass list contains a Pydantic
/// model marker, so [`refine_for_fastapi`] only stamps a
/// [`ParamSource::JsonBody`] when the annotation points at a class
/// that actually looks like a request body model. Walks the
/// `superclasses` field on each `class_definition`; a class with no
/// superclasses (or no Pydantic-flavoured base) is excluded — that
/// avoids stamping `JsonBody` on a plain dataclass / enum / DTO
/// declared in the same file.
fn collect_class_names(root: Node<'_>, bytes: &[u8]) -> Vec<String> {
let mut out = Vec::new();
walk_classes(root, bytes, &mut out);
walk_pydantic_classes(root, bytes, &mut out);
out
}
fn walk_classes(node: Node<'_>, bytes: &[u8], out: &mut Vec<String>) {
fn walk_pydantic_classes(node: Node<'_>, bytes: &[u8], out: &mut Vec<String>) {
if node.kind() == "class_definition"
&& class_has_pydantic_base(node, bytes)
&& let Some(name) = node
.child_by_field_name("name")
.and_then(|n| n.utf8_text(bytes).ok())
@ -207,10 +195,35 @@ fn walk_classes(node: Node<'_>, bytes: &[u8], out: &mut Vec<String>) {
}
let mut cur = node.walk();
for child in node.children(&mut cur) {
walk_classes(child, bytes, out);
walk_pydantic_classes(child, bytes, out);
}
}
/// True when the class's superclass list mentions a Pydantic model
/// marker — `BaseModel`, `pydantic.BaseModel`, `RootModel`,
/// `GenericModel`, or one of the FastAPI body-style bases
/// (`SQLModel`).
fn class_has_pydantic_base(class_node: Node<'_>, bytes: &[u8]) -> bool {
let Some(supers) = class_node.child_by_field_name("superclasses") else {
return false;
};
let mut cur = supers.walk();
supers.named_children(&mut cur).any(|sup| {
sup.utf8_text(bytes)
.map(superclass_looks_pydantic)
.unwrap_or(false)
})
}
fn superclass_looks_pydantic(text: &str) -> bool {
let trimmed = text.trim();
let last = trimmed.rsplit_once('.').map(|(_, s)| s).unwrap_or(trimmed);
matches!(
last,
"BaseModel" | "RootModel" | "GenericModel" | "SQLModel"
)
}
impl FrameworkAdapter for PythonFastApiAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
@ -333,6 +346,45 @@ mod tests {
assert!(matches!(db_binding.source, ParamSource::Implicit));
}
#[test]
fn non_pydantic_annotation_stays_query_param() {
// Regression guard: an earlier revision stamped any formal
// whose annotation referenced a class declared in the same
// file as `JsonBody`, even when the class was a plain
// dataclass / enum / DTO with no Pydantic base. A class
// without a Pydantic-flavoured superclass must not promote
// an annotated formal to `JsonBody`.
let src: &[u8] = b"from fastapi import FastAPI\nfrom dataclasses import dataclass\n@dataclass\nclass Item:\n name: str\napp = FastAPI()\n@app.post(\"/items\")\ndef create_item(item: Item):\n return item\n";
let tree = parse(src);
let binding = PythonFastApiAdapter
.detect(&summary("create_item"), tree.root_node(), src)
.unwrap();
let item_binding = binding
.request_params
.iter()
.find(|p| p.name == "item")
.unwrap();
assert!(matches!(item_binding.source, ParamSource::QueryParam(_)));
}
#[test]
fn qualified_pydantic_basemodel_recognised() {
// Regression guard: `class Foo(pydantic.BaseModel):` should
// still promote a formal annotated with `Foo` to JsonBody,
// matching the unqualified `class Foo(BaseModel):` case.
let src: &[u8] = b"from fastapi import FastAPI\nimport pydantic\nclass Item(pydantic.BaseModel):\n name: str\napp = FastAPI()\n@app.post(\"/items\")\ndef create_item(item: Item):\n return item\n";
let tree = parse(src);
let binding = PythonFastApiAdapter
.detect(&summary("create_item"), tree.root_node(), src)
.unwrap();
let item_binding = binding
.request_params
.iter()
.find(|p| p.name == "item")
.unwrap();
assert!(matches!(item_binding.source, ParamSource::JsonBody));
}
#[test]
fn skips_when_fastapi_not_imported() {
let src: &[u8] = b"from flask import Flask\napp = Flask(__name__)\n@app.get(\"/x\")\ndef x():\n return 1\n";

View file

@ -17,7 +17,8 @@ use crate::symbol::Lang;
use tree_sitter::Node;
use super::python_routes::{
bind_path_params, find_python_function, function_formal_names, source_imports_flask,
bind_path_params, find_python_function, first_string_arg, function_formal_names, methods_kwarg,
source_imports_flask,
};
pub struct PythonFlaskAdapter;
@ -90,48 +91,6 @@ fn decorator_route_shape(decorator: Node<'_>, bytes: &[u8]) -> Option<(HttpMetho
Some((method, path))
}
fn first_string_arg(args: Node<'_>, bytes: &[u8]) -> Option<String> {
let mut cur = args.walk();
for c in args.named_children(&mut cur) {
if c.kind() == "string" {
return Some(strip_string_quotes(c.utf8_text(bytes).ok()?).to_owned());
}
}
None
}
fn strip_string_quotes(raw: &str) -> &str {
let t = raw.trim();
let t = t.strip_prefix("b").unwrap_or(t);
let t = t.strip_prefix("r").unwrap_or(t);
let t = t.strip_prefix("u").unwrap_or(t);
t.trim_matches(['\'', '"'])
}
fn methods_kwarg(args: Node<'_>, bytes: &[u8]) -> Option<HttpMethod> {
let mut cur = args.walk();
for arg in args.children(&mut cur) {
if arg.kind() != "keyword_argument" {
continue;
}
let name = arg.child_by_field_name("name")?.utf8_text(bytes).ok()?;
if name != "methods" {
continue;
}
let value = arg.child_by_field_name("value")?;
let mut vc = value.walk();
for child in value.named_children(&mut vc) {
if child.kind() == "string" {
let raw = strip_string_quotes(child.utf8_text(bytes).ok()?);
if let Some(m) = HttpMethod::from_ident(raw) {
return Some(m);
}
}
}
}
None
}
impl FrameworkAdapter for PythonFlaskAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME

View file

@ -9,7 +9,7 @@
//! placeholder-binding semantics (so an unmatched formal becomes a
//! `QueryParam(name)` everywhere, not just in one adapter).
use crate::dynamic::framework::{ParamBinding, ParamSource};
use crate::dynamic::framework::{HttpMethod, ParamBinding, ParamSource};
use tree_sitter::Node;
/// True when `bytes` carries any of the well-known Flask import
@ -251,6 +251,59 @@ pub fn extract_path_placeholders(path: &str) -> Vec<String> {
out
}
/// Find the first positional string literal in a Python `argument_list`.
/// Used by every Python route adapter to pull the path template out of
/// `path("/users", view)` / `@app.route("/x")` / `Route("/x", endpoint=…)`.
pub fn first_string_arg(args: Node<'_>, bytes: &[u8]) -> Option<String> {
let mut cur = args.walk();
for c in args.named_children(&mut cur) {
if c.kind() == "string" {
return Some(strip_quotes(c.utf8_text(bytes).ok()?).to_owned());
}
}
None
}
/// Strip Python string-literal decoration: leading `b`/`r`/`u` prefix
/// and the matched single- or double-quote pair.
pub fn strip_quotes(raw: &str) -> &str {
let t = raw.trim();
let t = t.strip_prefix("b").unwrap_or(t);
let t = t.strip_prefix("r").unwrap_or(t);
let t = t.strip_prefix("u").unwrap_or(t);
t.trim_matches(['\'', '"'])
}
/// Extract the first HTTP method named in a `methods=[…]` keyword
/// argument. Returns `None` when no `methods=` kwarg is present or
/// the list contains no recognised method. Multi-method registrations
/// (`methods=["GET", "POST"]`) bind to the first method seen — the
/// [`super::super::RouteShape`] surface only carries a single method
/// today.
pub fn methods_kwarg(args: Node<'_>, bytes: &[u8]) -> Option<HttpMethod> {
let mut cur = args.walk();
for arg in args.children(&mut cur) {
if arg.kind() != "keyword_argument" {
continue;
}
let name = arg.child_by_field_name("name")?.utf8_text(bytes).ok()?;
if name != "methods" {
continue;
}
let value = arg.child_by_field_name("value")?;
let mut vc = value.walk();
for child in value.named_children(&mut vc) {
if child.kind() == "string" {
let raw = strip_quotes(child.utf8_text(bytes).ok()?);
if let Some(m) = HttpMethod::from_ident(raw) {
return Some(m);
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -17,7 +17,8 @@ use crate::symbol::Lang;
use tree_sitter::Node;
use super::python_routes::{
bind_path_params, find_python_function, function_formal_names, source_imports_starlette,
bind_path_params, find_python_function, first_string_arg, function_formal_names, methods_kwarg,
source_imports_starlette,
};
pub struct PythonStarletteAdapter;
@ -65,25 +66,6 @@ fn walk_routes(node: Node<'_>, bytes: &[u8], target: &str, out: &mut Option<(Htt
}
}
fn first_string_arg(args: Node<'_>, bytes: &[u8]) -> Option<String> {
let mut cur = args.walk();
for c in args.named_children(&mut cur) {
if c.kind() == "string" {
let raw = c.utf8_text(bytes).ok()?;
return Some(strip_quotes(raw).to_owned());
}
}
None
}
fn strip_quotes(raw: &str) -> &str {
let t = raw.trim();
let t = t.strip_prefix("b").unwrap_or(t);
let t = t.strip_prefix("r").unwrap_or(t);
let t = t.strip_prefix("u").unwrap_or(t);
t.trim_matches(['\'', '"'])
}
fn endpoint_references(args: Node<'_>, bytes: &[u8], target: &str) -> bool {
let mut cur = args.walk();
let mut seen_positional = 0usize;
@ -123,37 +105,6 @@ fn identifier_matches(node: Node<'_>, bytes: &[u8], target: &str) -> bool {
last == target || trimmed == target
}
fn methods_kwarg(args: Node<'_>, bytes: &[u8]) -> Option<HttpMethod> {
let mut cur = args.walk();
for arg in args.children(&mut cur) {
if arg.kind() != "keyword_argument" {
continue;
}
let Some(name) = arg
.child_by_field_name("name")
.and_then(|n| n.utf8_text(bytes).ok())
else {
continue;
};
if name != "methods" {
continue;
}
let Some(value) = arg.child_by_field_name("value") else {
continue;
};
let mut vc = value.walk();
for child in value.named_children(&mut vc) {
if child.kind() == "string"
&& let Some(raw) = child.utf8_text(bytes).ok()
&& let Some(m) = HttpMethod::from_ident(strip_quotes(raw))
{
return Some(m);
}
}
}
None
}
impl FrameworkAdapter for PythonStarletteAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME