Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
- Fixed provider exit-code classification for stdout-only authentication and quota failures, thanks @rohitjavvadi.
- Improved Node route mapping to preserve literal Express and Hono mount prefixes, thanks @rohitjavvadi.
- Improved Flask route mapping to preserve static blueprint URL prefixes, thanks @rohitjavvadi.
- Improved Django route mapping to preserve literal `include()` route prefixes, thanks @rohitjavvadi.
- Fixed Express route mapping for aliased Router imports that follow block comment banners, thanks @rohitjavvadi.
- Fixed Laravel route mapping to include array-style `Route::group` prefixes, thanks @rohitjavvadi.
- Fixed Fastify route-object mapping to emit static method arrays while ignoring dynamic entries, thanks @rohitjavvadi.
Expand Down
4 changes: 2 additions & 2 deletions docs/feature-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,8 @@ pytest files; Flask `@*.route(...)` handlers; FastAPI `@*.get(...)` /
`re_path(...)`, and legacy `url(...)` declarations. Flask and FastAPI route
methods are read from list, tuple, or set literals. FastAPI paths can be
positional strings or literal `path=` keywords. Django route paths are normalized
from literal route strings and simple named regex groups; includes are mapped as
their own URL groups without recursively expanding imported URL configs. Default
from literal route strings and simple named regex groups, and literal
`include("module.urls")` routes are expanded under their mount prefixes. Default
Python command detection covers pytest, ruff, mypy, pyright, and black.

Ruby mapping covers project metadata, executables, source groups, RSpec and
Expand Down
79 changes: 79 additions & 0 deletions src/mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13397,6 +13397,8 @@ add_executable(headerapp include/headers.hpp)
const root = await fixtureRoot("clawpatch-python-django-routes-");
await writeFixture(root, "requirements.txt", "django\npytest\n");
await writeFixture(root, "mysite/__init__.py", "");
await writeFixture(root, "api/__init__.py", "");
await writeFixture(root, "api/v1/__init__.py", "");
await writeFixture(
root,
"mysite/urls.py",
Expand Down Expand Up @@ -13452,6 +13454,7 @@ add_executable(headerapp include/headers.hpp)
" path('signup/', SignupView.as_view(), name='signup'),",
" path('admin/', admin.site.urls),",
" path('api/', include('api.urls')),",
" path('slashless', include('api.urls')),",
" path('tuple-api/', include(('tuple.urls', 'tuple'), namespace='tuple')),",
" re_path(r'^legacy/(?P<slug>[-\\w]+)/$', views.legacy, name='legacy'),",
" url(r'^old/(?P<pk>\\d+)/$', views.old_detail),",
Expand All @@ -13467,6 +13470,34 @@ add_executable(headerapp include/headers.hpp)
].join("\n"),
);
await writeFixture(root, "fallback/__init__.py", "");
await writeFixture(
root,
"api/urls.py",
[
"from django.urls import include, path",
"from . import views",
"",
"urlpatterns = [",
" path('users/<int:pk>/', views.user_detail, name='user-detail'),",
" path('status/', views.status, name='status'),",
" path('v1/', include('api.v1.urls')),",
"]",
"",
].join("\n"),
);
await writeFixture(
root,
"api/v1/urls.py",
[
"from django.urls import path",
"from . import views",
"",
"urlpatterns = [",
" path('ping/', views.ping, name='ping'),",
"]",
"",
].join("\n"),
);
await writeFixture(
root,
"fallback/urls.py",
Expand All @@ -13479,8 +13510,12 @@ add_executable(headerapp include/headers.hpp)
"",
].join("\n"),
);
await writeFixture(root, "api/views.py", "def user_detail():\n pass\n");
await writeFixture(root, "api/v1/views.py", "def ping():\n pass\n");
await writeFixture(root, "mysite/views.py", "class SignupView:\n pass\n");
await writeFixture(root, "fallback/views.py", "def dependency_only():\n pass\n");
await writeFixture(root, "api/test_urls.py", "def test_api_urls():\n pass\n");
await writeFixture(root, "api/v1/test_urls.py", "def test_api_v1_urls():\n pass\n");
await writeFixture(root, "mysite/test_urls.py", "def test_urls():\n pass\n");

