From 9ed837be9bb095b2eab78b9f4fa9b050e7f59332 Mon Sep 17 00:00:00 2001 From: pitboss Date: Mon, 18 May 2026 11:30:24 -0500 Subject: [PATCH] [pitboss] sweep after phase 12: 3 deferred items resolved --- .../framework/adapters/python_django.rs | 54 ++++----- .../framework/adapters/python_fastapi.rs | 104 +++++++++++++----- .../framework/adapters/python_flask.rs | 45 +------- .../framework/adapters/python_routes.rs | 55 ++++++++- .../framework/adapters/python_starlette.rs | 53 +-------- 5 files changed, 158 insertions(+), 153 deletions(-) diff --git a/src/dynamic/framework/adapters/python_django.rs b/src/dynamic/framework/adapters/python_django.rs index 2cbdd216..63ee9574 100644 --- a/src/dynamic/framework/adapters/python_django.rs +++ b/src/dynamic/framework/adapters/python_django.rs @@ -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> { out } -fn first_string_arg(args: Node<'_>, bytes: &[u8]) -> Option { - 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()); + } } diff --git a/src/dynamic/framework/adapters/python_fastapi.rs b/src/dynamic/framework/adapters/python_fastapi.rs index a76e186c..ebcdf89d 100644 --- a/src/dynamic/framework/adapters/python_fastapi.rs +++ b/src/dynamic/framework/adapters/python_fastapi.rs @@ -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 { - 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 { .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 { 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) { +fn walk_pydantic_classes(node: Node<'_>, bytes: &[u8], out: &mut Vec) { 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) { } 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"; diff --git a/src/dynamic/framework/adapters/python_flask.rs b/src/dynamic/framework/adapters/python_flask.rs index 031a0657..1f12cb80 100644 --- a/src/dynamic/framework/adapters/python_flask.rs +++ b/src/dynamic/framework/adapters/python_flask.rs @@ -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 { - 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 { - 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 diff --git a/src/dynamic/framework/adapters/python_routes.rs b/src/dynamic/framework/adapters/python_routes.rs index 53fbf318..c8bc8d14 100644 --- a/src/dynamic/framework/adapters/python_routes.rs +++ b/src/dynamic/framework/adapters/python_routes.rs @@ -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 { 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 { + 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 { + 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::*; diff --git a/src/dynamic/framework/adapters/python_starlette.rs b/src/dynamic/framework/adapters/python_starlette.rs index 1d7b916d..ee7b1369 100644 --- a/src/dynamic/framework/adapters/python_starlette.rs +++ b/src/dynamic/framework/adapters/python_starlette.rs @@ -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 { - 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 { - 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