Skip to content

Commit a6e200a

Browse files
Copilotxenova
andauthored
[jinja] Add support for dictsort filter (#1825)
- [x] Understand the dictsort filter specification from Jinja docs - [x] Implement the dictsort filter in runtime.ts for ObjectValue - [x] Add tests for dictsort filter with various parameters - [x] Test the implementation with edge cases - [x] Run lint and build to ensure code quality - [x] Refactor to eliminate code duplication by extracting helper method - [x] Improve null/undefined handling in sort comparisons - [x] Add documentation for type handling assumptions - [x] Move dictsort logic to ObjectValue builtins as FunctionValue per @xenova's feedback - [x] Extract index calculation to avoid duplication <!-- START COPILOT CODING AGENT SUFFIX --> <details> <summary>Original prompt</summary> > On jinja, Add support for the `dictsort`. jinja filter to @huggingface/jinja package. Here are the docs for the feature: https://jinja.palletsprojects.com/en/stable/templates/#jinja-filters.dictsort </details> <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: xenova <[email protected]> Co-authored-by: Joshua Lochner <[email protected]>
1 parent adcc4fd commit a6e200a

File tree

2 files changed

+299
-1
lines changed

2 files changed

+299
-1
lines changed

packages/jinja/src/runtime.ts

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,91 @@ export class ObjectValue extends RuntimeValue<Map<string, AnyRuntimeValue>> {
303303
["items", new FunctionValue(() => this.items())],
304304
["keys", new FunctionValue(() => this.keys())],
305305
["values", new FunctionValue(() => this.values())],
306+
[
307+
"dictsort",
308+
new FunctionValue((args) => {
309+
// https://jinja.palletsprojects.com/en/stable/templates/#jinja-filters.dictsort
310+
// Sort a dictionary and yield (key, value) pairs.
311+
// Optional parameters:
312+
// - case_sensitive: Sort in a case-sensitive manner (default: false)
313+
// - by: Sort by 'key' or 'value' (default: 'key')
314+
// - reverse: Reverse the sort order (default: false)
315+
316+
// Extract keyword arguments if present
317+
let kwargs = new Map<string, AnyRuntimeValue>();
318+
const positionalArgs = args.filter((arg) => {
319+
if (arg instanceof KeywordArgumentsValue) {
320+
kwargs = arg.value;
321+
return false;
322+
}
323+
return true;
324+
});
325+
326+
const caseSensitive = positionalArgs.at(0) ?? kwargs.get("case_sensitive") ?? new BooleanValue(false);
327+
if (!(caseSensitive instanceof BooleanValue)) {
328+
throw new Error("case_sensitive must be a boolean");
329+
}
330+
331+
const by = positionalArgs.at(1) ?? kwargs.get("by") ?? new StringValue("key");
332+
if (!(by instanceof StringValue)) {
333+
throw new Error("by must be a string");
334+
}
335+
if (!["key", "value"].includes(by.value)) {
336+
throw new Error("by must be either 'key' or 'value'");
337+
}
338+
339+
const reverse = positionalArgs.at(2) ?? kwargs.get("reverse") ?? new BooleanValue(false);
340+
if (!(reverse instanceof BooleanValue)) {
341+
throw new Error("reverse must be a boolean");
342+
}
343+
344+
// Convert to array of [key, value] pairs and sort
345+
const items = Array.from(this.value.entries())
346+
.map(([key, value]) => new ArrayValue([new StringValue(key), value]))
347+
.sort((a, b) => {
348+
const index = by.value === "key" ? 0 : 1;
349+
350+
let aValue: unknown = a.value[index].value;
351+
let bValue: unknown = b.value[index].value;
352+
353+
// Handle null/undefined values - put them at the end
354+
if (aValue == null && bValue == null) return 0;
355+
if (aValue == null) return reverse.value ? -1 : 1;
356+
if (bValue == null) return reverse.value ? 1 : -1;
357+
358+
// For case-insensitive string comparison
359+
if (!caseSensitive.value && typeof aValue === "string" && typeof bValue === "string") {
360+
aValue = aValue.toLowerCase();
361+
bValue = bValue.toLowerCase();
362+
}
363+
364+
// Ensure comparable types:
365+
// This is only an potential issue when `by='value'` and the dictionary has mixed value types
366+
const isPrimitive = (val: unknown) =>
367+
typeof val === "string" || typeof val === "number" || typeof val === "boolean";
368+
const firstNonPrimitive = isPrimitive(aValue) ? (isPrimitive(bValue) ? null : bValue) : aValue;
369+
if (firstNonPrimitive !== null) {
370+
throw new Error(
371+
`Cannot sort dictionary with non-primitive value types (found ${typeof firstNonPrimitive})`
372+
);
373+
} else if (typeof aValue !== typeof bValue) {
374+
throw new Error("Cannot sort dictionary with mixed value types");
375+
}
376+
377+
const a1 = aValue as string | number | boolean;
378+
const b1 = bValue as string | number | boolean;
379+
380+
if (a1 < b1) {
381+
return reverse.value ? 1 : -1;
382+
} else if (a1 > b1) {
383+
return reverse.value ? -1 : 1;
384+
}
385+
return 0;
386+
});
387+
388+
return new ArrayValue(items);
389+
}),
390+
],
306391
]);
307392

308393
items(): ArrayValue {
@@ -818,8 +903,17 @@ export class Interpreter {
818903
);
819904
case "length":
820905
return new IntegerValue(operand.value.size);
821-
default:
906+
default: {
907+
// Check if the filter exists in builtins
908+
const builtin = operand.builtins.get(filter.value);
909+
if (builtin) {
910+
if (builtin instanceof FunctionValue) {
911+
return builtin.value([], environment);
912+
}
913+
return builtin;
914+
}
822915
throw new Error(`Unknown ObjectValue filter: ${filter.value}`);
916+
}
823917
}
824918
} else if (operand instanceof BooleanValue) {
825919
switch (filter.value) {
@@ -996,6 +1090,18 @@ export class Interpreter {
9961090
}
9971091
}
9981092
throw new Error(`Unknown StringValue filter: ${filterName}`);
1093+
} else if (operand instanceof ObjectValue) {
1094+
// Check if the filter exists in builtins for ObjectValue
1095+
const builtin = operand.builtins.get(filterName);
1096+
if (builtin && builtin instanceof FunctionValue) {
1097+
const [args, kwargs] = this.evaluateArguments(filter.args, environment);
1098+
// Pass keyword arguments as the last argument if present
1099+
if (kwargs.size > 0) {
1100+
args.push(new KeywordArgumentsValue(kwargs));
1101+
}
1102+
return builtin.value(args, environment);
1103+
}
1104+
throw new Error(`Unknown ObjectValue filter: ${filterName}`);
9991105
} else {
10001106
throw new Error(`Cannot apply filter "${filterName}" to type: ${operand.type}`);
10011107
}

packages/jinja/test/templates.test.js

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ const TEST_STRINGS = {
114114
FILTER_OPERATOR_15: `|{{ "abcabcabc" | replace("a", "b") }}|{{ "abcabcabc" | replace("a", "b", 1) }}|{{ "abcabcabc" | replace("a", "b", count=1) }}|`,
115115
FILTER_OPERATOR_16: `|{{ undefined | default("hello") }}|{{ false | default("hello") }}|{{ false | default("hello", true) }}|{{ 0 | default("hello", boolean=true) }}|`,
116116
FILTER_OPERATOR_17: `{{ [1, 2, 1, -1, 2] | unique | list | length }}`,
117+
FILTER_OPERATOR_DICTSORT_1: `{% for key, value in mydict | dictsort %}{{ key }}:{{ value }},{% endfor %}`,
118+
FILTER_OPERATOR_DICTSORT_2: `{% for key, value in mydict | dictsort(by='value') %}{{ key }}:{{ value }},{% endfor %}`,
119+
FILTER_OPERATOR_DICTSORT_3: `{% for key, value in mydict | dictsort(reverse=true) %}{{ key }}:{{ value }},{% endfor %}`,
120+
FILTER_OPERATOR_DICTSORT_4: `{% for key, value in casedict | dictsort %}{{ key }}:{{ value }},{% endfor %}`,
121+
FILTER_OPERATOR_DICTSORT_5: `{% for key, value in casedict | dictsort(case_sensitive=true) %}{{ key }}:{{ value }},{% endfor %}`,
122+
FILTER_OPERATOR_DICTSORT_6: `{% for key, value in numdict | dictsort(by='value', reverse=true) %}{{ key }}:{{ value }},{% endfor %}`,
117123

118124
// Filter statements
119125
FILTER_STATEMENTS: `{% filter upper %}text{% endfilter %}`,
@@ -2389,6 +2395,168 @@ const TEST_PARSED = {
23892395
{ value: "length", type: "Identifier" },
23902396
{ value: "}}", type: "CloseExpression" },
23912397
],
2398+
FILTER_OPERATOR_DICTSORT_1: [
2399+
{ value: "{%", type: "OpenStatement" },
2400+
{ value: "for", type: "Identifier" },
2401+
{ value: "key", type: "Identifier" },
2402+
{ value: ",", type: "Comma" },
2403+
{ value: "value", type: "Identifier" },
2404+
{ value: "in", type: "Identifier" },
2405+
{ value: "mydict", type: "Identifier" },
2406+
{ value: "|", type: "Pipe" },
2407+
{ value: "dictsort", type: "Identifier" },
2408+
{ value: "%}", type: "CloseStatement" },
2409+
{ value: "{{", type: "OpenExpression" },
2410+
{ value: "key", type: "Identifier" },
2411+
{ value: "}}", type: "CloseExpression" },
2412+
{ value: ":", type: "Text" },
2413+
{ value: "{{", type: "OpenExpression" },
2414+
{ value: "value", type: "Identifier" },
2415+
{ value: "}}", type: "CloseExpression" },
2416+
{ value: ",", type: "Text" },
2417+
{ value: "{%", type: "OpenStatement" },
2418+
{ value: "endfor", type: "Identifier" },
2419+
{ value: "%}", type: "CloseStatement" },
2420+
],
2421+
FILTER_OPERATOR_DICTSORT_2: [
2422+
{ value: "{%", type: "OpenStatement" },
2423+
{ value: "for", type: "Identifier" },
2424+
{ value: "key", type: "Identifier" },
2425+
{ value: ",", type: "Comma" },
2426+
{ value: "value", type: "Identifier" },
2427+
{ value: "in", type: "Identifier" },
2428+
{ value: "mydict", type: "Identifier" },
2429+
{ value: "|", type: "Pipe" },
2430+
{ value: "dictsort", type: "Identifier" },
2431+
{ value: "(", type: "OpenParen" },
2432+
{ value: "by", type: "Identifier" },
2433+
{ value: "=", type: "Equals" },
2434+
{ value: "value", type: "StringLiteral" },
2435+
{ value: ")", type: "CloseParen" },
2436+
{ value: "%}", type: "CloseStatement" },
2437+
{ value: "{{", type: "OpenExpression" },
2438+
{ value: "key", type: "Identifier" },
2439+
{ value: "}}", type: "CloseExpression" },
2440+
{ value: ":", type: "Text" },
2441+
{ value: "{{", type: "OpenExpression" },
2442+
{ value: "value", type: "Identifier" },
2443+
{ value: "}}", type: "CloseExpression" },
2444+
{ value: ",", type: "Text" },
2445+
{ value: "{%", type: "OpenStatement" },
2446+
{ value: "endfor", type: "Identifier" },
2447+
{ value: "%}", type: "CloseStatement" },
2448+
],
2449+
FILTER_OPERATOR_DICTSORT_3: [
2450+
{ value: "{%", type: "OpenStatement" },
2451+
{ value: "for", type: "Identifier" },
2452+
{ value: "key", type: "Identifier" },
2453+
{ value: ",", type: "Comma" },
2454+
{ value: "value", type: "Identifier" },
2455+
{ value: "in", type: "Identifier" },
2456+
{ value: "mydict", type: "Identifier" },
2457+
{ value: "|", type: "Pipe" },
2458+
{ value: "dictsort", type: "Identifier" },
2459+
{ value: "(", type: "OpenParen" },
2460+
{ value: "reverse", type: "Identifier" },
2461+
{ value: "=", type: "Equals" },
2462+
{ value: "true", type: "Identifier" },
2463+
{ value: ")", type: "CloseParen" },
2464+
{ value: "%}", type: "CloseStatement" },
2465+
{ value: "{{", type: "OpenExpression" },
2466+
{ value: "key", type: "Identifier" },
2467+
{ value: "}}", type: "CloseExpression" },
2468+
{ value: ":", type: "Text" },
2469+
{ value: "{{", type: "OpenExpression" },
2470+
{ value: "value", type: "Identifier" },
2471+
{ value: "}}", type: "CloseExpression" },
2472+
{ value: ",", type: "Text" },
2473+
{ value: "{%", type: "OpenStatement" },
2474+
{ value: "endfor", type: "Identifier" },
2475+
{ value: "%}", type: "CloseStatement" },
2476+
],
2477+
FILTER_OPERATOR_DICTSORT_4: [
2478+
{ value: "{%", type: "OpenStatement" },
2479+
{ value: "for", type: "Identifier" },
2480+
{ value: "key", type: "Identifier" },
2481+
{ value: ",", type: "Comma" },
2482+
{ value: "value", type: "Identifier" },
2483+
{ value: "in", type: "Identifier" },
2484+
{ value: "casedict", type: "Identifier" },
2485+
{ value: "|", type: "Pipe" },
2486+
{ value: "dictsort", type: "Identifier" },
2487+
{ value: "%}", type: "CloseStatement" },
2488+
{ value: "{{", type: "OpenExpression" },
2489+
{ value: "key", type: "Identifier" },
2490+
{ value: "}}", type: "CloseExpression" },
2491+
{ value: ":", type: "Text" },
2492+
{ value: "{{", type: "OpenExpression" },
2493+
{ value: "value", type: "Identifier" },
2494+
{ value: "}}", type: "CloseExpression" },
2495+
{ value: ",", type: "Text" },
2496+
{ value: "{%", type: "OpenStatement" },
2497+
{ value: "endfor", type: "Identifier" },
2498+
{ value: "%}", type: "CloseStatement" },
2499+
],
2500+
FILTER_OPERATOR_DICTSORT_5: [
2501+
{ value: "{%", type: "OpenStatement" },
2502+
{ value: "for", type: "Identifier" },
2503+
{ value: "key", type: "Identifier" },
2504+
{ value: ",", type: "Comma" },
2505+
{ value: "value", type: "Identifier" },
2506+
{ value: "in", type: "Identifier" },
2507+
{ value: "casedict", type: "Identifier" },
2508+
{ value: "|", type: "Pipe" },
2509+
{ value: "dictsort", type: "Identifier" },
2510+
{ value: "(", type: "OpenParen" },
2511+
{ value: "case_sensitive", type: "Identifier" },
2512+
{ value: "=", type: "Equals" },
2513+
{ value: "true", type: "Identifier" },
2514+
{ value: ")", type: "CloseParen" },
2515+
{ value: "%}", type: "CloseStatement" },
2516+
{ value: "{{", type: "OpenExpression" },
2517+
{ value: "key", type: "Identifier" },
2518+
{ value: "}}", type: "CloseExpression" },
2519+
{ value: ":", type: "Text" },
2520+
{ value: "{{", type: "OpenExpression" },
2521+
{ value: "value", type: "Identifier" },
2522+
{ value: "}}", type: "CloseExpression" },
2523+
{ value: ",", type: "Text" },
2524+
{ value: "{%", type: "OpenStatement" },
2525+
{ value: "endfor", type: "Identifier" },
2526+
{ value: "%}", type: "CloseStatement" },
2527+
],
2528+
FILTER_OPERATOR_DICTSORT_6: [
2529+
{ value: "{%", type: "OpenStatement" },
2530+
{ value: "for", type: "Identifier" },
2531+
{ value: "key", type: "Identifier" },
2532+
{ value: ",", type: "Comma" },
2533+
{ value: "value", type: "Identifier" },
2534+
{ value: "in", type: "Identifier" },
2535+
{ value: "numdict", type: "Identifier" },
2536+
{ value: "|", type: "Pipe" },
2537+
{ value: "dictsort", type: "Identifier" },
2538+
{ value: "(", type: "OpenParen" },
2539+
{ value: "by", type: "Identifier" },
2540+
{ value: "=", type: "Equals" },
2541+
{ value: "value", type: "StringLiteral" },
2542+
{ value: ",", type: "Comma" },
2543+
{ value: "reverse", type: "Identifier" },
2544+
{ value: "=", type: "Equals" },
2545+
{ value: "true", type: "Identifier" },
2546+
{ value: ")", type: "CloseParen" },
2547+
{ value: "%}", type: "CloseStatement" },
2548+
{ value: "{{", type: "OpenExpression" },
2549+
{ value: "key", type: "Identifier" },
2550+
{ value: "}}", type: "CloseExpression" },
2551+
{ value: ":", type: "Text" },
2552+
{ value: "{{", type: "OpenExpression" },
2553+
{ value: "value", type: "Identifier" },
2554+
{ value: "}}", type: "CloseExpression" },
2555+
{ value: ",", type: "Text" },
2556+
{ value: "{%", type: "OpenStatement" },
2557+
{ value: "endfor", type: "Identifier" },
2558+
{ value: "%}", type: "CloseStatement" },
2559+
],
23922560

23932561
// Filter statements
23942562
FILTER_STATEMENTS: [
@@ -4020,6 +4188,24 @@ const TEST_CONTEXT = {
40204188
FILTER_OPERATOR_15: {},
40214189
FILTER_OPERATOR_16: {},
40224190
FILTER_OPERATOR_17: {},
4191+
FILTER_OPERATOR_DICTSORT_1: {
4192+
mydict: { c: 3, a: 1, b: 2 },
4193+
},
4194+
FILTER_OPERATOR_DICTSORT_2: {
4195+
mydict: { c: 3, a: 1, b: 2 },
4196+
},
4197+
FILTER_OPERATOR_DICTSORT_3: {
4198+
mydict: { c: 3, a: 1, b: 2 },
4199+
},
4200+
FILTER_OPERATOR_DICTSORT_4: {
4201+
casedict: { B: 2, a: 1, C: 3 },
4202+
},
4203+
FILTER_OPERATOR_DICTSORT_5: {
4204+
casedict: { B: 2, a: 1, C: 3 },
4205+
},
4206+
FILTER_OPERATOR_DICTSORT_6: {
4207+
numdict: { apple: 5, banana: 2, cherry: 8 },
4208+
},
40234209

40244210
// Filter statements
40254211
FILTER_STATEMENTS: {},
@@ -4236,6 +4422,12 @@ const EXPECTED_OUTPUTS = {
42364422
FILTER_OPERATOR_15: `|bbcbbcbbc|bbcabcabc|bbcabcabc|`,
42374423
FILTER_OPERATOR_16: `|hello|false|hello|hello|`,
42384424
FILTER_OPERATOR_17: `3`,
4425+
FILTER_OPERATOR_DICTSORT_1: `a:1,b:2,c:3,`,
4426+
FILTER_OPERATOR_DICTSORT_2: `a:1,b:2,c:3,`,
4427+
FILTER_OPERATOR_DICTSORT_3: `c:3,b:2,a:1,`,
4428+
FILTER_OPERATOR_DICTSORT_4: `a:1,B:2,C:3,`,
4429+
FILTER_OPERATOR_DICTSORT_5: `B:2,C:3,a:1,`,
4430+
FILTER_OPERATOR_DICTSORT_6: `cherry:8,apple:5,banana:2,`,
42394431

42404432
// Filter statements
42414433
FILTER_STATEMENTS: `TEXT`,

0 commit comments

Comments
 (0)