const project = await detectProject(root);
Expand All @@ -13500,6 +13535,15 @@ add_executable(headerapp include/headers.hpp)
"Django route /signup/",
"Django route /admin/",
"Django route /api/",
"Django route /api/users/:pk/",
"Django route /api/status/",
"Django route /api/v1/",
"Django route /api/v1/ping/",
"Django route /slashless",
"Django route /slashlessusers/:pk/",
"Django route /slashlessstatus/",
"Django route /slashlessv1/",
"Django route /slashlessv1/ping/",
"Django route /tuple-api/",
"Django route /dependency-only/",
"Django route /legacy/:slug/",
Expand All @@ -13518,6 +13562,34 @@ add_executable(headerapp include/headers.hpp)
{ path: "mysite/test_urls.py", command: "pytest" },
]);
expect(byTitle("Django route /api/")?.entrypoints[0]?.symbol).toBe("api.urls");
expect(byTitle("Django route /slashless")?.entrypoints[0]?.symbol).toBe("api.urls");
expect(byTitle("Django route /api/users/:pk/")?.entrypoints[0]).toMatchObject({
path: "api/urls.py",
symbol: "views.user_detail",
route: "/api/users/:pk/",
});
expect(byTitle("Django route /slashlessusers/:pk/")?.entrypoints[0]).toMatchObject({
path: "api/urls.py",
symbol: "views.user_detail",
route: "/slashlessusers/:pk/",
});
expect(byTitle("Django route /api/users/:pk/")?.tests).toEqual([
{ path: "api/test_urls.py", command: "pytest" },
{ path: "api/v1/test_urls.py", command: "pytest" },
]);
expect(byTitle("Django route /api/v1/")?.entrypoints[0]).toMatchObject({
path: "api/urls.py",
symbol: "api.v1.urls",
route: "/api/v1/",
});
expect(byTitle("Django route /api/v1/ping/")?.entrypoints[0]).toMatchObject({
path: "api/v1/urls.py",
symbol: "views.ping",
route: "/api/v1/ping/",
});
expect(byTitle("Django route /api/v1/ping/")?.tests).toEqual([
{ path: "api/v1/test_urls.py", command: "pytest" },
]);
expect(byTitle("Django route /tuple-api/")?.entrypoints[0]?.symbol).toBeNull();
expect(byTitle("Django route /signup/")?.entrypoints[0]?.symbol).toBe("SignupView.as_view");
expect(byTitle("Django route /admin/")?.entrypoints[0]?.symbol).toBe("admin.site.urls");
Expand All @@ -13528,6 +13600,10 @@ add_executable(headerapp include/headers.hpp)
});
expect(byTitle("Django route /accounts/password/reset/")?.trustBoundaries).toContain("auth");
expect(byTitle("Django route /signup/")?.trustBoundaries).toContain("auth");
expect(routes.filter((feature) => feature.title === "Django route /users/:pk/")).toHaveLength(
1,
);
expect(byTitle("Django route /users/:pk/")?.entrypoints[0]?.path).toBe("mysite/urls.py");
expect(byTitle("Django route /users/:pk/")?.trustBoundaries).not.toContain("auth");
expect(byTitle("Django route /orders/")?.trustBoundaries).not.toContain("auth");
expect(titles).not.toContain("Django route /tenant/");
Expand All @@ -13540,6 +13616,9 @@ add_executable(headerapp include/headers.hpp)
expect(titles).not.toContain("Django route /indented-docs-only/");
expect(titles).not.toContain("Django route /local-only/");
expect(titles).not.toContain("Django route /helper/");
expect(titles).not.toContain("Django route /status/");
expect(titles).not.toContain("Django route /v1/");
expect(titles).not.toContain("Django route /v1/ping/");
expect(titles).not.toContain("Django route /unused/");
});

