mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss] sweep after phase 12: 3 deferred items resolved
This commit is contained in:
parent
df9fd2bb17
commit
9ed837be9b
5 changed files with 158 additions and 153 deletions
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue