chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,517 @@
|
||||
package languages
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func TestPyExtractor_Function(t *testing.T) {
|
||||
src := []byte(`def greet(name):
|
||||
return f"Hello {name}"
|
||||
`)
|
||||
e := NewPythonExtractor()
|
||||
result, err := e.Extract("app.py", src)
|
||||
require.NoError(t, err)
|
||||
|
||||
funcs := nodesOfKind(result.Nodes, graph.KindFunction)
|
||||
require.Len(t, funcs, 1)
|
||||
assert.Equal(t, "greet", funcs[0].Name)
|
||||
}
|
||||
|
||||
func TestPyExtractor_Class(t *testing.T) {
|
||||
src := []byte(`class UserService:
|
||||
def __init__(self):
|
||||
self.users = []
|
||||
|
||||
def get_user(self, user_id):
|
||||
return None
|
||||
`)
|
||||
e := NewPythonExtractor()
|
||||
result, err := e.Extract("service.py", src)
|
||||
require.NoError(t, err)
|
||||
|
||||
types := nodesOfKind(result.Nodes, graph.KindType)
|
||||
require.Len(t, types, 1)
|
||||
assert.Equal(t, "UserService", types[0].Name)
|
||||
|
||||
// Class methods are extracted as KindMethod, not KindFunction.
|
||||
methods := nodesOfKind(result.Nodes, graph.KindMethod)
|
||||
assert.Len(t, methods, 2) // __init__, get_user
|
||||
|
||||
// No top-level functions.
|
||||
funcs := nodesOfKind(result.Nodes, graph.KindFunction)
|
||||
assert.Len(t, funcs, 0)
|
||||
|
||||
// EdgeMemberOf edges link methods to the class.
|
||||
memberEdges := edgesOfKind(result.Edges, graph.EdgeMemberOf)
|
||||
assert.Len(t, memberEdges, 2)
|
||||
for _, e := range memberEdges {
|
||||
assert.Equal(t, "service.py::UserService", e.To)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPyExtractor_Imports(t *testing.T) {
|
||||
src := []byte(`import os
|
||||
from pathlib import Path
|
||||
`)
|
||||
e := NewPythonExtractor()
|
||||
result, err := e.Extract("app.py", src)
|
||||
require.NoError(t, err)
|
||||
|
||||
imports := edgesOfKind(result.Edges, graph.EdgeImports)
|
||||
require.Len(t, imports, 2)
|
||||
}
|
||||
|
||||
func TestPyExtractor_TypeEnv_TypeHint(t *testing.T) {
|
||||
src := []byte(`
|
||||
class UserService:
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
def main():
|
||||
svc: UserService = get_service()
|
||||
svc.save()
|
||||
`)
|
||||
e := NewPythonExtractor()
|
||||
result, err := e.Extract("app.py", src)
|
||||
require.NoError(t, err)
|
||||
|
||||
calls := edgesOfKind(result.Edges, graph.EdgeCalls)
|
||||
var saveCall *graph.Edge
|
||||
for _, c := range calls {
|
||||
if strings.HasSuffix(c.To, "save") {
|
||||
saveCall = c
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, saveCall, "expected a call edge to save")
|
||||
require.NotNil(t, saveCall.Meta, "expected Meta on save call edge")
|
||||
assert.Equal(t, "UserService", saveCall.Meta["receiver_type"])
|
||||
}
|
||||
|
||||
func TestPyExtractor_TypeEnv_ClassConstructor(t *testing.T) {
|
||||
src := []byte(`
|
||||
class Client:
|
||||
def connect(self):
|
||||
pass
|
||||
|
||||
def main():
|
||||
client = Client()
|
||||
client.connect()
|
||||
`)
|
||||
e := NewPythonExtractor()
|
||||
result, err := e.Extract("app.py", src)
|
||||
require.NoError(t, err)
|
||||
|
||||
calls := edgesOfKind(result.Edges, graph.EdgeCalls)
|
||||
var connectCall *graph.Edge
|
||||
for _, c := range calls {
|
||||
if strings.HasSuffix(c.To, "connect") {
|
||||
connectCall = c
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, connectCall)
|
||||
require.NotNil(t, connectCall.Meta)
|
||||
assert.Equal(t, "Client", connectCall.Meta["receiver_type"])
|
||||
}
|
||||
|
||||
func TestPyExtractor_TypeEnv_Chain(t *testing.T) {
|
||||
src := []byte(`
|
||||
class Connection:
|
||||
def query(self) -> Result:
|
||||
return Result()
|
||||
|
||||
class Result:
|
||||
def first(self) -> User:
|
||||
return User()
|
||||
|
||||
class User:
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
def main():
|
||||
conn = Connection()
|
||||
conn.query().first().save()
|
||||
`)
|
||||
e := NewPythonExtractor()
|
||||
result, err := e.Extract("app.py", src)
|
||||
require.NoError(t, err)
|
||||
|
||||
calls := edgesOfKind(result.Edges, graph.EdgeCalls)
|
||||
var saveCall *graph.Edge
|
||||
for _, c := range calls {
|
||||
if strings.HasSuffix(c.To, "save") {
|
||||
saveCall = c
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, saveCall, "expected a call edge to save")
|
||||
require.NotNil(t, saveCall.Meta, "expected Meta on chained save call edge")
|
||||
assert.Equal(t, "User", saveCall.Meta["receiver_type"])
|
||||
}
|
||||
|
||||
func TestPyExtractor_TypeEnv_Unknown(t *testing.T) {
|
||||
src := []byte(`
|
||||
def get_service():
|
||||
return None
|
||||
|
||||
def main():
|
||||
svc = get_service()
|
||||
svc.process()
|
||||
`)
|
||||
e := NewPythonExtractor()
|
||||
result, err := e.Extract("app.py", src)
|
||||
require.NoError(t, err)
|
||||
|
||||
calls := edgesOfKind(result.Edges, graph.EdgeCalls)
|
||||
var processCall *graph.Edge
|
||||
for _, c := range calls {
|
||||
if strings.HasSuffix(c.To, "process") {
|
||||
processCall = c
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, processCall)
|
||||
assert.NotContains(t, processCall.Meta, "receiver_type", "unknown type should not produce a receiver_type hint")
|
||||
}
|
||||
|
||||
func TestPythonExtractor_FastAPIDepends(t *testing.T) {
|
||||
// Depends(target) in a parameter default (or Annotated[T, Depends(target)])
|
||||
// should produce a direct call edge from the handler to target, not
|
||||
// just to the generic Depends function. Without this pass,
|
||||
// callers(target) is empty for any DI-only factory.
|
||||
src := []byte(`
|
||||
from fastapi import Depends
|
||||
from typing import Annotated
|
||||
|
||||
def get_settings():
|
||||
return {"db": "x"}
|
||||
|
||||
def handler(settings: Annotated[dict, Depends(get_settings)]):
|
||||
return settings
|
||||
`)
|
||||
e := NewPythonExtractor()
|
||||
result, err := e.Extract("app.py", src)
|
||||
require.NoError(t, err)
|
||||
|
||||
var found *graph.Edge
|
||||
for _, c := range edgesOfKind(result.Edges, graph.EdgeCalls) {
|
||||
if c.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if v, _ := c.Meta["via"].(string); v == "fastapi.Depends" {
|
||||
if strings.HasSuffix(c.To, "get_settings") {
|
||||
found = c
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
require.NotNil(t, found, "expected a fastapi.Depends edge to get_settings")
|
||||
assert.Equal(t, "app.py::handler", found.From)
|
||||
}
|
||||
|
||||
func TestPythonExtractor_DependsOnlyOnIdentifierArg(t *testing.T) {
|
||||
// Depends() with a non-identifier argument (lambda, attribute access)
|
||||
// shouldn't produce a bogus edge — we can only statically resolve
|
||||
// plain identifier targets.
|
||||
src := []byte(`
|
||||
from fastapi import Depends
|
||||
|
||||
def handler(x = Depends(lambda: 42), y = Depends(obj.method)):
|
||||
return x
|
||||
`)
|
||||
e := NewPythonExtractor()
|
||||
result, err := e.Extract("app.py", src)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, c := range edgesOfKind(result.Edges, graph.EdgeCalls) {
|
||||
if c.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if v, _ := c.Meta["via"].(string); v == "fastapi.Depends" {
|
||||
t.Fatalf("unexpected fastapi.Depends edge: %+v", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPythonExtractor_DocAndVisibility(t *testing.T) {
|
||||
src := []byte(`def foo():
|
||||
"""Foo does the thing.
|
||||
|
||||
Some details.
|
||||
"""
|
||||
return 1
|
||||
|
||||
|
||||
def _helper():
|
||||
"""Internal helper."""
|
||||
return 2
|
||||
|
||||
|
||||
class Server:
|
||||
"""The server."""
|
||||
|
||||
def start(self):
|
||||
"""Start it."""
|
||||
return None
|
||||
|
||||
def _stop(self):
|
||||
return None
|
||||
`)
|
||||
e := NewPythonExtractor()
|
||||
result, err := e.Extract("server.py", src)
|
||||
require.NoError(t, err)
|
||||
|
||||
byID := map[string]*graph.Node{}
|
||||
for _, n := range result.Nodes {
|
||||
byID[n.ID] = n
|
||||
}
|
||||
|
||||
foo := byID["server.py::foo"]
|
||||
require.NotNil(t, foo)
|
||||
if foo.Meta["doc"] != "Foo does the thing." {
|
||||
t.Fatalf("foo.doc = %q", foo.Meta["doc"])
|
||||
}
|
||||
if foo.Meta["visibility"] != "public" {
|
||||
t.Fatalf("foo.vis = %q", foo.Meta["visibility"])
|
||||
}
|
||||
|
||||
helper := byID["server.py::_helper"]
|
||||
require.NotNil(t, helper)
|
||||
if helper.Meta["visibility"] != "private" {
|
||||
t.Fatalf("_helper.vis = %q", helper.Meta["visibility"])
|
||||
}
|
||||
|
||||
server := byID["server.py::Server"]
|
||||
require.NotNil(t, server)
|
||||
if server.Meta["doc"] != "The server." {
|
||||
t.Fatalf("Server.doc = %q", server.Meta["doc"])
|
||||
}
|
||||
if server.Meta["visibility"] != "public" {
|
||||
t.Fatalf("Server.vis = %q", server.Meta["visibility"])
|
||||
}
|
||||
|
||||
start := byID["server.py::Server.start"]
|
||||
require.NotNil(t, start)
|
||||
if start.Meta["doc"] != "Start it." {
|
||||
t.Fatalf("Server.start.doc = %q", start.Meta["doc"])
|
||||
}
|
||||
|
||||
stop := byID["server.py::Server._stop"]
|
||||
require.NotNil(t, stop)
|
||||
if stop.Meta["visibility"] != "private" {
|
||||
t.Fatalf("Server._stop.vis = %q", stop.Meta["visibility"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPythonExtractor_AnnotationEdges(t *testing.T) {
|
||||
src := []byte(`from app import app
|
||||
from functools import lru_cache
|
||||
|
||||
@app.route("/users/:id")
|
||||
@lru_cache
|
||||
def get_user(id):
|
||||
return {}
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
name: str
|
||||
|
||||
@deprecated
|
||||
def old_helper():
|
||||
pass
|
||||
`)
|
||||
e := NewPythonExtractor()
|
||||
result, err := e.Extract("svc.py", src)
|
||||
require.NoError(t, err)
|
||||
|
||||
annNames := map[string]bool{}
|
||||
for _, n := range result.Nodes {
|
||||
if v, _ := n.Meta["kind"].(string); v == "annotation" {
|
||||
annNames[n.Name] = true
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"app.route", "lru_cache", "dataclass", "deprecated"} {
|
||||
if !annNames[want] {
|
||||
t.Fatalf("missing annotation node %q (got %v)", want, annNames)
|
||||
}
|
||||
}
|
||||
|
||||
edges := map[string][]string{}
|
||||
argsByEdge := map[string]string{}
|
||||
for _, e := range result.Edges {
|
||||
if e.Kind != graph.EdgeAnnotated {
|
||||
continue
|
||||
}
|
||||
edges[e.From] = append(edges[e.From], e.To)
|
||||
if v, ok := e.Meta["args"].(string); ok {
|
||||
argsByEdge[e.From+"->"+e.To] = v
|
||||
}
|
||||
}
|
||||
|
||||
if !pyContains(edges["svc.py::get_user"], "annotation::python::app.route") {
|
||||
t.Fatalf("missing app.route edge on get_user, got %v", edges["svc.py::get_user"])
|
||||
}
|
||||
if argsByEdge["svc.py::get_user->annotation::python::app.route"] != `"/users/:id"` {
|
||||
t.Fatalf("app.route args = %q", argsByEdge["svc.py::get_user->annotation::python::app.route"])
|
||||
}
|
||||
if !pyContains(edges["svc.py::User"], "annotation::python::dataclass") {
|
||||
t.Fatalf("missing dataclass edge on User, got %v", edges["svc.py::User"])
|
||||
}
|
||||
}
|
||||
|
||||
func pyContains(haystack []string, needle string) bool {
|
||||
for _, h := range haystack {
|
||||
if h == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestPythonExtractor_ThrowsRaise(t *testing.T) {
|
||||
src := []byte(`def parse(s):
|
||||
if not s:
|
||||
raise ValueError("empty")
|
||||
if len(s) > 10:
|
||||
raise errors.TooLong()
|
||||
return int(s)
|
||||
|
||||
|
||||
def silent():
|
||||
try:
|
||||
x = 1
|
||||
except Exception:
|
||||
pass
|
||||
`)
|
||||
e := NewPythonExtractor()
|
||||
result, err := e.Extract("p.py", src)
|
||||
require.NoError(t, err)
|
||||
|
||||
throws := edgesOfKind(result.Edges, graph.EdgeThrows)
|
||||
parseTargets := map[string]bool{}
|
||||
for _, e := range throws {
|
||||
if e.From == "p.py::parse" {
|
||||
parseTargets[e.To] = true
|
||||
}
|
||||
}
|
||||
assert.True(t, parseTargets["unresolved::ValueError"], "ValueError missing")
|
||||
assert.True(t, parseTargets["unresolved::TooLong"], "TooLong (attribute access) missing")
|
||||
|
||||
for _, e := range throws {
|
||||
if e.From == "p.py::silent" {
|
||||
t.Fatalf("silent() doesn't raise; got %v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPythonExtractor_ImportNodes(t *testing.T) {
|
||||
// Note: relative imports (`from .local import x`) aren't covered
|
||||
// by the current import query — pre-existing limitation. The
|
||||
// import-node promotion only sees absolute paths today.
|
||||
src := []byte(`import os
|
||||
import requests
|
||||
from app import service
|
||||
`)
|
||||
e := NewPythonExtractor()
|
||||
result, err := e.Extract("svc.py", src)
|
||||
require.NoError(t, err)
|
||||
|
||||
imports := nodesOfKind(result.Nodes, graph.KindImport)
|
||||
require.GreaterOrEqual(t, len(imports), 3)
|
||||
|
||||
byID := map[string]*graph.Node{}
|
||||
for _, n := range imports {
|
||||
byID[n.ID] = n
|
||||
}
|
||||
|
||||
osNode := byID["svc.py::import::os"]
|
||||
require.NotNil(t, osNode)
|
||||
assert.Equal(t, false, osNode.Meta["is_external"], "os is stdlib")
|
||||
|
||||
req := byID["svc.py::import::requests"]
|
||||
require.NotNil(t, req)
|
||||
assert.Equal(t, true, req.Meta["is_external"])
|
||||
}
|
||||
|
||||
// Decorated methods (@property, @staticmethod, @contextmanager, ...) are
|
||||
// wrapped in a decorated_definition node between the def and the class
|
||||
// body. They must still classify as class members: two same-named
|
||||
// decorated properties on two classes in one file used to collapse into
|
||||
// bare colliding `<file>::<name>` IDs (httpx's Headers.encoding vs
|
||||
// Response.encoding), so every consumer addressing the class-qualified
|
||||
// ID silently landed on the wrong declaration.
|
||||
func TestPyExtractor_DecoratedMethodsAreClassMembers(t *testing.T) {
|
||||
src := []byte(`class Headers:
|
||||
@property
|
||||
def encoding(self):
|
||||
return "ascii"
|
||||
|
||||
class Response:
|
||||
@property
|
||||
def encoding(self):
|
||||
return "utf-8"
|
||||
|
||||
@staticmethod
|
||||
def build():
|
||||
return Response()
|
||||
|
||||
def caller():
|
||||
@wraps
|
||||
def inner():
|
||||
pass
|
||||
return inner
|
||||
`)
|
||||
e := NewPythonExtractor()
|
||||
result, err := e.Extract("m.py", src)
|
||||
require.NoError(t, err)
|
||||
|
||||
byID := map[string]*graph.Node{}
|
||||
for _, n := range result.Nodes {
|
||||
byID[n.ID] = n
|
||||
}
|
||||
|
||||
he := byID["m.py::Headers.encoding"]
|
||||
require.NotNil(t, he, "decorated property must get a class-qualified ID (got IDs: %v)", pyNodeIDs(result.Nodes))
|
||||
assert.Equal(t, graph.KindMethod, he.Kind)
|
||||
assert.Equal(t, 3, he.StartLine)
|
||||
assert.Equal(t, "Headers", he.Meta["receiver"])
|
||||
|
||||
re := byID["m.py::Response.encoding"]
|
||||
require.NotNil(t, re, "same-named decorated property on a second class must stay distinct")
|
||||
assert.Equal(t, graph.KindMethod, re.Kind)
|
||||
assert.Equal(t, 8, re.StartLine)
|
||||
assert.Equal(t, "Response", re.Meta["receiver"])
|
||||
|
||||
rb := byID["m.py::Response.build"]
|
||||
require.NotNil(t, rb, "@staticmethod def must classify as a method")
|
||||
assert.Equal(t, graph.KindMethod, rb.Kind)
|
||||
|
||||
// A decorated function nested inside a function is NOT a class
|
||||
// member — it stays in the free-function bucket.
|
||||
in := byID["m.py::inner"]
|
||||
require.NotNil(t, in)
|
||||
assert.Equal(t, graph.KindFunction, in.Kind)
|
||||
|
||||
// member_of edges link each decorated method to its class.
|
||||
memberTo := map[string]string{}
|
||||
for _, ed := range edgesOfKind(result.Edges, graph.EdgeMemberOf) {
|
||||
memberTo[ed.From] = ed.To
|
||||
}
|
||||
assert.Equal(t, "m.py::Headers", memberTo["m.py::Headers.encoding"])
|
||||
assert.Equal(t, "m.py::Response", memberTo["m.py::Response.encoding"])
|
||||
assert.Equal(t, "m.py::Response", memberTo["m.py::Response.build"])
|
||||
}
|
||||
|
||||
func pyNodeIDs(nodes []*graph.Node) []string {
|
||||
out := make([]string, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
out = append(out, n.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user