Expand Down
136 changes: 116 additions & 20 deletions src/mappers/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,36 +397,132 @@ async function djangoRouteSeeds(
...(await walk(root, await pythonSourceRoots(root))).filter(isReviewablePythonSourceFile),
]);
const seeds: FeatureSeed[] = [];
const routesByFile = new Map<string, DjangoRoute[]>();
for (const filePath of routeFiles) {
const source = await readFile(join(root, filePath), "utf8");
if (!sourceLooksDjangoUrls(filePath, source, hasDjangoDependency)) {
continue;
}
for (const route of parseDjangoRoutes(filePath, source)) {
const tests = associatedTests([route.filePath], testFiles, testCommand);
seeds.push({
title: `Django route ${route.routePath}`,
summary: djangoRouteSummary(route),
kind: "route",
source: "python-django-route",
confidence: "high",
entryPath: route.filePath,
symbol: route.symbol,
route: route.routePath,
command: null,
ownedFiles: [{ path: route.filePath, reason: "Django URL route declaration" }],
contextFiles: tests.map((test) => ({ path: test.path, reason: "associated test" })),
tests,
tags: ["python", "django", "route"],
trustBoundaries: djangoRouteTrustBoundaries(route),
testCommand,
skipNearbyTests: true,
});
routesByFile.set(filePath, parseDjangoRoutes(filePath, source));
}
const includedRouteFiles = await djangoIncludedRouteFiles(root, routesByFile);
const routeFilesToSeed = [...routesByFile.keys()].filter(
(filePath) => !includedRouteFiles.has(filePath),
);
for (const filePath of routeFilesToSeed) {
const routes = routesByFile.get(filePath) ?? [];
for (const route of routes) {
for (const expanded of await expandDjangoIncludedRoutes(
root,
route,
routesByFile,
new Set([filePath]),
)) {
const tests = associatedTests([expanded.filePath], testFiles, testCommand);
seeds.push({
title: `Django route ${expanded.routePath}`,
summary: djangoRouteSummary(expanded),
kind: "route",
source: "python-django-route",
confidence: "high",
entryPath: expanded.filePath,
symbol: expanded.symbol,
route: expanded.routePath,
command: null,
ownedFiles: [{ path: expanded.filePath, reason: "Django URL route declaration" }],
contextFiles: tests.map((test) => ({ path: test.path, reason: "associated test" })),
tests,
tags: ["python", "django", "route"],
trustBoundaries: djangoRouteTrustBoundaries(expanded),
testCommand,
skipNearbyTests: true,
});
}
}
}
return seeds;
}

async function djangoIncludedRouteFiles(
root: string,
routesByFile: Map<string, DjangoRoute[]>,
): Promise<Set<string>> {
const included = new Set<string>();
for (const routes of routesByFile.values()) {
for (const route of routes) {
if (!route.include || route.symbol === null) {
continue;
}
const includePath = await resolveDjangoIncludeModule(root, route.symbol);
if (includePath !== null && routesByFile.has(includePath)) {
included.add(includePath);
}
}
}
return included;
}

async function expandDjangoIncludedRoutes(
root: string,
route: DjangoRoute,
routesByFile: Map<string, DjangoRoute[]>,
visited: Set<string>,
): Promise<DjangoRoute[]> {
const routes = [route];
if (!route.include || route.symbol === null) {
return routes;
}
const includePath = await resolveDjangoIncludeModule(root, route.symbol);
if (includePath === null || visited.has(includePath)) {
return routes;
}
let includedRoutes = routesByFile.get(includePath);
if (includedRoutes === undefined) {
const source = await readFile(join(root, includePath), "utf8");
includedRoutes = parseDjangoRoutes(includePath, source);
routesByFile.set(includePath, includedRoutes);
}
const nextVisited = new Set([...visited, includePath]);
for (const included of includedRoutes) {
const mounted = {
...included,
routePath: joinDjangoRoutePaths(route.routePath, included.routePath),
};
routes.push(...(await expandDjangoIncludedRoutes(root, mounted, routesByFile, nextVisited)));
}
return routes;
}

async function resolveDjangoIncludeModule(
root: string,
moduleName: string,
): Promise<string | null> {
if (!/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/u.test(moduleName)) {
return null;
}
const modulePath = `${moduleName.replace(/\./gu, "/")}.py`;
const candidates = new Set<string>([modulePath]);
for (const sourceRoot of await pythonSourceRoots(root)) {
candidates.add(`${sourceRoot}/${modulePath}`);
}
for (const candidate of candidates) {
if (await isSafeFile(root, join(root, candidate))) {
return candidate;
}
}
return null;
}

function joinDjangoRoutePaths(prefix: string, route: string): string {
if (prefix === "/") {
return route;
}
if (route === "/") {
return prefix;
}
return normalizeDjangoRoutePath(`${prefix.replace(/^\/+/u, "")}${route.replace(/^\/+/u, "")}`);
}

function sourceLooksDjangoUrls(
filePath: string,
source: string,
Expand Down