diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ef40687..9ee1d04 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,6 +18,9 @@ jobs: steps: - uses: actions/checkout@v5 + with: + # Initialize Ruff submodule + submodules: recursive # First check Rust tests - name: Install Rust toolchain @@ -30,7 +33,20 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Run Rust tests - run: cargo test + shell: bash + run: | + # Build cargo test command with -p flags for each package + # We do so because the ruff submodule also contains crates and those will + # run if we don't limit the tests to only our packages. + # And `cargo test` command doesn't allow to exclude crates based on patterns. + # + # 1. Get all directories in our `crates/` folder + packages=$(find crates -maxdepth 1 -mindepth 1 -type d | \ + sed 's/crates\///' | \ + tr '\n' ' ') + echo "Running tests for packages: $packages" + # 2. Format as `cargo test -p -p ...` + cargo test $(echo "$packages" | sed 's/\([^ ]*\)/-p \1/g') # After Rust tests pass, run Python tests next - name: Set up Python ${{ matrix.python-version }} diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d1592a8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "crates/djc-safe-eval/submodules/ruff"] + path = crates/djc-safe-eval/submodules/ruff + url = https://github.com/astral-sh/ruff.git + # tag = 0.14.0 diff --git a/Cargo.lock b/Cargo.lock index e133bd2..bc564e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,195 +3,1880 @@ version = 4 [[package]] -name = "autocfg" -version = "1.4.0" +name = "aho-corasick" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] [[package]] -name = "djc-core" -version = "1.1.0" +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ - "djc-html-transformer", - "pyo3", - "quick-xml", + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", ] [[package]] -name = "djc-html-transformer" -version = "1.0.3" +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-lossy" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d3a5dc826f84d0ea11882bb8054ff7f3d482602e11bb181101303a279ea01f" dependencies = [ - "pyo3", - "quick-xml", + "anstyle", ] [[package]] -name = "heck" -version = "0.5.0" +name = "anstyle-parse" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] [[package]] -name = "indoc" -version = "2.0.7" +name = "anstyle-query" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-svg" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b9ec8c976eada1b0f9747a3d7cc4eae3bef10613e443746e7487f26c872fde" +dependencies = [ + "anstyle", + "anstyle-lossy", + "anstyle-parse", + "html-escape", + "unicode-width", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "attribute-derive" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "boxcar" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" dependencies = [ "rustversion", ] [[package]] -name = "libc" -version = "0.2.169" +name = "cfg-if" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "memchr" -version = "2.7.4" +name = "clap" +version = "4.5.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +dependencies = [ + "clap_builder", + "clap_derive", +] [[package]] -name = "memoffset" -version = "0.9.1" +name = "clap_builder" +version = "4.5.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" dependencies = [ - "autocfg", + "anstream", + "anstyle", + "clap_lex", + "strsim", ] [[package]] -name = "once_cell" -version = "1.21.3" +name = "clap_derive" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "portable-atomic" -version = "1.11.1" +name = "clap_lex" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] -name = "proc-macro2" -version = "1.0.93" +name = "collection_literals" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" dependencies = [ - "unicode-ident", + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", ] [[package]] -name = "pyo3" -version = "0.27.0" +name = "console" +version = "0.15.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa8e48c12afdeb26aa4be4e5c49fb5e11c3efa0878db783a960eea2b9ac6dd19" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" dependencies = [ - "indoc", + "encode_unicode", "libc", - "memoffset", "once_cell", - "portable-atomic", - "pyo3-build-config", - "pyo3-ffi", - "pyo3-macros", - "unindent", + "windows-sys 0.59.0", ] [[package]] -name = "pyo3-build-config" -version = "0.27.0" +name = "crossbeam-deque" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc1989dbf2b60852e0782c7487ebf0b4c7f43161ffe820849b56cf05f945cee1" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" dependencies = [ - "target-lexicon", + "crossbeam-epoch", + "crossbeam-utils", ] [[package]] -name = "pyo3-ffi" -version = "0.27.0" +name = "crossbeam-epoch" +version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c808286da7500385148930152e54fb6883452033085bf1f857d85d4e82ca905c" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ - "libc", - "pyo3-build-config", + "crossbeam-utils", ] [[package]] -name = "pyo3-macros" -version = "0.27.0" +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "derive-where" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0543c16be0d86cf0dbf2e2b636ece9fd38f20406bb43c255e0bc368095f92" +checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" dependencies = [ "proc-macro2", - "pyo3-macros-backend", "quote", "syn", ] [[package]] -name = "pyo3-macros-backend" -version = "0.27.0" +name = "djc-core" +version = "1.1.0" +dependencies = [ + "djc-html-transformer", + "djc-safe-eval", + "pyo3", + "quick-xml", +] + +[[package]] +name = "djc-html-transformer" +version = "1.0.3" +dependencies = [ + "quick-xml", +] + +[[package]] +name = "djc-safe-eval" +version = "1.0.0" +dependencies = [ + "ruff_python_ast", + "ruff_python_codegen", + "ruff_python_parser", + "ruff_source_file", + "ruff_text_size", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "escape8259" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5692dd7b5a1978a5aeb0ce83b7655c58ca8efdcb79d21036ea249da95afec2c6" + +[[package]] +name = "escargot" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a00da2ce064dcd582448ea24a5a26fa9527e0483103019b741ebcbe632dcd29" +checksum = "11c3aea32bc97b500c9ca6a72b768a26e558264303d101d3409cf6d57a9ed0cf" dependencies = [ - "heck", - "proc-macro2", - "pyo3-build-config", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "filetime" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.60.2", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "get-size-derive2" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46b134aa084df7c3a513a1035c52f623e4b3065dfaf3d905a4f28a2e79b5bb3f" +dependencies = [ + "attribute-derive", "quote", "syn", ] [[package]] -name = "quick-xml" -version = "0.38.3" +name = "get-size2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +checksum = "c0d51c9f2e956a517619ad9e7eaebc7a573f9c49b38152e12eade750f89156f9" dependencies = [ - "memchr", + "compact_str", + "get-size-derive2", + "hashbrown 0.16.0", + "smallvec", ] [[package]] -name = "quote" -version = "1.0.38" +name = "getopts" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ - "proc-macro2", + "unicode-width", ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "getrandom" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] [[package]] -name = "syn" -version = "2.0.107" +name = "glob" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a26dbd934e5451d21ef060c018dae56fc073894c5a7896f882928a76e6d081b" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", ] [[package]] -name = "target-lexicon" -version = "0.13.3" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] -name = "unicode-ident" -version = "1.0.14" +name = "hashbrown" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" [[package]] -name = "unindent" -version = "0.2.4" +name = "hashlink" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "insta" +version = "1.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fdb647ebde000f43b5b53f773c30cf9b0cb4300453208713fa38b2c70935a0" +dependencies = [ + "console", + "globset", + "once_cell", + "similar", + "walkdir", +] + +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + +[[package]] +name = "intrusive-collections" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86" +dependencies = [ + "memoffset", +] + +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "is-macro" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] + +[[package]] +name = "libtest-mimic" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc0bda45ed5b3a2904262c1bb91e526127aa70e7ef3758aba2ef93cf896b9b58" +dependencies = [ + "clap", + "escape8259", + "termcolor", + "threadpool", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37a6df7eab65fc7bee654a421404947e10a0f7085b6951bf2ea395f4659fb0cf" +dependencies = [ + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f77d387774f6f6eec64a004eac0ed525aab7fa1966d94b42f743797b3e395afb" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd13844a4242793e02df3e2ec093f540d948299a6a77ea9ce7afd8623f542be" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaf8f9f1108270b90d3676b8679586385430e5c0bb78bb5f043f95499c821a71" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a3b2274450ba5288bc9b8c1b69ff569d1d61189d4bff38f8d22e03d17f932b" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quick-xml" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "ruff_annotate_snippets" +version = "0.1.0" +dependencies = [ + "anstream", + "anstyle", + "memchr", + "ruff_annotate_snippets", + "serde", + "snapbox", + "toml", + "tryfn", + "unicode-width", +] + +[[package]] +name = "ruff_cache" +version = "0.0.0" +dependencies = [ + "filetime", + "glob", + "globset", + "itertools", + "regex", + "ruff_macros", + "seahash", +] + +[[package]] +name = "ruff_macros" +version = "0.0.0" +dependencies = [ + "heck", + "itertools", + "proc-macro2", + "quote", + "ruff_python_trivia", + "syn", +] + +[[package]] +name = "ruff_python_ast" +version = "0.0.0" +dependencies = [ + "aho-corasick", + "bitflags", + "compact_str", + "get-size2", + "is-macro", + "itertools", + "memchr", + "ruff_cache", + "ruff_macros", + "ruff_python_trivia", + "ruff_source_file", + "ruff_text_size", + "rustc-hash", + "salsa", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "ruff_python_codegen" +version = "0.0.0" +dependencies = [ + "ruff_python_ast", + "ruff_python_literal", + "ruff_python_parser", + "ruff_source_file", + "ruff_text_size", + "test-case", +] + +[[package]] +name = "ruff_python_literal" +version = "0.0.0" +dependencies = [ + "bitflags", + "itertools", + "ruff_python_ast", + "unic-ucd-category", +] + +[[package]] +name = "ruff_python_parser" +version = "0.0.0" +dependencies = [ + "anyhow", + "bitflags", + "bstr", + "compact_str", + "get-size2", + "insta", + "memchr", + "ruff_annotate_snippets", + "ruff_python_ast", + "ruff_python_trivia", + "ruff_source_file", + "ruff_text_size", + "rustc-hash", + "serde", + "serde_json", + "static_assertions", + "unicode-ident", + "unicode-normalization", + "unicode_names2", + "walkdir", +] + +[[package]] +name = "ruff_python_trivia" +version = "0.0.0" +dependencies = [ + "itertools", + "ruff_source_file", + "ruff_text_size", + "unicode-ident", +] + +[[package]] +name = "ruff_source_file" +version = "0.0.0" +dependencies = [ + "get-size2", + "memchr", + "ruff_text_size", + "serde", +] + +[[package]] +name = "ruff_text_size" +version = "0.0.0" +dependencies = [ + "get-size2", + "schemars", + "serde", + "serde_test", + "static_assertions", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "salsa" +version = "0.23.0" +source = "git+https://github.com/salsa-rs/salsa.git?rev=29ab321b45d00daa4315fa2a06f7207759a8c87e#29ab321b45d00daa4315fa2a06f7207759a8c87e" +dependencies = [ + "boxcar", + "compact_str", + "crossbeam-queue", + "crossbeam-utils", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "intrusive-collections", + "inventory", + "parking_lot", + "portable-atomic", + "rustc-hash", + "salsa-macro-rules", + "salsa-macros", + "smallvec", + "thin-vec", + "tracing", +] + +[[package]] +name = "salsa-macro-rules" +version = "0.23.0" +source = "git+https://github.com/salsa-rs/salsa.git?rev=29ab321b45d00daa4315fa2a06f7207759a8c87e#29ab321b45d00daa4315fa2a06f7207759a8c87e" + +[[package]] +name = "salsa-macros" +version = "0.23.0" +source = "git+https://github.com/salsa-rs/salsa.git?rev=29ab321b45d00daa4315fa2a06f7207759a8c87e#29ab321b45d00daa4315fa2a06f7207759a8c87e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_test" +version = "1.0.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f901ee573cab6b3060453d2d5f0bae4e6d628c23c0a962ff9b5f1d7c8d4f1ed" +dependencies = [ + "serde", +] + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "snapbox" +version = "0.6.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96fa1ce81be900d083b30ec2d481e6658c2acfaa2cfc7be45ccc2cc1b820edb3" +dependencies = [ + "anstream", + "anstyle", + "anstyle-svg", + "escargot", + "libc", + "normalize-line-endings", + "os_pipe", + "serde_json", + "similar", + "snapbox-macros", + "wait-timeout", + "windows-sys 0.60.2", +] + +[[package]] +name = "snapbox-macros" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b750c344002d7cc69afb9da00ebd9b5c0f8ac2eb7d115d9d45d5b5f47718d74" +dependencies = [ + "anstream", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a26dbd934e5451d21ef060c018dae56fc073894c5a7896f882928a76e6d081b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "test-case" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +dependencies = [ + "test-case-macros", +] + +[[package]] +name = "test-case-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "test-case-macros" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "test-case-core", +] + +[[package]] +name = "thin-vec" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144f754d318415ac792f9d69fc87abbbfc043ce2ef041c60f16ad828f638717d" + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tryfn" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fe242ee9e646acec9ab73a5c540e8543ed1b107f0ce42be831e0775d423c396" +dependencies = [ + "ignore", + "libtest-mimic", + "snapbox", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-category" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8d4591f5fcfe1bd4453baaf803c40e1b1e69ff8455c47620440b46efef91c0" +dependencies = [ + "matches", + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode_names2" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1673eca9782c84de5f81b82e4109dcfb3611c8ba0d52930ec4a9478f547b2dd" +dependencies = [ + "phf", + "unicode_names2_generator", +] + +[[package]] +name = "unicode_names2_generator" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91e5b84611016120197efd7dc93ef76774f4e084cd73c9fb3ea4a86c570c56e" +dependencies = [ + "getopts", + "log", + "phf_codegen", + "rand", +] + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "utf8-width" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 4f41e57..4970ff9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,15 +2,117 @@ members = [ "crates/djc-core", "crates/djc-html-transformer", + "crates/djc-safe-eval", ] resolver = "2" +[workspace.package] +edition = "2024" +rust-version = "1.88" +authors = ["Juro Oravec "] +license = "MIT" +homepage = "https://github.com/django-components/djc-core" +documentation = "https://github.com/django-components/djc-core" +repository = "https://github.com/django-components/djc-core" + [workspace.dependencies] pyo3 = { version = "0.27.1", features = ["extension-module"] } quick-xml = "0.38.3" +# Ruff dependencies +ruff_annotate_snippets = { path = "crates/djc-safe-eval/submodules/ruff/crates/ruff_annotate_snippets" } +ruff_cache = { path = "crates/djc-safe-eval/submodules/ruff/crates/ruff_cache" } +ruff_macros = { path = "crates/djc-safe-eval/submodules/ruff/crates/ruff_macros" } +ruff_python_ast = { path = "crates/djc-safe-eval/submodules/ruff/crates/ruff_python_ast" } +ruff_python_codegen = { path = "crates/djc-safe-eval/submodules/ruff/crates/ruff_python_codegen" } +ruff_python_literal = { path = "crates/djc-safe-eval/submodules/ruff/crates/ruff_python_literal" } +ruff_python_parser = { path = "crates/djc-safe-eval/submodules/ruff/crates/ruff_python_parser" } +ruff_python_trivia = { path = "crates/djc-safe-eval/submodules/ruff/crates/ruff_python_trivia" } +ruff_source_file = { path = "crates/djc-safe-eval/submodules/ruff/crates/ruff_source_file" } +ruff_text_size = { path = "crates/djc-safe-eval/submodules/ruff/crates/ruff_text_size" } + +# Used by ruff_annotate_snippets +anstream = { version = "0.6.18" } +anstyle = { version = "1.0.10" } +unicode-width = { version = "0.2.0" } +snapbox = { version = "0.6.0", features = [ + "diff", + "term-svg", + "cmd", + "examples", +] } +toml = { version = "0.9.0" } +tryfn = { version = "0.2.1" } + +# Used by ruff_python_ast +aho-corasick = { version = "1.1.3" } +bitflags = { version = "2.5.0" } +compact_str = { version = "0.9.0" } +get-size2 = { version = "0.7.0", features = [ + "derive", + "smallvec", + "hashbrown", + "compact-str", +] } +is-macro = { version = "0.3.5" } +itertools = { version = "0.14.0" } +memchr = { version = "2.7.1" } +rustc-hash = { version = "2.0.0" } +salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "29ab321b45d00daa4315fa2a06f7207759a8c87e", default-features = false, features = [ + "compact_str", + "macros", + "salsa_unstable", + "inventory", +] } +schemars = { version = "0.8.16" } +serde = { version = "1.0.197", features = ["derive"] } +thiserror = { version = "2.0.17" } + +# Used by ruff_cache +filetime = { version = "0.2.23" } +glob = { version = "0.3.1" } +globset = { version = "0.4.14" } +regex = { version = "1.12.2" } +seahash = { version = "4.1.0" } + +# Used by ruff_macros +heck = "0.5.0" +proc-macro2 = { version = "1.0.79" } +quote = { version = "1.0.23" } +syn = { version = "2.0.55" } + +# Used by ruff_python_codegen +test-case = { version = "3.3.1" } + +# Used by ruff_python_literal +unic-ucd-category = { version = "0.9" } + +# Used by ruff_python_parser +anyhow = { version = "1.0.80" } +bstr = { version = "1.9.1" } +insta = { version = "1.35.1" } +serde_json = { version = "1.0.113" } +unicode-normalization = { version = "0.1.23" } +unicode_names2 = { version = "1.2.2" } +walkdir = { version = "2.3.2" } + +# Used by ruff_python_trivia +unicode-ident = { version = "1.0.12" } + +# Used by ruff_text_size +serde_test = { version = "1.0.152" } +static_assertions = "1.1.0" + # https://ohadravid.github.io/posts/2023-03-rusty-python [profile.release] debug = true # Debug symbols for profiler. lto = true # Link-time optimization. codegen-units = 1 # Slower compilation but faster code. + +[workspace.lints.rust] +unsafe_code = "warn" +unreachable_pub = "warn" +unexpected_cfgs = { level = "warn", check-cfg = [ + "cfg(fuzzing)", + "cfg(codspeed)", +] } diff --git a/README.md b/README.md index 52bbb5c..55f4afd 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,74 @@ pip install djc-core ## Packages +### Safe eval + +Re-implementation of Jinja2's sandboxed evaluation logic, built in Rust using the Ruff Python parser. + +**Usage** + +```python +from djc_core import safe_eval + +# Compile an expression +compiled = safe_eval("my_var + 1") + +# Evaluate with a context +result = compiled({"my_var": 5}) +print(result) # 6 +``` + +**Key Features** + +- **Security**: Blocks unsafe operations like `eval()`, `exec()`, accessing private attributes (`_private`), and dangerous builtins +- **Variable tracking**: Reports which variables are used and which are assigned via walrus operator (`:=`) +- **Error reporting**: Provides detailed error messages with underlined source code indicating where errors occurred +- **Performance**: Implemented in Rust for fast parsing and transformation + +**Supported Syntax** + +Almost all Python expression features are supported: + +- Literals, data structures, operators +- Comprehensions, lambdas, conditionals +- F-strings and t-strings +- Function calls, attribute/subscript access +- Walrus operator for assignments + +**Security** + +By default, `safe_eval` blocks: + +- Unsafe builtins (`eval`, `exec`, `open`, etc.) +- Private attributes (starting with `_`) +- Dunder attributes (`__class__`, `__dict__`, etc.) +- Functions decorated with `@unsafe` +- Django methods marked with `alters_data = True` + +For more details, examples, and advanced usage, see [`crates/djc-safe-eval/README.md`](crates/djc-safe-eval/README.md). + +> **WARNING!** Just like Jinja2 and Django's templating, none of these are 100% bulletproof solutions! +> +> Because they work by blocking known unsafe scenarios. There can always be a new unknown scenario. +> +> If you expose a dangerous function to the template/expression, this can be potentially exploited. +> +> Safer approach would be to allow to call only those functions that have been explicitly tagged as safe. +> +> If you really need to render templates submitted from your users you should instead define the UI blocks yourself, and let your users pick and choose through JSON or similar: +> +> ```json +> { +> "template": "my_template", +> "user_id": 123, +> "blocks": [ +> {"type": "header", "title": "Hello!"}, +> {"type": "paragraph", "text": "This is my blog"}, +> {"type": "table", "data": [[1, 2, 3], [3, 4, 5]]}, +> ] +> } +> ``` + ### HTML transfomer Transform HTML in a single pass. This is a simple implementation. diff --git a/crates/djc-core/Cargo.toml b/crates/djc-core/Cargo.toml index c8ed537..2ec9675 100644 --- a/crates/djc-core/Cargo.toml +++ b/crates/djc-core/Cargo.toml @@ -2,7 +2,7 @@ name = "djc-core" description = "Singular Python API for Rust code used by django-components" version = "1.1.0" -edition = "2021" +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] @@ -11,5 +11,6 @@ crate-type = ["cdylib"] [dependencies] djc-html-transformer = { path = "../djc-html-transformer" } +djc-safe-eval = { path = "../djc-safe-eval" } pyo3 = { workspace = true } quick-xml = { workspace = true } diff --git a/crates/djc-core/src/lib.rs b/crates/djc-core/src/lib.rs index dcc7135..3df31e1 100644 --- a/crates/djc-core/src/lib.rs +++ b/crates/djc-core/src/lib.rs @@ -1,7 +1,8 @@ use djc_html_transformer::{ set_html_attributes as set_html_attributes_rust, HtmlTransformerConfig, }; -use pyo3::exceptions::{PyValueError}; +use djc_safe_eval::safe_eval as safe_eval_rust; +use pyo3::exceptions::{PySyntaxError, PyValueError}; use pyo3::prelude::*; use pyo3::types::{PyDict, PyTuple}; @@ -10,6 +11,10 @@ use pyo3::types::{PyDict, PyTuple}; fn djc_core(m: &Bound<'_, PyModule>) -> PyResult<()> { // HTML transformer m.add_function(wrap_pyfunction!(set_html_attributes, m)?)?; + + // Safe eval + m.add_function(wrap_pyfunction!(safe_eval, m)?)?; + Ok(()) } @@ -74,3 +79,31 @@ pub fn set_html_attributes( Err(e) => Err(PyValueError::new_err(e.to_string())), } } + +/// Transform a Python expression string to make it safe for evaluation. +/// +/// This function takes a Python expression string and transforms it into safe code +/// by wrapping potentially unsafe operations (like variable access, function calls, +/// attribute access, etc.) with sandboxed function calls. +/// +/// Args: +/// source (str): The Python expression string to transform. +/// +/// Returns: +/// str: The transformed Python expression as a string. +/// +/// Raises: +/// SyntaxError: If the input is not valid Python syntax or contains forbidden constructs. +/// +/// Example: +/// >>> safe_eval("my_var + 1") +/// 'variable("my_var") + 1' +/// +/// >>> safe_eval("lambda x: x + my_var") +/// 'lambda x: x + variable("my_var")' +#[pyfunction] +#[pyo3(signature = (source))] +fn safe_eval(source: &str) -> PyResult { + let result = safe_eval_rust(source).map_err(|e| PySyntaxError::new_err(e.to_string()))?; + Ok(result) +} diff --git a/crates/djc-safe-eval/Cargo.toml b/crates/djc-safe-eval/Cargo.toml new file mode 100644 index 0000000..689bbd3 --- /dev/null +++ b/crates/djc-safe-eval/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "djc-safe-eval" +version = "1.0.0" +description = "Safe Python expression eval" +# NOTE: To use this edition, run +# `rustup toolchain install nightly && rustup override set nightly` +edition = "2024" + +[dependencies] +ruff_python_parser = { workspace = true } +ruff_python_ast = { workspace = true } +ruff_python_codegen = { workspace = true } +ruff_source_file = { workspace = true } +ruff_text_size = { workspace = true } diff --git a/crates/djc-safe-eval/README.md b/crates/djc-safe-eval/README.md new file mode 100644 index 0000000..52a3d62 --- /dev/null +++ b/crates/djc-safe-eval/README.md @@ -0,0 +1,381 @@ +# DJC safe Python eval + +This is a re-implementation of Jinja2's sandboxed evaluation logic, built in Rust using the Ruff Python parser. + +It works by: + +1. Parsing the Python expression into an AST using `ruff_python_parser` +2. Validating the AST against a set of allowed nodes -> Unsupported syntax raises error. +3. Transforming specific nodes so we can intercept them: + - Variables → `variable("name")` + - Function calls → `call(func, *args, **kwargs)` + - Attributes → `attribute(obj, "attr")` + - Subscripts → `subscript(obj, key)` + - Walrus operator → `assign("var", value)` + - F-strings → `format("...")` calls with transformed arguments + - T-strings → `template(...)` calls with interpolation objects +4. Re-generating Python code from the modified AST using `ruff_python_codegen` +5. On the Python side we define `call()`, `variable()`, etc. + - This is the logic that runs when the target expression uses e.g. function calls. + Here we implement similar safety measures as Jinja. +6. User receives back a function to evaluate the compiled code. + +7. **Runtime**: The generated code is evaluated with sandboxed interceptors that enforce security policies + +This package is split in 2 parts: + +- The Rust code defined here +- Python code defined in [`djc_core/djc_safe_eval`](../../djc_core/djc_safe_eval/) + +The expression parsing is done in Rust so that this module can be used directly +by the djc template parser and linter. This module not only sandboxes the Python +expression, but also collects metadata for the linter about: + +1. What variables were used in the expression +2. What variables were introduced in the expression using walrus operator `x := 1` + +## Usage + +### Basic usage + +```python +from djc_core import safe_eval + +# Compile an expression +compiled = safe_eval("my_var + 1") + +# Evaluate with a context +result = compiled({"my_var": 5}) +print(result) # 6 +``` + +### More examples + +```python +from djc_core import safe_eval + +# Conditionals +compiled = safe_eval( + "'Login' if not user.authenticated else 'Logout'" +) +result = compiled({"user": anon_user}) +print(result) # "Login" + +# Comprehension +compiled = safe_eval( + "[x * 2 for x in items if x > 0]" +) +result = compiled({"items": [1, 2, -3, 4, 5]}) +print(result) # [2, 4, 8, 10] + +# Lambda +compiled = safe_eval( + "max(users, key=lambda u: u.last_login)" +) +result = compiled({ + "users": [User(), ...], + "max": max, +}) +print(result) # 2025-11-02T15:54:36Z +``` + +### Assignments + +You can use the walrus operator `x := val` to assign a value to the context object. Assigned variable is then accessible to the rest of the expression: + +```py +compiled = safe_eval("(y := x * 2)") +context = {"x": 4} +result = compiled(context) +print(result) # 8 +print(context) # {"x": 4, "y": 8} +``` + +Walrus operator can be used also inside comprehensions or lambdas: + +```py +# Comprehension +compiled = safe_eval("[(x := y) for y in [1, 2, 3]]") +context = {} +result = compiled(context) +print(context) # {"x": 3} + +# Lambda +compiled = safe_eval("fn_with_callback(on_done=lambda res: (data := res))") +context = {"fn_with_callback": fn_with_callback} +result = compiled(context) +print(context) # {"data": {...}, "fn_with_callback": fn_with_callback}, } +``` + +> **NOTE: This differs from regular Python, where walrus operator inside a function +> will NOT leak out.** + +If you try to assign a variable to the same value as an existing comprehension or +lambda arguments, you will get a SyntaxError: + +```py +safe_eval("[(y := y) for y in [1, 2, 3]]") # SyntaxError +safe_eval("lambda x: (x := 123))") # SyntaxError +``` + +### Unsafe operations + +Unsafe operations raise `SecurityError`. See all unsafe scenarios in [What is unsafe?](#what-is-unsafe) + +```python +# Unsafe functions +compiled = safe_eval("eval('1+1')") +result = compiled({"eval": eval}) +# SecurityError: unsafe builtin 'eval' +# +# 1 | eval('1+1') +# ^^^^^^^^^^^ + +# Private attributes +compiled = safe_eval("obj._private") +result = compiled({"obj": MyObject()}) +# SecurityError: unsafe attribute '_private' +# +# 1 | obj._private +# ^^^^^^^^^^^^ +``` + +### Mark functions as unsafe + +Use the `@unsafe` decorator to mark functions as unsafe in expressions. + +This is compatible with Jinja's `@unsafe` decorator. + +```py +from djc_core import safe_eval, unsafe + +@unsafe +def dump_all_passwords(): + return UserPasswords.objects.all() + +compiled = safe_eval("evil()") +result = compiled({"evil": dump_all_passwords}) +# SecurityError: unsafe function 'dump_all_passwords' +# +# 1 | evil() +# ^^^^^^ +``` + +### Custom validators + +`safe_eval` can accept extra validators. These are run **in addition to** the rules defined in [What is unsafe?](#what-is-unsafe) + +Return `False` to indicate that the value is NOT safe. + +| Function | Signature | +| -------------------- | ------------------------------------- | +| `validate_variable` | `(var_name: str) -> bool` | +| `validate_attribute` | `(obj: Any, attr: str) -> bool` | +| `validate_subscript` | `(obj: Any, key: str) -> bool` | +| `validate_callable` | `(obj: Any) -> bool` | +| `validate_assign` | `(var_name: str, value: Any) -> bool` | + +```python +from djc_core import safe_eval, SecurityError + +# Example 1: Custom variable validator +def validate_var(name: str) -> bool: + return not name.startswith("secret") + +compiled = safe_eval( + "public_var + secret_var", + validate_variable=validate_var, +) +result = compiled({ + "public_var": 1, + "secret_var": 42, +}) +# SecurityError: unsafe variable 'secret_var' +# +# 1 | public_var + secret_var +# ^^^^^^^^^^ + +# Example 2: Custom attribute validator +allowed = {"name", "value", "items"} +def validate_attr(obj: Any, attr: str) -> bool: + return attr in allowed + +compiled = safe_eval( + "f'Owner: {obj.owner}'", + validate_attribute=validate_attr +) +result = compiled({"obj": MyObject()}) +# SecurityError: unsafe attribute 'owner' +# +# 1 | f'Owner: {obj.owner}' +# ^^^^^^^^^ +``` + +### Error reporting + +When an expression raises an error, the error message includes the position in the expression where the error happened: + +```python +from djc_core import safe_eval + +compiled = safe_eval("my_var + undefined_var") +result = compiled({"my_var": 5}) +# NameError: name 'undefined_var' is not defined +# +# 1 | my_var + undefined_var +# ^^^^^^^^^^^^^ +``` + +### What is unsafe? + +Here's a list of all unsafe scenarios that will trigger `SecurityError`: + +- **Unsafe builtins**: `eval`, `exec`, `compile`, `open`, `input`, etc., even if passed under different names. +- **Private attributes**: Starting with `_` +- **Dunder attributes**: Internal Python attributes like `__class__`, `__dict__`, `mro`, etc. +- **Unsafe methods**: + - Functions decorated with `@unsafe` + - Django methods marked with `alters_data = True` + - `str.format` and `str.format_map` (use f-strings instead) +- **Internal attributes**: Prevents access to frame, code, and other internal Python object attributes + +## Syntax features + +_This section describes the features enforced on the compiler (Rust) level._ + +[Statements](https://docs.python.org/3/library/ast.html#statements) are NOT supported (AKA anything that spans multiple lines and uses identation, like `for`, `match`, `with`, etc). + +The entire python code must be a SINGLE [expression](https://docs.python.org/3/library/ast.html#expressions). As a rule of thumb, anything that can be assigned to a variable is an expression. So even, `a and b` or `c + d` are both still just a single expression. + +For simplicity we don't allow async features like async comprehensions. + +### Supported syntax + +Almost anything that is a valid Python expression is allowed: + +- **Literals**: strings, numbers (integers, floats, scientific notation), bytes, booleans, `None`, `Ellipsis` +- **Data structures**: lists, tuples, sets, dictionaries +- **String formatting**: f-strings (`f"Hello {name}"`), t-strings (template strings), `%` formatting +- **Operators**: + - Unary: `+`, `-`, `not`, `~` + - Binary: `+`, `-`, `*`, `/`, `%`, `**`, `//`, `<<`, `>>`, `&`, `^`, `|` + - Comparison: `<`, `<=`, `>`, `>=`, `==`, `!=`, `in`, `not in`, `is`, `is not` + - Boolean: `and`, `or` +- **Comprehensions**: list (`[...]`), set (`{...}`), dict (`{k: v ...}`), generator (`(...)`) + - Note: Async comprehensions are **not** allowed +- **Conditional expressions**: ternary operator (`x if condition else y`) +- **Variables**: `obj` with security checks +- **Function calls**: with positional, keyword, `*args`, and `**kwargs` arguments +- **Spread operators**: `*args`, `**kwargs` in function calls +- **Attribute access**: `obj.attr` with security checks +- **Subscript access**: `obj[key]` and slice notation `obj[start:end:step]` +- **Lambda functions**: anonymous functions with proper parameter scoping +- **Walrus operator**: `(x := value)` for inline assignments + +### Unsupported syntax + +- **Statements**: assignments (`=`), augmented assignments (`+=`, `-=`), `del`, `import`, class/function definitions, `return`, `yield`, etc. +- **Async/Await**: async comprehensions, `await` expressions +- **Control Flow**: `if`/`elif`/`else` statements, `for`, `while`, `break`, `continue`, `try`/`except`/`finally`, `with` +- **Builtins**: No built-in functions are available by default (pass them as variables if needed) +- **Type annotations:** `x: int` +- **Class and functions:** `def fn()` or `class Cls` +- **Function-only keywords:** return, yield, global, nonlocal + +### Variable scoping + +The transformer matches Python's scoping rules for comprehensions and lambdas, but diverges for walrus assignments: + +- **Comprehensions**: Variables introduced in comprehensions are local to the comprehension (e.g., `x` in `[x for x in items]`) +- **Lambda parameters**: Lambda parameters are local to the lambda and not transformed +- **Walrus operator**: Walrus assignments remain available outside of comprehensions or lambdas.(diverges from Python) + +## Performance + +Python expressions with `safe_eval` are 5-8x slower than if the expression was called outside of the template: + +```py +fn = safe_eval("a + b * c") +fn({"a": 1, "b": 2, "c": 3}) + +# vs + +fn = lambda ctx: ctx["a"] + ctx["b"] * ctx["c"] +fn({"a": 1, "b": 2, "c": 3}) +``` + +This is the tradeoff for all the security checks that we do, as we have to check safety of each attribute or variable access, or function call. + +I tried to see what would happen if I cached the results, and got about 30-50% improvement. LLM estimated that at 10,000 entries, the cache could take up ~3-5 MB. This would be relevant only to large projects, say with 500 templates, each having total of 20 tags or expressions (`{% ...%}`, `{{ }}`). + +- For comparison, my last work project had about ~100 templates, and that was a mid-sized app that I worked on for ~1.5 years. + +However, I removed this caching from this final PR. In django-components I think that it will be more meaningful to cache on the level of entire tags and expressions (`{% ...%}`, `{{ }}`), which will make the caching in `safe_eval` less relevant. + +Once the Python expressions are fully integrated in django-components, and we find that these Python expressions take up non-neglibible time, we could introduce the caching. + +## Development + +### Dependencies + +This crate depends on several internal crates from the `ruff` project, included as a git submodule: + +- [`ruff_python_parser`](https://github.com/astral-sh/ruff/crates/ruff_python_parser) - Python parser +- [`ruff_python_ast`](https://github.com/astral-sh/ruff/crates/ruff_python_ast) - AST types +- [`ruff_python_codegen`](https://github.com/astral-sh/ruff/crates/ruff_python_codegen) - Code generation +- [`ruff_source_file`](https://github.com/astral-sh/ruff/crates/ruff_source_file) - Source file handling +- [`ruff_text_size`](https://github.com/astral-sh/ruff/crates/ruff_text_size) - Text size utilities + +These crates are essential for parsing Python code into an AST and manipulating it. + +However, there's an issue with using these as upstream dependencies: + +1. These crates are not available on [crates.io](https://crates.io/). Their `Cargo.toml` files are marked with `publish = false`. + +2. `cargo` allows to specify dependencies as git links ([see docs](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#specifying-dependencies-from-git-repositories)). However, I (Juro) wasn't able to get it working. + + - It seems that `cargo` may be ignoring nested crates if there is `Cargo.toml` at the root. And `ruff`'s codebase does have a root `Cargo.toml`. So we're unable to target the internal crates like `ruff_python_ast`. + +So as a workaround solution, we use a **Git submodule** to have access to the `ruff` source code directly in our project. + +The `ruff` repository is included as a submodule in `crates/djc-safe-eval/submodules/ruff`. Our `Cargo.toml` uses `path` dependencies to refer to the crates within this submodule. + +### Initial setup + +When you first clone this repository, the submodule directory will be empty. You must initialize it: + +```bash +git submodule update --init --recursive +``` + +### Updating the Ruff dependency + +The version of `ruff` is locked to a specific commit or tag, documented in `.gitmodules`. To update: + +1. Navigate into the submodule directory: + + ```bash + cd crates/djc-safe-eval/submodules/ruff + ``` + +2. Fetch the latest tags: + + ```bash + git fetch origin --tags + ``` + +3. Check out the new tag (e.g., `0.15.0`): + + ```bash + git checkout 0.15.0 + ``` + +4. Navigate back and commit the change: + + ```bash + cd ../../.. + git add .gitmodules crates/djc-safe-eval/submodules/ruff + git commit -m "Update Ruff submodule to 0.15.0" + ``` + +5. To keep track of the current version, update the comment in the `.gitmodules` file. diff --git a/crates/djc-safe-eval/src/codegen.rs b/crates/djc-safe-eval/src/codegen.rs new file mode 100644 index 0000000..08375cc --- /dev/null +++ b/crates/djc-safe-eval/src/codegen.rs @@ -0,0 +1,61 @@ +use ruff_python_ast as ast; +use ruff_python_codegen as codegen; +use ruff_source_file::LineEnding; + +/// Generate Python code from a transformed AST expression +pub fn generate_python_code(expr: &ast::Expr) -> String { + let indentation = codegen::Indentation::default(); + let generator = codegen::Generator::new(&indentation, LineEnding::default()); + generator.expr(expr) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transformer::transform_expression_string; + + fn _test_transformation(input: &str, expected: &str) { + let result = transform_expression_string(input); + assert!(result.is_ok()); + let transform_result = result.unwrap(); + let generated = generate_python_code(&transform_result.expression); + assert_eq!(generated, expected); + } + + fn _test_forbidden_syntax(input: &str) { + let result = transform_expression_string(input); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.contains("Parse error") || error.contains("Unexpected token")); + } + + #[test] + fn test_generate_simple_literal() { + _test_transformation("42", "42"); + } + + #[test] + fn test_generate_binary_operation() { + _test_transformation("1 + 2", "1 + 2"); + } + + #[test] + fn test_generate_unary_operation() { + _test_transformation("-42", "-42"); + } + + #[test] + fn test_generate_comparison() { + _test_transformation("1 == 2", "1 == 2"); + } + + #[test] + fn test_generate_list() { + _test_transformation("[1, 2, 3]", "[1, 2, 3]"); + } + + #[test] + fn test_generate_dict() { + _test_transformation("{'a': 1, 'b': 2}", "{'a': 1, 'b': 2}"); + } +} diff --git a/crates/djc-safe-eval/src/lib.rs b/crates/djc-safe-eval/src/lib.rs new file mode 100644 index 0000000..5877680 --- /dev/null +++ b/crates/djc-safe-eval/src/lib.rs @@ -0,0 +1,15 @@ +pub mod codegen; +pub mod transformer; +mod utils { + pub mod python_ast; +} + +// Re-export public API +pub use codegen::generate_python_code; +pub use transformer::{Token, TransformResult, transform_expression_string}; + +pub fn safe_eval(source: &str) -> Result { + let result = transform_expression_string(source)?; + let generated_code = codegen::generate_python_code(&result.expression); + Ok(generated_code) +} diff --git a/crates/djc-safe-eval/src/transformer.rs b/crates/djc-safe-eval/src/transformer.rs new file mode 100644 index 0000000..497113a --- /dev/null +++ b/crates/djc-safe-eval/src/transformer.rs @@ -0,0 +1,1343 @@ +//! # Safe Python eval transformer +//! +//! This module provides a logic that takes Python code, and transforms potentially +//! unsafe Python expressions (e.g. function calls) into safe code that can be evaluated. +//! +//! ## Overview +//! +//! The transformer: +//! +//! 1. Parses Python expressions using `ruff_python_parser` +//! 2. Validates them against a whitelist of allowed AST nodes +//! 3. Transforms specific nodes to enable sandboxing +//! 4. Unparses them back to Python code using `ruff_python_codegen` +//! +//! ## Transformations +//! +//! The following transformations are applied to make expressions safe for evaluation: +//! +//! 1. **Variable access** - `my_var` → `variable(context, source, token, "my_var")` where `token` is `(start_index, end_index)` +//! 2. **Function calls** - `foo(1, 2, a=3, *args, **kwargs)` → `call(context, source, token, foo, 1, 2, a=x, *args, **kwargs)` +//! 3. **Attribute access** - `obj.attr` → `attribute(context, source, token, obj, "attr")` +//! 4. **Subscript access** - `obj[key]` → `subscript(context, source, token, obj, key)` +//! 5. **Walrus operator** - `(x := value)` → `assign(context, source, token, "x", value)` where `token` contains the entire expression range +//! +//! Because of the changes above, we also need to transform: +//! +//! 6. **Slice notation** - `obj[1:10:2]` → `subscript(context, source, token, obj, slice(context, source, token, 1, 10, 2))` - Because slice syntax is valid only inside square brackets. +//! 7. **F-strings** - `f"Hello {price!r:.2f}"` → `format(context, source, token, "Hello {}", (variable(context, source, token, "price"), "r", ".2f"))` - To avoid issues with quote escaping and enable error reporting +//! 8. **T-strings** - `t"Hello {name!r:>10}"` → `template(context, source, token, "Hello ", interpolation(context, source, token, variable(context, source, token, "name"), "expr", "r", ">10"))` - To avoid issues with quote escaping +//! +//! ## Variable Scoping +//! +//! Variables are tracked to handle different scoping rules: +//! +//! ### Comprehensions +//! Variables introduced in comprehensions are local to the comprehension and NOT transformed: +//! - `[x for x in items]` → `[x for x in variable(context, source, token, "items")]` +//! - The variable `x` is NOT transformed because it's local to the comprehension +//! +//! Walrus assignments in comprehensions ARE transformed and DO leak out (matching Python behavior): +//! - `[y for x in items if (y := x + 1)]` → `[assign(context, source, token, "y", x + 1) for x in variable(context, source, token, "items")]` +//! - the variable `y` is accessible after the comprehension +//! +//! If you define walrus variable with the same name as comprehension variables, +//! you will get a `SyntaxError`: +//! - `[(n := n + 1) + n for n in items]` - SyntaxError: cannot rebind comprehension iteration variable 'n' +//! +//! ### Lambda Functions +//! Lambda parameters are local to the lambda and NOT transformed: +//! - `lambda x: x + 1` - the parameter `x` is NOT transformed +//! +//! **Walrus assignments in lambdas ARE transformed and DO leak out to the context** (diverges from Python): +//! - `(lambda: (x := 3))()` - the variable `x` IS accessible after the lambda executes +//! - This is diffrent to normal Python code, but it's intentional, so that +//! we can assign a variable to the context from within a callback function, +//! e.g.: `fn_with_callback(on_done=lambda res: (data := res))` +//! +//! If you define walrus variable with the same name as one of the lambda parameters, +//! you will get a `SyntaxError`: +//! - `(lambda x: (x := 3) and x**2)` - SyntaxError: cannot rebind lambda parameter 'x' +//! +//! ## Allowed Python features +//! +//! - **Literals**: strings, numbers, bytes, booleans, None, Ellipsis +//! - **String formatting**: f-strings `f"Hello {name}"`, t-strings, `%` formatting +//! - **Data structures**: lists, tuples, sets, dicts +//! - **Operators**: unary (`+`, `-`, `not`, `~`), binary (`+`, `-`, `*`, `/`, `%`, `**`, `//`), comparison, boolean +//! - **Comprehensions**: list, set, dict, generator (but NOT async comprehensions) +//! - **Conditionals**: ternary operator (`x if y else z`) +//! - **Variables**: identifiers +//! - **Function calls**: all calls +//! - **Spread operators**: `*args`, `**kwargs` +//! - **Attribute access**: `obj.attr` +//! - **Subscript access**: `obj[key]`, including slices `obj[start:end:step]` +//! - **Lambda expressions**: `lambda x: x + 1`` +//! +//! ## Disallowed Python Features +//! +//! The following are explicitly forbidden for security: +//! - **Statements**: assignments, del, import, class/function definitions, etc. +//! - **Async/await**: async comprehensions, await expressions +//! - **Generators**: yield, yield from +//! +//! ## Variable Tracking +//! +//! The transformer tracks two types of variables with their positions: +//! +//! ### Used Variables +//! Variables that are accessed from the outside context (not local to comprehensions/lambdas). +//! These are variables that need to be provided in the evaluation context. +//! Example: In `lambda c: a + 1 + d`, the variables `a` and `d` are used from context (not `c`, which is a lambda parameter). +//! +//! ### Assigned Variables +//! Variables assigned via the walrus operator (`:=`) that become available outside their scope. +//! These are tracked from both comprehensions and lambdas (unlike Python, where lambdas don't leak). +//! Example: In `[y for x in items if (y := x + 1)]`, the variable `y` is assigned and available after the comprehension. +//! Example: In `(lambda: (data := res))()`, the variable `data` is assigned and available after the lambda executes. +//! +//! Both types are returned as `Token` instances with position information (byte offsets and line/column numbers). +//! +//! ## Error Handling +//! +//! The transformer returns `Result` where errors include: +//! - Parse errors from invalid Python syntax +//! - Validation errors when forbidden AST nodes are encountered +//! - SyntaxError when walrus assignments conflict with comprehension iteration variables or lambda parameters +//! + +// Python AST types +// As based on Python docs 3.14 (15/10/2025) +// https://docs.python.org/3/library/ast.html#root-nodes +// +// Associated with them are the corresponding Rust types in ruff_python_ast::* +// +// ------------------- +// LEGEND: +// - ✅ ALLOWED +// - ❌ DISALLOWED +// - ⚠️ CAREFUL / NEEDS INTERCEPTION +// - 🔵 IMPLEMENTED +// ------------------- +// +// Root nodes: +// - ❌ Module - ModModule +// - ✅ Expression - ModExpression +// - ❌ Interactive - X (No Rust type?) +// - ❌ FunctionType - X (No Rust type?) +// +// Literals: +// - 🔵✅ Constant (str) - ExprStringLiteral +// - 🔵✅ Constant (bytes) - ExprBytesLiteral +// - 🔵✅ Constant (int) - ExprNumberLiteral (int, float, complex?) +// - 🔵✅ Constant (bool) - ExprBooleanLiteral +// - 🔵✅ Constant (None) - ExprNoneLiteral +// - 🔵✅ Constant (...) - ExprEllipsisLiteral +// - 🔵✅ JoinedStr - FString (f"{a} {b}") +// - 🔵✅ - ExprFString +// - 🔵✅ TemplateStr - TString (t"{a} {b}") +// - 🔵✅ - ExprTString +// - 🔵✅ Interpolation - InterpolatedElement +// - 🔵✅ - InterpolatedStringLiteralElement +// - 🔵✅ - InterpolatedStringFormatSpec +// - 🔵✅ List - ExprList +// - 🔵✅ Tuple - ExprTuple +// - 🔵✅ Set - ExprSet +// - 🔵✅ Dict - ExprDict +// +// Variables: +// - 🔵⚠️ Name - ExprName (variable name) - Wrap in `variable(context, source, token, "name")` +// - 🔵✅ Load - ExprContext::Load (x) +// - 🔵❌ Store - ExprContext::Store (x = 1) +// - 🔵❌ Del - ExprContext::Del (del x) +// - 🔵✅ Starred - ExprStarred (star spread, e.g. `fn(*args, **kwargs)`) +// +// Expressions: +// - 🔵✅ UnaryOp - ExprUnaryOp (not, invert, + (pos sign), - (neg sign)) +// - 🔵✅ BinOp - ExprBinOp (add, sub, mul, div, mod, pow, lshift, rshift, bitand, bitxor, bitor) +// - 🔵✅ BoolOp - ExprBoolOp (and, or) +// - 🔵✅ Compare - ExprCompare (<, <=, >, >=, ==, !=, in, not in, is, is not) +// - 🔵⚠️ Call - ExprCall (function call) - Intercept to prevent calling private / dunder methods +// - 🔵✅ Keyword - Keyword (keyword argument when calling a function) +// - 🔵✅ IfExp - ExprIf (ternary operator, e.g. `x if y else z`) +// - 🔵⚠️ Attribute - ExprAttribute (attribute access, e.g. `x.y`) - Intercept to prevent accessing private / dunder attributes +// - 🔵⚠️ NamedExpr - ExprNamed (walrus operator, e.g. `x := y`; assigns to context) - Intercepted to set the value to the context +// - 🔵⚠️ Subscript - ExprSubscript (subscript access, e.g. `x[y]`) - Intercept to prevent accessing private / dunder attributes +// - 🔵⚠️ Slice - ExprSlice (e.g. numeric part in `x[1:2]`, same as `slice(1, 2)`) - Transformed to slice(1, 2) calls +// - 🔵⚠️ GeneratorExp - ExprGenerator (e.g. `(x for x in range(10))`) - Disallow async +// - 🔵⚠️ ListComp - ExprListComp (e.g. `[x for x in range(10)]`) - Disallow async +// - 🔵⚠️ SetComp - ExprSetComp (e.g. `{x for x in range(10)}`) - Disallow async +// - 🔵⚠️ DictComp - ExprDictComp (e.g. `{x: x for x in range(10)}`) - Disallow async +// - 🔵⚠️ comprehension - Comprehension (single `for` in comprehension) - Disallow async +// +// Statements: +// - 🔵❌ Assign - StmtAssign +// - 🔵❌ AnnAssign - StmtAnnAssign +// - 🔵❌ AugAssign - StmtAugAssign +// - 🔵❌ Raise - StmtRaise +// - 🔵❌ Assert - StmtAssert +// - 🔵❌ Delete - StmtDelete +// - 🔵❌ Pass - StmtPass +// - 🔵❌ TypeAlias - StmtTypeAlias +// - ❌ - StmtExpr (expr wrapper in Ruff Python AST. We should never see this) +// +// Statements (imports): +// - 🔵❌ Import - StmtImport +// - 🔵❌ ImportFrom - StmtImportFrom +// - 🔵❌ Alias - Alias +// +// Statements (control flow): +// - 🔵❌ If - StmtIf +// - 🔵❌ - ElifElseClause +// - 🔵❌ For - StmtFor +// - 🔵❌ While - StmtWhile +// - 🔵❌ Break - StmtBreak +// - 🔵❌ Continue - StmtContinue +// - 🔵❌ Try - StmtTry +// - 🔵❌ TryStar - StmtTry (with is_star flag) +// - 🔵❌ ExceptHandler - ExceptHandlerExceptHandler +// - 🔵❌ With - StmtWith +// - 🔵❌ withitem - WithItem +// +// Statements (pattern matching): +// - 🔵❌ Match - StmtMatch +// - 🔵❌ match_case - MatchCase +// - 🔵❌ MatchValue - PatternMatchValue +// - 🔵❌ MatchSingleton - PatternMatchSingleton +// - 🔵❌ MatchSequence - PatternMatchSequence +// - 🔵❌ MatchStar - PatternMatchStar +// - 🔵❌ MatchMapping - PatternMatchMapping +// - 🔵❌ MatchClass - PatternMatchClass +// - 🔵❌ - PatternArguments (arg position in class match) +// - 🔵❌ - PatternKeyword (kwarg position in class match) +// - 🔵❌ MatchAs - PatternMatchAs +// - 🔵❌ MatchOr - PatternMatchOr +// +// Type annotations: +// - ❌ TypeIgnore - X (No Rust type?) +// +// Type parameters (Python 3.12+): +// - 🔵❌ TypeVar - TypeParamTypeVar (e.g. `type T = int` or `[T]` in generic function) +// - 🔵❌ ParamSpec - TypeParamParamSpec (e.g. `[**P]` in generic function) +// - 🔵❌ TypeVarTuple - TypeParamTypeVarTuple (e.g. `[*Ts]` in generic function) +// - 🔵❌ - TypeParams (the `[T]` syntax in `def func[T](x: T) -> T:`) +// +// Function and class definitions: +// - 🔵❌ FunctionDef - StmtFunctionDef +// - 🔵✅ Lambda - ExprLambda +// - 🔵✅ - Arguments +// - 🔵✅ - Parameters +// - 🔵✅ - Parameter +// - 🔵✅ - ParameterWithDefault +// - 🔵❌ Return - StmtReturn +// - 🔵❌ Yield - ExprYield +// - 🔵❌ YieldFrom - ExprYieldFrom +// - 🔵❌ Global - StmtGlobal +// - 🔵❌ NonLocal - StmtNonlocal +// - 🔵❌ ClassDef - StmtClassDef +// +// Async and await: +// - 🔵❌ AsyncFunctionDef - X (No Rust type?) +// - 🔵❌ Await - ExprAwait +// - 🔵❌ AsyncFor - X (No Rust type?) +// - 🔵❌ AsyncWith - X (No Rust type?) +// - 🔵❌ AsyncComprehension - X (No Rust type?) +// +// Other: +// - 🔵✅ - Identifier (name of a variable, function, class, etc.) +// - 🔵❌ - Decorator +// - 🔵⚠️ - Comments - Ignored/removed from the expression +// - ❌ - StmtIpyEscapeCommand +// - ❌ - ExprIpyEscapeCommand + +use crate::utils::python_ast::{ + attribute, call, get_expr_range, interceptor_call, none_literal, string_literal, +}; +use ruff_python_ast::visitor::transformer::Transformer; +use ruff_python_ast::{self as ast, Expr, Stmt}; +use ruff_python_parser::parse_expression; +use ruff_source_file::LineIndex; +use std::cell::RefCell; +use std::collections::HashSet; + +/// Metadata of a matched token with its position information +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Token { + /// String content of the token + pub content: String, + /// Start index in the original input string + pub start_index: usize, + /// End index in the original input string + pub end_index: usize, + /// Line and column number (1-indexed) + pub line_col: (usize, usize), +} + +/// Result of transforming an expression string +#[derive(Debug, Clone)] +pub struct TransformResult { + /// The transformed expression + pub expression: Expr, + /// Tokens for variables that are used from the outside context + pub used_vars: Vec, + /// Tokens for variables that are assigned via walrus operator (:=) + pub assigned_vars: Vec, +} + +/// The main entry point for transforming an expression string. +/// Returns the transformed expression along with tokens for variables used and assigned. +pub fn transform_expression_string(source: &str) -> Result { + let transformer = SandboxTransformer::new(); + let ast = parse_expression(source).map_err(|e| format!("Parse error: {}", e))?; + + // Create a LineIndex to convert byte offsets to line/column positions + let line_index = LineIndex::from_source_text(source); + + // The top-level AST for an expression is an `ast::Mod::Expression`. + // We want to transform the single expression inside it. + // ast.syntax() returns a &ModExpression for parse_expression + let module = ast.syntax(); + // It should have a single expression inside it + let mut expr = *module.body.clone(); + transformer.visit_expr(&mut expr); + + // Check if any validation errors occurred during transformation + if let Some(error) = transformer.get_error() { + return Err(error); + } + + // Get the used variables and convert to Vec of Tokens + // Also convert byte offsets to line/column positions + let mut used_vars: Vec = transformer + .get_used_variables() + .into_iter() + .map(|(name, start, end)| { + // Convert byte offset to line/column (only for start position) + let start_pos = + line_index.line_column(ruff_text_size::TextSize::from(start as u32), source); + + // Extract the variable name from the source text for token content + let content = if start < end && end <= source.len() { + source[start..end].to_string() + } else { + name.clone() + }; + + Token { + content, + start_index: start, + end_index: end, + line_col: ( + start_pos.line.to_zero_indexed() + 1, // Convert to 1-indexed + start_pos.column.to_zero_indexed() + 1, // Convert to 1-indexed + ), + } + }) + .collect(); + // Sort by start_index for consistent output + used_vars.sort_by(|a, b| a.start_index.cmp(&b.start_index)); + + // Get the assigned variables (from walrus operator) and convert to Vec of Tokens + let mut assigned_vars: Vec = transformer + .get_assignments() + .into_iter() + .map(|(name, start, end)| { + // Convert byte offset to line/column (only for start position) + let start_pos = + line_index.line_column(ruff_text_size::TextSize::from(start as u32), source); + + // Extract the variable name from the source text for token content + let content = if start < end && end <= source.len() { + source[start..end].to_string() + } else { + name.clone() + }; + + Token { + content, + start_index: start, + end_index: end, + line_col: ( + start_pos.line.to_zero_indexed() + 1, // Convert to 1-indexed + start_pos.column.to_zero_indexed() + 1, // Convert to 1-indexed + ), + } + }) + .collect(); + // Sort by start_index for consistent output + assigned_vars.sort_by(|a, b| a.start_index.cmp(&b.start_index)); + + Ok(TransformResult { + expression: expr, + used_vars, + assigned_vars, + }) +} + +/// Our custom AST transformer that validates and transforms Python expressions +/// to make them safe for evaluation in a sandboxed environment. +pub struct SandboxTransformer { + // Track variables introduced by comprehensions that should NOT be transformed to `variable("name", context)` calls + // Using RefCell so we can modify it during traversal + comprehension_variables: RefCell>, + // Track variables introduced by lambda parameters that should NOT be transformed to `variable("name", context)` calls + // Using RefCell so we can modify it during traversal + lambda_variables: RefCell>, + // Track validation errors instead of panicking + // NOTE: Inside transformer methods (visit_xxx) we have only read access to SandboxTransformer + // hence why we use RefCell to borrow the value from the outside. + validation_error: RefCell>, + // Track variables assigned via walrus operator (:=) in this scope. + // These need to be propagated up to parent scopes so they remain accessible. + // This is so that we replicate how Python behaves, where e.g. a walrus op + // inside a comprehension remains available even outside the comprehension. + // ```py + // items = [1, 2, 3] + // [y for x in items if (y := x + 1)] + // print(y) # 4 + // ``` + // We use a Vec to store all occurrences with their positions. + // Each tuple is (variable_name, start_index, end_index). + assignments: RefCell>, + // Track variables that are needed from the outside context (not local). + // These are variables that need to be accessed via variable(context, source, token, "name"). + // When we transform `var_name` to `variable(context, source, token, "var_name")`, we record + // that `var_name` is needed. This helps determine what variables the expression + // requires from the context in which it's evaluated. + // We use a Vec to store all occurrences with their positions. + // Each tuple is (variable_name, start_index, end_index). + used_variables: RefCell>, +} + +impl SandboxTransformer { + pub fn new() -> Self { + Self { + comprehension_variables: RefCell::new(HashSet::new()), + lambda_variables: RefCell::new(HashSet::new()), + validation_error: RefCell::new(None), + assignments: RefCell::new(Vec::new()), + used_variables: RefCell::new(Vec::new()), + } + } + + /// Create a new SandboxTransformer with additional local variables + /// We have to create a new copy because inside visit_xxx methods we have only read access. + /// + /// The child transformer: + /// - Inherits parent's comprehension_variables + additional_comprehension_vars + /// - Inherits parent's lambda_variables + additional_lambda_vars + /// - Starts with empty assignments (to track only new assignments made in child scope) + fn with_locals( + &self, + additional_comprehension_vars: HashSet, + additional_lambda_vars: HashSet, + ) -> Self { + let mut new_comp_vars = self.comprehension_variables.borrow().clone(); + new_comp_vars.extend(additional_comprehension_vars); + let mut new_lambda_vars = self.lambda_variables.borrow().clone(); + new_lambda_vars.extend(additional_lambda_vars); + + Self { + comprehension_variables: RefCell::new(new_comp_vars), + lambda_variables: RefCell::new(new_lambda_vars), + validation_error: RefCell::new(self.validation_error.borrow().clone()), + assignments: RefCell::new(Vec::new()), + used_variables: RefCell::new(Vec::new()), + } + } + + /// Set a validation error + fn set_error(&self, error: String) { + *self.validation_error.borrow_mut() = Some(error); + } + + /// Get the validation error + fn get_error(&self) -> Option { + self.validation_error.borrow().clone() + } + + /// Check if there are any errors + fn has_error(&self) -> bool { + self.validation_error.borrow().is_some() + } + + /// Record a new variable assignment from walrus operator. + /// These will remain available within he current function scope. + /// Thus, if we are in a comprehension, the assignment will remain available even after leaving the scope. + /// ```py + /// items = [1, 2, 3] + /// [y for x in items if (y := x + 1)] + /// print(y) # 4 + /// ``` + /// Records all occurrences of each variable name. + fn add_assignment(&self, var_name: String, start_index: usize, end_index: usize) { + self.assignments + .borrow_mut() + .push((var_name, start_index, end_index)); + } + + /// Get all assignments made in this function scope with their positions + fn get_assignments(&self) -> Vec<(String, usize, usize)> { + self.assignments.borrow().clone() + } + + /// Record a variable that is needed from the outside context. + /// This is called when we transform a variable access to variable(context, source, token, "name"). + /// Records all occurrences of each variable name. + fn add_used_variable(&self, var_name: String, start_index: usize, end_index: usize) { + self.used_variables + .borrow_mut() + .push((var_name, start_index, end_index)); + } + + /// Get all variables that are needed from the outside context with their positions + fn get_used_variables(&self) -> Vec<(String, usize, usize)> { + self.used_variables.borrow().clone() + } + + /// Propagate assignments and errors from a child transformer back to this one + fn propagate_from_child(&self, child: &SandboxTransformer) { + // Always propagate assignments up (so walrus variables remain accessible after leaving the scope) + // Now that we always transform walrus to assign(), assignments always propagate + self.assignments + .borrow_mut() + .extend(child.get_assignments()); + + // Always propagate used_variables up (variables needed from context) + self.used_variables + .borrow_mut() + .extend(child.get_used_variables()); + + // Always propagate errors up + if child.has_error() { + self.set_error(child.get_error().unwrap()); + } + } + + /// Check if a variable name is local (comprehension or lambda variable) + fn is_local_variable(&self, name: &str) -> bool { + // NOTE: Previously this contained also walrus assignments `(x := 2)`. + // because the variable should be available after assignment, e.g. `(x := 2) and x > 1`. + // HOWEVER, we actually replace the walrus operator with call to `assign(...)`, + // and thus the variable is not assigned in THIS scope. + // So we still need to replace later references with `variable("name", context)` calls. + self.comprehension_variables.borrow().contains(name) + || self.lambda_variables.borrow().contains(name) + } + + /// Visit an expression with additional local variables + fn visit_expr_with_locals( + &self, + expr: &mut Expr, + new_comprehension_vars: HashSet, + new_lambda_vars: HashSet, + ) { + let child_transformer = self.with_locals(new_comprehension_vars, new_lambda_vars); + child_transformer.visit_expr(expr); + self.propagate_from_child(&child_transformer); + } + + /// Visit a comprehension with additional local variables from the generator targets + fn visit_comprehension_with_locals( + &self, + comprehension: &mut ast::Comprehension, + new_comprehension_vars: HashSet, + ) { + let child_transformer = self.with_locals(new_comprehension_vars, HashSet::new()); + child_transformer.visit_comprehension(comprehension); + // Comprehensions propagate walrus assignments (they leak out in Python) + self.propagate_from_child(&child_transformer); + } + + /// Extract variable names from a target expression (for comprehensions) + fn extract_target_variables(target: &Expr) -> HashSet { + let mut vars = HashSet::new(); + match target { + // E.g. `x` in `[x+1 for x in range(10)]` + Expr::Name(name) => { + vars.insert(name.id.as_str().to_string()); + } + // E.g. `(x, y)` in `[x+1 for x, y in range(10)]` + Expr::Tuple(tuple) => { + for elt in &tuple.elts { + vars.extend(Self::extract_target_variables(elt)); + } + } + _ => { + panic!( + "Validation Error: Unsupported target type in comprehension: {:?}", + target + ); + } + } + vars + } + + /// Helper function to handle comprehension logic common to ListComp, SetComp, DictComp, and Generator + /// This function: + /// 1. Checks for async generators + /// 2. Extracts comprehension variables from generator targets + /// 3. Visits generators + /// 4. Calls the provided closure to transform the element(s) + fn handle_comprehension(&self, generators: &mut [ast::Comprehension], transform_elements: F) + where + F: FnOnce(&Self, HashSet), + { + // Check if any generator is async + for generator in generators.iter() { + if generator.is_async { + self.set_error( + "Validation Error: Async comprehensions are not allowed for security reasons" + .to_string(), + ); + return; + } + } + + // Extract all comprehension variables from all generators + let mut comprehension_vars = HashSet::new(); + for generator in generators.iter() { + comprehension_vars.extend(Self::extract_target_variables(&generator.target)); + } + + // IMPORTANT: Visit generators FIRST (including if conditions where walrus might occur) + // This way, any walrus assignments in the if conditions will be tracked and propagated + // before we visit the element + for generator in generators.iter_mut() { + self.visit_comprehension_with_locals(generator, comprehension_vars.clone()); + } + + // Transform the element(s) with comprehension variables + // This includes the variables introduced by generators (like 'x' in [x+1 for x in items]) + // Comprehensions propagate walrus assignments (they leak out in Python) + transform_elements(self, comprehension_vars); + } +} + +impl Transformer for SandboxTransformer { + /// Override the default visit_expr to implement our validation and transformation logic + fn visit_expr(&self, expr: &mut Expr) { + match expr { + // ✅ ALLOWED - Literals + Expr::StringLiteral(_) + | Expr::BytesLiteral(_) + | Expr::NumberLiteral(_) + | Expr::BooleanLiteral(_) + | Expr::NoneLiteral(_) + | Expr::EllipsisLiteral(_) => { + // These are safe, no transformation needed + } + + // ✅ ALLOWED - Data structures + Expr::List(list) => { + // Transform all elements + for element in list.elts.iter_mut() { + self.visit_expr(element); + } + } + Expr::Tuple(tuple) => { + // Transform all elements + for element in tuple.elts.iter_mut() { + self.visit_expr(element); + } + } + Expr::Dict(dict) => { + // Transform all keys and values + for item in dict.items.iter_mut() { + if let Some(key) = &mut item.key { + self.visit_expr(key); + } + self.visit_expr(&mut item.value); + } + } + Expr::Set(set) => { + // Transform all elements + for element in set.elts.iter_mut() { + self.visit_expr(element); + } + } + + // ✅ ALLOWED - Basic expressions + Expr::UnaryOp(unary_op) => { + // Transform the operand + self.visit_expr(&mut unary_op.operand); + } + Expr::BinOp(bin_op) => { + // Transform the left and right operands + self.visit_expr(&mut bin_op.left); + self.visit_expr(&mut bin_op.right); + } + Expr::BoolOp(bool_op) => { + // Transform all values in the boolean operation + for value in bool_op.values.iter_mut() { + self.visit_expr(value); + } + } + Expr::Compare(compare) => { + // Transform the left side and all comparators + self.visit_expr(&mut compare.left); + for comparator in compare.comparators.iter_mut() { + self.visit_expr(comparator); + } + } + // ✅ Ternary if expression: `x if condition else y` + Expr::If(if_expr) => { + // Transform all three parts: test (condition), body (true branch), orelse (false branch) + self.visit_expr(&mut if_expr.test); + self.visit_expr(&mut if_expr.body); + self.visit_expr(&mut if_expr.orelse); + } + + // ✅ ALLOWED - Comprehensions (but check for async) + Expr::ListComp(list_comp) => { + self.handle_comprehension(&mut list_comp.generators, |transformer, comp_vars| { + // Transform element with comprehension variables + // Comprehensions propagate walrus assignments + transformer.visit_expr_with_locals( + &mut list_comp.elt, + comp_vars, + HashSet::new(), + ) + }); + } + Expr::SetComp(set_comp) => { + self.handle_comprehension(&mut set_comp.generators, |transformer, comp_vars| { + // Transform element with comprehension variables + // Comprehensions propagate walrus assignments + transformer.visit_expr_with_locals( + &mut set_comp.elt, + comp_vars, + HashSet::new(), + ); + }); + } + Expr::DictComp(dict_comp) => { + self.handle_comprehension(&mut dict_comp.generators, |transformer, comp_vars| { + // Transform key and value with comprehension variables + // Comprehensions propagate walrus assignments + transformer.visit_expr_with_locals( + &mut dict_comp.key, + comp_vars.clone(), + HashSet::new(), + ); + transformer.visit_expr_with_locals( + &mut dict_comp.value, + comp_vars, + HashSet::new(), + ); + }); + } + Expr::Generator(generator) => { + self.handle_comprehension(&mut generator.generators, |transformer, comp_vars| { + // Transform element with comprehension variables + // Comprehensions propagate walrus assignments + transformer.visit_expr_with_locals( + &mut generator.elt, + comp_vars, + HashSet::new(), + ); + }); + } + + // ⚠️ Variable names transform from `my_var` to `variable(context, source, token, "my_var")` + Expr::Name(name) => { + // Check if this is a locally introduced variable (from comprehensions, lambdas, etc.) + // or if it was assigned via walrus operator + let var_name = name.id.as_str(); + if self.is_local_variable(var_name) { + // This is a local variable, don't transform it to variable(context, source, token, "name") + // Just allow it as-is + } else { + // Transform `var_name` to `variable(context, source, token, "var_name")`. + // That way, when evaluating the modified expression, we can plug in + // the definition of `variable()` and safely handle the variable access. + // The token tuple contains (start_index, end_index) for error reporting. + // We need to replace the current Expr::Name with Expr::Call + let var_name = name.id.as_str().to_string(); + let range = name.range; + + // Record that this variable is needed from the outside context + // Convert TextSize to usize for start and end indices + let start_index = range.start().to_usize(); + let end_index = range.end().to_usize(); + self.add_used_variable(var_name.clone(), start_index, end_index); + + // Create a StringLiteral for the variable name + let var_name_literal = string_literal(&var_name, range); + + // `variable(context, source, (start_index, end_index), "var_name")` + let call_expr = + interceptor_call("variable", vec![var_name_literal], vec![], range); + + // Replace the current expression with the call + *expr = call_expr; + } + } + + // ⚠️ Function call transform from `foo(a, b=2, **c)` to `call(foo, a, b=2, **c)` + Expr::Call(call_expr) => { + // Transform to call(fn, *args, **kwargs) + // First, recursively transform the function expression and arguments + self.visit_expr(&mut call_expr.func); + self.visit_arguments(&mut call_expr.arguments); + + // Now wrap the entire call in `call(fn, *args, **kwargs)` + // This keeps the original function signature intact + + // Prepend the function as the first positional argument to call() + let mut args = vec![*call_expr.func.clone()]; + args.extend(call_expr.arguments.args.to_vec()); + + // `call(context, source, (start_index, end_index), fn, *args, **kwargs)` + let wrapper_call = interceptor_call( + "call", + args, + call_expr.arguments.keywords.to_vec(), + call_expr.range, + ); + + // Replace the current expression with the wrapper call + *expr = wrapper_call; + } + Expr::Starred(starred) => { + // Allow starred expressions (e.g., *args in function calls) + // Transform the value inside the starred expression + self.visit_expr(&mut starred.value); + } + + // ⚠️ Attribute access transform from `obj.attr` to `attribute(obj, "attr")` + Expr::Attribute(attr) => { + // Transform to attribute(obj, "attr_name") + // First, recursively transform the object expression + self.visit_expr(&mut attr.value); + + // Now wrap the attribute access in attribute(obj, "attr_name") + let range = attr.range; + let attr_name = attr.attr.as_str().to_string(); + + // Create a StringLiteral for the attribute name + let attr_name_literal = string_literal(&attr_name, range); + + // `attribute(context, source, (start_index, end_index), obj, "attr_name")` + let wrapper_call = interceptor_call( + "attribute", + vec![*attr.value.clone(), attr_name_literal], + vec![], + range, + ); + + // Replace the current expression with the wrapper call + *expr = wrapper_call; + } + + // ⚠️ Subscript subscript transform from `obj[key]` to `subscript(obj, key)` + Expr::Subscript(subscript) => { + // Transform to subscript(obj, key) + // First, recursively transform both the object and the key expressions + self.visit_expr(&mut subscript.value); + self.visit_expr(&mut subscript.slice); + + // Now wrap the subscript access in subscript(obj, key) + let range = subscript.range; + + // `subscript(context, source, (start_index, end_index), obj, key)` + let wrapper_call = interceptor_call( + "subscript", + vec![*subscript.value.clone(), *subscript.slice.clone()], + vec![], + range, + ); + + // Replace the current expression with the wrapper call + *expr = wrapper_call; + } + + // ⚠️ Slice transform from `obj[1:10:2]` to `slice(1, 10, 2)` + Expr::Slice(slice) => { + // Transform slice expressions (e.g., obj[1:10:2]) into slice(1, 10, 2) calls + // This is necessary because we're wrapping subscripts in subscript() calls, + // and Python doesn't allow : syntax outside of [] + + // First, recursively transform the lower, upper, and step expressions if they exist + if let Some(lower) = &mut slice.lower { + self.visit_expr(lower); + } + if let Some(upper) = &mut slice.upper { + self.visit_expr(upper); + } + if let Some(step) = &mut slice.step { + self.visit_expr(step); + } + + // Now convert the slice into a slice() call + let range = slice.range; + let slice_args = vec![ + // lower + slice + .lower + .as_ref() + .map(|e| *e.clone()) + .unwrap_or_else(|| none_literal(range)), + // upper + slice + .upper + .as_ref() + .map(|e| *e.clone()) + .unwrap_or_else(|| none_literal(range)), + // step + slice + .step + .as_ref() + .map(|e| *e.clone()) + .unwrap_or_else(|| none_literal(range)), + ]; + + // `slice(context, source, (start_index, end_index), lower, upper, step)` + let slice_call = interceptor_call("slice", slice_args, vec![], range); + + // Replace the current expression with the slice() call + *expr = slice_call; + } + + // ⚠️ Walrus operator transform from `x := y` to `assign(context, source, token, "x", y)` + // We always transform to assign() even in lambdas, so assignments leak out to the context + // This allows patterns like: fn_with_callback(on_done=lambda res: (data:= res)) + // However, we raise SyntaxError if the assignment conflicts with comprehension or lambda variables + Expr::Named(named) => { + // First, recursively transform the value expression + self.visit_expr(&mut named.value); + + // Get the variable name from the target + let var_name = match &*named.target { + Expr::Name(name) => name.id.as_str().to_string(), + _ => { + self.set_error( + "Validation Error: Named expression target must be a simple variable name" + .to_string(), + ); + return; + } + }; + + let range = named.range; + + // Check for conflicts with comprehension variables + if self.comprehension_variables.borrow().contains(&var_name) { + self.set_error(format!( + "SyntaxError: assignment expression cannot rebind comprehension iteration variable '{}'", + var_name + )); + return; + } + + // Check for conflicts with lambda variables + if self.lambda_variables.borrow().contains(&var_name) { + self.set_error(format!( + "SyntaxError: assignment expression cannot rebind lambda parameter '{}'", + var_name + )); + return; + } + + // Transform to assign() call and record assignment + // Extract position from the target (variable name), not the entire named expression + let target_range = match &*named.target { + Expr::Name(name) => name.range, + _ => range, // Fallback to entire expression if target is not a simple name + }; + let start_index = target_range.start().to_usize(); + let end_index = target_range.end().to_usize(); + self.add_assignment(var_name.clone(), start_index, end_index); + + // Create a StringLiteral for the variable name + let var_name_literal = string_literal(&var_name, range); + + // `assign(context, source, (start_index, end_index), "var_name", value)` + let wrapper_call = interceptor_call( + "assign", + vec![var_name_literal, *named.value.clone()], + vec![], + range, + ); + + // Replace the current expression with the wrapper call + *expr = wrapper_call; + } + + // ⚠️ F-string transform from `f"Hello {price!r:.2f}"` to `format(context, source, token, "Hello {}", (variable(context, source, token, "price"), "r", ".2f"))` + Expr::FString(f_string) => { + // Transform f-strings to .format() calls to avoid quote escaping issues + // f"Hello {name}" becomes "Hello {}".format(name) + + let range = f_string.range; + let mut format_args: Vec = Vec::new(); + let mut template_parts: Vec = Vec::new(); + + // Process all parts of the f-string + for part in &mut f_string.value { + if let ast::FStringPart::FString(f_str) = part { + // Process each element in the f-string + for element in f_str.elements.iter_mut() { + match element { + ast::InterpolatedStringElement::Literal(lit) => { + // Add literal text to template + template_parts.push(lit.value.to_string()); + } + ast::InterpolatedStringElement::Interpolation(interpolation) => { + // Transform the expression + self.visit_expr(&mut interpolation.expression); + + // Get range from the expression before cloning + let expr_range = + get_expr_range(&interpolation.expression, range); + let value_expr = *interpolation.expression.clone(); + + // Build conversion flag string ("r", "s", "a", or None) + let conversion_flag = match interpolation.conversion { + ast::ConversionFlag::None => none_literal(expr_range), + ast::ConversionFlag::Str => string_literal("s", expr_range), + ast::ConversionFlag::Ascii => { + string_literal("a", expr_range) + } + ast::ConversionFlag::Repr => { + string_literal("r", expr_range) + } + }; + + // Build format spec. Either: + // - static string (`:.2f`, `:>10`, etc.) + // - dynamic expression (`{width}.{precision}f`) + // We don't call built-in `format()` here; we pass the format spec as metadata + // and let our `format()` interceptor handle it. + let format_spec_expr = if let Some(format_spec) = + &mut interpolation.format_spec + { + // Build the format spec - can be static or dynamic + let mut spec_template_parts = Vec::new(); + let mut spec_format_args = Vec::new(); + + for spec_element in format_spec.elements.iter_mut() { + match spec_element { + ast::InterpolatedStringElement::Literal(lit) => { + spec_template_parts.push(lit.value.to_string()); + } + ast::InterpolatedStringElement::Interpolation( + spec_interp, + ) => { + // Format specs with expressions! + // e.g., f"{value:{width}.{precision}f}" + // Transform the expression in the format spec + self.visit_expr(&mut spec_interp.expression); + + // Add {} placeholder + spec_template_parts.push("{}".to_string()); + + // Add the transformed expression to spec args + spec_format_args + .push(*spec_interp.expression.clone()); + } + } + } + + let spec_template_str = spec_template_parts.join(""); + + // Build the format spec expression + if spec_format_args.is_empty() { + // Static format spec - just use a string literal + string_literal(&spec_template_str, expr_range) + } else { + // Dynamic format spec - we need to pass both template and args + // We'll pass this as a tuple: (template, *args) + let spec_template_literal = + string_literal(&spec_template_str, expr_range); + // Create a tuple: (template, arg1, arg2, ...) + Expr::Tuple(ast::ExprTuple { + node_index: Default::default(), + range: expr_range, + ctx: ast::ExprContext::Load, + parenthesized: false, + elts: { + let mut tuple_elts = + vec![spec_template_literal]; + tuple_elts.extend(spec_format_args); + tuple_elts.into() + }, + }) + } + } else { + // No format spec - use empty string + string_literal("", expr_range) + }; + + // Add simple placeholder to template + template_parts.push("{}".to_string()); + + // Pass each interpolation as a tuple: (value, conversion_flag, format_spec) + // This allows our format() interceptor to apply conversion and format_spec + // instead of calling built-in functions that won't be caught by error handling + let interpolation_tuple = Expr::Tuple(ast::ExprTuple { + node_index: Default::default(), + range: expr_range, + ctx: ast::ExprContext::Load, + parenthesized: false, + elts: vec![value_expr, conversion_flag, format_spec_expr] + .into(), + }); + + // Add the tuple to format args + format_args.push(interpolation_tuple); + } + } + } + } + } + + // Build the template string + let template_str = template_parts.join(""); + + // Create a string literal for the template + let template_literal = string_literal(&template_str, range); + + // `format(context, source, token, "template {}", *args)` + // We use an intercepted format() function instead of the built-in .format() method + // so that errors inside f-strings get nice error reporting with underlining + let mut format_args_with_template = vec![template_literal]; + format_args_with_template.extend(format_args); + + let format_call = + interceptor_call("format", format_args_with_template, vec![], range); + + // Replace the f-string with the intercepted format() call + *expr = format_call; + } + + // ⚠️ T-string transform from `t"Hello {name!r:>10}"` to `template(context, source, token, "Hello ", interpolation(context, source, token, variable(context, source, token, "name"), "expr", "r", ">10"))` + Expr::TString(t_string) => { + // Transform t-strings to Template() constructor calls + // t"Hello {name}" becomes Template("Hello ", Interpolation(name, "name", None, ""), "") + // See: https://docs.python.org/3.14/library/string.templatelib.html#template-strings + + let range = t_string.range; + let mut template_args: Vec = Vec::new(); + + // Process all parts of the t-string (TString doesn't have wrapper, just TString directly) + for t_str in &mut t_string.value { + // Process each element in the t-string + for element in t_str.elements.iter_mut() { + match element { + ast::InterpolatedStringElement::Literal(lit) => { + // Add literal string to Template args + let string_lit_literal = string_literal(&lit.value, lit.range); + template_args.push(string_lit_literal); + } + ast::InterpolatedStringElement::Interpolation(interpolation) => { + // Transform the expression + self.visit_expr(&mut interpolation.expression); + + // Use the interpolation expression's range for token info + let expr_range = get_expr_range(&interpolation.expression, range); + + // Get the expression text (we'll use a placeholder for now) + // In a real implementation, we'd preserve the original expression text from source + // Python docs recommend to use an empty string in manually-created + // Interpolations. + // See https://docs.python.org/3.14/library/string.templatelib.html#string.templatelib.Interpolation.expression + let expr_text = "".to_string(); // TODO: preserve original expression text + + // Build conversion argument + let conversion_expr = match interpolation.conversion { + ast::ConversionFlag::None => none_literal(interpolation.range), + ast::ConversionFlag::Str => { + string_literal("s", interpolation.range) + } + ast::ConversionFlag::Ascii => { + string_literal("a", interpolation.range) + } + ast::ConversionFlag::Repr => { + string_literal("r", interpolation.range) + } + }; + + // Build format spec - can be static string or dynamic expression + let format_spec_expr = if let Some(format_spec) = + &mut interpolation.format_spec + { + let mut spec_template_parts = Vec::new(); + let mut spec_format_args = Vec::new(); + + for spec_element in format_spec.elements.iter_mut() { + match spec_element { + ast::InterpolatedStringElement::Literal(lit) => { + spec_template_parts.push(lit.value.to_string()); + } + ast::InterpolatedStringElement::Interpolation( + spec_interp, + ) => { + // Dynamic format spec with expressions! + self.visit_expr(&mut spec_interp.expression); + spec_template_parts.push("{}".to_string()); + spec_format_args + .push(*spec_interp.expression.clone()); + } + } + } + + let spec_template_str = spec_template_parts.join(""); + + // Build the format spec expression + // Use interpolation.range for consistency with conversion expressions + if spec_format_args.is_empty() { + // Static format spec - just use a string literal + string_literal(&spec_template_str, interpolation.range) + } else { + // Dynamic format spec - use "{}".format(args...) + let spec_template_literal = + string_literal(&spec_template_str, interpolation.range); + // `"{}".format(args...)` + call( + attribute( + spec_template_literal, + "format", + interpolation.range, + ), + spec_format_args, + vec![], + interpolation.range, + ) + } + } else { + // No format spec - use empty string + string_literal("", interpolation.range) + }; + + // `interpolation(context, source, token, value, expression, conversion, format_spec)` + // Use interpolation.range for error reporting to point to the exact {expression!r:format} location + let interpolation_call = interceptor_call( + "interpolation", + vec![ + *interpolation.expression.clone(), + string_literal(&expr_text, expr_range), + conversion_expr, + format_spec_expr, + ], + vec![], + interpolation.range, + ); + + template_args.push(interpolation_call); + } + } + } + } + + // `template(context, source, token, ...)` + let template_call = interceptor_call("template", template_args, vec![], range); + + // Replace the t-string with the template() call + *expr = template_call; + } + + // ⚠️ In lambdas we don't transform the args/kwrags introduced by the function + Expr::Lambda(lambda) => { + // First, transform default parameter values in the OUTER scope + // (before lambda parameters become local variables) + // This is because default values are evaluated when the lambda is defined, + // not when it's called, so they should use the outer scope's variables + if let Some(parameters) = &mut lambda.parameters { + // Transform defaults for positional-only args + for param in &mut parameters.posonlyargs { + if let Some(default) = &mut param.default { + self.visit_expr(default); + } + } + // Transform defaults for regular positional/keyword args + for param in &mut parameters.args { + if let Some(default) = &mut param.default { + self.visit_expr(default); + } + } + // Transform defaults for keyword-only args + for param in &mut parameters.kwonlyargs { + if let Some(default) = &mut param.default { + self.visit_expr(default); + } + } + } + + // Now extract lambda parameter names into lambda variables + let mut lambda_vars = HashSet::new(); + if let Some(parameters) = &lambda.parameters { + for param in ¶meters.posonlyargs { + lambda_vars.insert(param.name().to_string()); + } + for param in ¶meters.args { + lambda_vars.insert(param.name().to_string()); + } + for param in ¶meters.kwonlyargs { + lambda_vars.insert(param.name().to_string()); + } + if let Some(vararg) = ¶meters.vararg { + lambda_vars.insert(vararg.name().to_string()); + } + if let Some(kwarg) = ¶meters.kwarg { + lambda_vars.insert(kwarg.name().to_string()); + } + } + + // Transform the lambda body with the parameter names as lambda variables + // This ensures lambda parameters are not transformed to variable("param", context) calls + // Walrus assignments inside lambdas are transformed to assign() and leak out to context + // This allows patterns like: fn_with_callback(on_done=lambda res: (data:= res)) + // However, walrus assignments cannot rebind lambda parameters (SyntaxError) + self.visit_expr_with_locals(&mut lambda.body, HashSet::new(), lambda_vars); + } + + _ => { + panic!("Validation Error: Unsupported expression: {:?}", expr); + } + } + } + + /// Override to forbid all statements + fn visit_stmt(&self, _stmt: &mut Stmt) { + self.set_error( + "Validation Error: Statements are not allowed in expression context".to_string(), + ); + return; + } + + /// Override visit_comprehension to handle local variables properly + fn visit_comprehension(&self, comprehension: &mut ast::Comprehension) { + // Comprehension breakdown: + // [x + 1 for x, y in range(n, 1) if (z := x + y) if True] + // ^^^^^ <-- elt (owned by ExprListComp) + // ^^^^ <-- target (owned by Comprehension) + // ^^^^^^^^^^^ <-- iter (owned by Comprehension) + // ^^^^^^^^^^^^^^^^^^^^^^ <-- ifs (owned by Comprehension) + // + // NOTE: The 'elt' is NOT part of Comprehension - it's owned by ExprListComp/ExprSetComp/etc. + // The visit_comprehension method only handles: target, iter, ifs + // + // Comprehensions are evaluated in the order: + // 1. iter + // 2. ifs + // 3. elt + // + // We don't visit the target as it's a newly introduced variable that doesn't need transformation + // Instead, we only visit the iter and ifs. elt is visited by respective comps like ListComp. + + // Visit the iterable expression FIRST + // This is important because the iter might contain walrus assignments that need to be + // available in the if conditions + // E.g., for x in [(a := i) for i in items] if (b := a + 1) + // The 'a' from the inner comprehension should be available in the if condition + self.visit_expr(&mut comprehension.iter); + + // Now visit all if conditions - these should have access to: + // - Local variables introduced by this comprehension's target + // - Walrus assignments made in the iter expression + for if_expr in comprehension.ifs.iter_mut() { + self.visit_expr(if_expr); + } + } +} diff --git a/crates/djc-safe-eval/src/utils/python_ast.rs b/crates/djc-safe-eval/src/utils/python_ast.rs new file mode 100644 index 0000000..0814246 --- /dev/null +++ b/crates/djc-safe-eval/src/utils/python_ast.rs @@ -0,0 +1,148 @@ +use ruff_python_ast::name::Name; +use ruff_python_ast::{ + Arguments, Expr, ExprAttribute, ExprCall, ExprContext, ExprName, ExprNoneLiteral, ExprNumberLiteral, Keyword, + ExprStringLiteral, ExprTuple, Identifier, StringLiteral, StringLiteralFlags, + StringLiteralValue, +}; +use ruff_text_size::TextRange; + +pub fn string_literal(value: &str, range: TextRange) -> Expr { + Expr::StringLiteral(ExprStringLiteral { + node_index: Default::default(), + range, + value: StringLiteralValue::single(StringLiteral { + range, + node_index: Default::default(), + value: value.to_string().into_boxed_str(), + flags: StringLiteralFlags::empty(), + }), + }) +} + +pub fn number_literal(value: usize, range: TextRange) -> Expr { + Expr::NumberLiteral(ExprNumberLiteral { + node_index: Default::default(), + range, + value: ruff_python_ast::Number::Int(ruff_python_ast::Int::from( + value.min(u32::MAX as usize) as u32, + )), + }) +} + +pub fn variable_name(name: &str, range: TextRange) -> Expr { + Expr::Name(ExprName { + node_index: Default::default(), + range, + id: Name::new(name), + ctx: ExprContext::Load, + }) +} + +pub fn none_literal(range: TextRange) -> Expr { + Expr::NoneLiteral(ExprNoneLiteral { + node_index: Default::default(), + range, + }) +} + +pub fn tuple_literal(elements: Vec, range: TextRange) -> Expr { + Expr::Tuple(ExprTuple { + node_index: Default::default(), + range, + ctx: ExprContext::Load, + parenthesized: true, + elts: elements.into(), + }) +} + +pub fn attribute(value: Expr, attr: &str, range: TextRange) -> Expr { + Expr::Attribute(ExprAttribute { + node_index: Default::default(), + range, + value: Box::new(value), + attr: Identifier::new(attr, range), + ctx: ExprContext::Load, + }) +} + +pub fn call(func: Expr, args: Vec, keywords: Vec, range: TextRange) -> Expr { + Expr::Call(ExprCall { + node_index: Default::default(), + range, + func: Box::new(func), + arguments: Arguments { + range, + node_index: Default::default(), + args: args.into_boxed_slice(), + keywords: keywords.into_boxed_slice(), + }, + }) +} + +/// Helper to create a call to an interceptor function (e.g. `variable()`, `call()`, etc) +pub fn interceptor_call( + func_name: &str, + args: Vec, + keywords: Vec, + range: ruff_text_size::TextRange, +) -> Expr { + // Prepend context, source, and token tuple as first arguments + let mut all_args = vec![ + variable_name("context", range), + variable_name("source", range), + // Create tuple: (start_index_int, end_index_int) + tuple_literal(vec![ + number_literal(range.start().to_usize(), range), + number_literal(range.end().to_usize(), range), + ], range), + ]; + + // Followed by the rest of the arguments + all_args.extend(args); + + // E.g. `call(context, source, (start_index, end_index), fn, *args, **kwargs)` + call( + variable_name(func_name, range), + all_args, + keywords, + range + ) +} + +/// Helper to extract the TextRange from an Expr. +/// Used to get the range of interpolated expressions in f-strings and t-strings. +pub fn get_expr_range( + expr: &Expr, + fallback_range: ruff_text_size::TextRange, +) -> ruff_text_size::TextRange { + match expr { + Expr::Name(n) => n.range, + Expr::Call(c) => c.range, + Expr::Attribute(a) => a.range, + Expr::Subscript(s) => s.range, + Expr::BinOp(b) => b.range, + Expr::UnaryOp(u) => u.range, + Expr::Compare(c) => c.range, + Expr::BoolOp(b) => b.range, + Expr::If(i) => i.range, + Expr::Named(n) => n.range, + Expr::StringLiteral(s) => s.range, + Expr::NumberLiteral(n) => n.range, + Expr::BooleanLiteral(b) => b.range, + Expr::NoneLiteral(n) => n.range, + Expr::List(l) => l.range, + Expr::Tuple(t) => t.range, + Expr::Dict(d) => d.range, + Expr::Set(s) => s.range, + Expr::FString(f) => f.range, + Expr::TString(t) => t.range, + Expr::Lambda(l) => l.range, + Expr::ListComp(l) => l.range, + Expr::SetComp(s) => s.range, + Expr::DictComp(d) => d.range, + Expr::Generator(g) => g.range, + Expr::Starred(s) => s.range, + Expr::Slice(s) => s.range, + _ => fallback_range, // Fallback to provided range for unknown types + } +} diff --git a/crates/djc-safe-eval/submodules/ruff b/crates/djc-safe-eval/submodules/ruff new file mode 160000 index 0000000..beea8cd --- /dev/null +++ b/crates/djc-safe-eval/submodules/ruff @@ -0,0 +1 @@ +Subproject commit beea8cdfec826802a7d9ecada3b38156eb693e77 diff --git a/crates/djc-safe-eval/tests/transformer.rs b/crates/djc-safe-eval/tests/transformer.rs new file mode 100644 index 0000000..51102f0 --- /dev/null +++ b/crates/djc-safe-eval/tests/transformer.rs @@ -0,0 +1,2419 @@ +#[cfg(test)] +mod tests { + use djc_safe_eval::codegen::generate_python_code; + use djc_safe_eval::transformer::transform_expression_string; + + fn _test_transformation( + input: &str, + expected: &str, + expected_used_vars: Vec<(&str, usize, usize, (usize, usize))>, // (content, start_index, end_index, (line, col)) + expected_assigned_vars: Vec<(&str, usize, usize, (usize, usize))>, // (content, start_index, end_index, (line, col)) + ) { + let result = transform_expression_string(input); + assert!(result.is_ok()); + let transform_result = result.unwrap(); + let generated = generate_python_code(&transform_result.expression); + assert_eq!(generated, expected); + + // Assert used variables with full token information + let actual_used_vars: Vec<(&str, usize, usize, (usize, usize))> = transform_result + .used_vars + .iter() + .map(|token| { + ( + token.content.as_str(), + token.start_index, + token.end_index, + token.line_col, + ) + }) + .collect(); + assert_eq!( + actual_used_vars, expected_used_vars, + "Used variables mismatch for input: {}", + input + ); + + // Assert assigned variables with full token information + let actual_assigned_vars: Vec<(&str, usize, usize, (usize, usize))> = transform_result + .assigned_vars + .iter() + .map(|token| { + ( + token.content.as_str(), + token.start_index, + token.end_index, + token.line_col, + ) + }) + .collect(); + assert_eq!( + actual_assigned_vars, expected_assigned_vars, + "Assigned variables mismatch for input: {}", + input + ); + } + + fn _test_forbidden_syntax(input: &str) { + let result = transform_expression_string(input); + if result.is_ok() { + // If transformation succeeded, print the result to help debug + let transform_result = result.unwrap(); + let generated = generate_python_code(&transform_result.expression); + panic!( + "Expected transformation to fail, but it succeeded.\nInput: {}\nGenerated code: {}", + input, generated + ); + } + let error = result.unwrap_err(); + if !error.contains("Parse error") + && !error.contains("Unexpected token") + && !error.contains("SyntaxError") + { + panic!( + "Expected error to contain 'Parse error', 'Unexpected token', or 'SyntaxError', but got:\nInput: {}\nError: {}", + input, error + ); + } + } + + #[test] + fn test_allow_comments() { + _test_transformation("1 # comment", "1", vec![], vec![]); + } + + // === LITERAL TESTS === + + #[test] + fn test_allow_literal_string() { + _test_transformation("\"hello world\"", "\"hello world\"", vec![], vec![]); + } + + #[test] + fn test_allow_literal_bytes() { + _test_transformation("b'hello'", "b'hello'", vec![], vec![]); + } + + #[test] + fn test_allow_literal_integer() { + _test_transformation("42", "42", vec![], vec![]); + } + + #[test] + fn test_allow_literal_integer_negative() { + _test_transformation("-42", "-42", vec![], vec![]); + } + + #[test] + fn test_allow_literal_float() { + _test_transformation("3.14", "3.14", vec![], vec![]); + } + + #[test] + fn test_allow_literal_float_negative() { + _test_transformation("-3.14", "-3.14", vec![], vec![]); + } + + #[test] + fn test_allow_literal_float_scientific() { + _test_transformation("-1e10", "-10000000000.0", vec![], vec![]); + } + + #[test] + fn test_allow_literal_boolean_true() { + _test_transformation("True", "True", vec![], vec![]); + } + + #[test] + fn test_allow_literal_boolean_false() { + _test_transformation("False", "False", vec![], vec![]); + } + + #[test] + fn test_allow_literal_none() { + _test_transformation("None", "None", vec![], vec![]); + } + + #[test] + fn test_allow_literal_ellipsis() { + _test_transformation("...", "...", vec![], vec![]); + } + + // === DATA STRUCTURE TESTS === + + #[test] + fn test_allow_list_empty() { + _test_transformation("[]", "[]", vec![], vec![]); + } + + #[test] + fn test_allow_list_with_literals() { + _test_transformation("[1, 2, 3]", "[1, 2, 3]", vec![], vec![]); + } + + #[test] + fn test_allow_tuple_empty() { + _test_transformation("()", "()", vec![], vec![]); + } + + #[test] + fn test_allow_tuple_with_literals() { + _test_transformation("(1, 2, 3)", "1, 2, 3", vec![], vec![]); + } + + // NOTE: As can be seen below, it's not possible to construct empty + // sets, because `set` will be interpreted as a variable and replaced + // with `variable('set', context)`. + #[test] + fn test_allow_set_empty() { + _test_transformation( + "set()", + "call(context, source, (0, 5), variable(context, source, (0, 3), 'set'))", + vec![("set", 0, 3, (1, 1))], + vec![], + ); + } + + #[test] + fn test_allow_set_literal() { + _test_transformation("{1, 2, 3}", "{1, 2, 3}", vec![], vec![]); + } + + #[test] + fn test_allow_dict_empty() { + _test_transformation("{}", "{}", vec![], vec![]); + } + + #[test] + fn test_allow_dict_with_literals() { + _test_transformation("{'a': 1, 'b': 2}", "{'a': 1, 'b': 2}", vec![], vec![]); + } + + #[test] + fn test_allow_nested_data_structures() { + _test_transformation( + "[1, [2, 3], {'a': 4}]", + "[1, [2, 3], {'a': 4}]", + vec![], + vec![], + ); + } + + // === UNARY OPERATOR TESTS === + + #[test] + fn test_allow_unary_plus() { + _test_transformation("+42", "+42", vec![], vec![]); + } + + #[test] + fn test_allow_unary_minus() { + _test_transformation("-42", "-42", vec![], vec![]); + } + + #[test] + fn test_allow_unary_not() { + _test_transformation("not True", "not True", vec![], vec![]); + } + + #[test] + fn test_allow_unary_invert() { + _test_transformation("~42", "~42", vec![], vec![]); + } + + #[test] + fn test_allow_nested_unary_operators() { + _test_transformation("--42", "--42", vec![], vec![]); + } + + // === BINARY OPERATOR TESTS === + + #[test] + fn test_allow_binary_add() { + _test_transformation("1 + 2", "1 + 2", vec![], vec![]); + } + + #[test] + fn test_allow_binary_subtract() { + _test_transformation("5 - 3", "5 - 3", vec![], vec![]); + } + + #[test] + fn test_allow_binary_multiply() { + _test_transformation("4 * 5", "4 * 5", vec![], vec![]); + } + + #[test] + fn test_allow_binary_divide() { + _test_transformation("10 / 2", "10 / 2", vec![], vec![]); + } + + #[test] + fn test_allow_binary_modulo() { + _test_transformation("10 % 3", "10 % 3", vec![], vec![]); + } + + #[test] + fn test_allow_binary_power() { + _test_transformation("2 ** 3", "2 ** 3", vec![], vec![]); + } + + #[test] + fn test_allow_binary_equality() { + _test_transformation("1 == 1", "1 == 1", vec![], vec![]); + } + + #[test] + fn test_allow_binary_inequality() { + _test_transformation("1 != 2", "1 != 2", vec![], vec![]); + } + + #[test] + fn test_allow_binary_less_than() { + _test_transformation("1 < 2", "1 < 2", vec![], vec![]); + } + + #[test] + fn test_allow_binary_greater_than() { + _test_transformation("3 > 2", "3 > 2", vec![], vec![]); + } + + #[test] + fn test_allow_binary_less_equal() { + _test_transformation("2 <= 3", "2 <= 3", vec![], vec![]); + } + + #[test] + fn test_allow_binary_greater_equal() { + _test_transformation("3 >= 2", "3 >= 2", vec![], vec![]); + } + + #[test] + fn test_allow_nested_binary_operations() { + _test_transformation("1 + 2 * 3", "1 + 2 * 3", vec![], vec![]); + } + + // Boolean operator tests + #[test] + fn test_allow_boolean_and() { + _test_transformation("True and False", "True and False", vec![], vec![]); + } + + #[test] + fn test_allow_boolean_or() { + _test_transformation("True or False", "True or False", vec![], vec![]); + } + + #[test] + fn test_allow_boolean_chained_and() { + _test_transformation( + "True and False and True", + "True and False and True", + vec![], + vec![], + ); + } + + #[test] + fn test_allow_boolean_chained_or() { + _test_transformation( + "False or True or False", + "False or True or False", + vec![], + vec![], + ); + } + + #[test] + fn test_allow_boolean_mixed_operators() { + _test_transformation( + "True and False or True", + "True and False or True", + vec![], + vec![], + ); + } + + #[test] + fn test_allow_boolean_with_comparisons() { + _test_transformation("1 < 2 and 3 > 4", "1 < 2 and 3 > 4", vec![], vec![]); + } + + // Comparison operator tests + + #[test] + fn test_allow_comparison_less_than() { + _test_transformation("1 < 2", "1 < 2", vec![], vec![]); + } + + #[test] + fn test_allow_comparison_less_equal() { + _test_transformation("1 <= 2", "1 <= 2", vec![], vec![]); + } + + #[test] + fn test_allow_comparison_greater_than() { + _test_transformation("3 > 2", "3 > 2", vec![], vec![]); + } + + #[test] + fn test_allow_comparison_greater_equal() { + _test_transformation("3 >= 2", "3 >= 2", vec![], vec![]); + } + + #[test] + fn test_allow_comparison_equality() { + _test_transformation("1 == 1", "1 == 1", vec![], vec![]); + } + + #[test] + fn test_allow_comparison_inequality() { + _test_transformation("1 != 2", "1 != 2", vec![], vec![]); + } + + #[test] + fn test_allow_comparison_in() { + _test_transformation("1 in [1, 2, 3]", "1 in [1, 2, 3]", vec![], vec![]); + } + + #[test] + fn test_allow_comparison_not_in() { + _test_transformation("4 not in [1, 2, 3]", "4 not in [1, 2, 3]", vec![], vec![]); + } + + #[test] + fn test_allow_comparison_is() { + _test_transformation( + "x is None", + "variable(context, source, (0, 1), 'x') is None", + vec![("x", 0, 1, (1, 1))], + vec![], + ); + } + + #[test] + fn test_allow_comparison_is_not() { + _test_transformation( + "x is not None", + "variable(context, source, (0, 1), 'x') is not None", + vec![("x", 0, 1, (1, 1))], + vec![], + ); + } + + #[test] + fn test_allow_comparison_chained() { + _test_transformation("1 < 2 < 3", "1 < 2 < 3", vec![], vec![]); + } + + #[test] + fn test_allow_comparison_mixed_types() { + _test_transformation("'hello' == 'world'", "'hello' == 'world'", vec![], vec![]); + } + + // === COMPREHENSION TESTS === + + #[test] + fn test_allow_list_comprehension() { + _test_transformation( + "[x for x in items]", + "[x for x in variable(context, source, (12, 17), 'items')]", + vec![("items", 12, 17, (1, 13))], + vec![], + ); + } + + #[test] + fn test_allow_list_comprehension_with_condition() { + _test_transformation( + "[x for x in items if x > 0]", + "[x for x in variable(context, source, (12, 17), 'items') if x > 0]", + vec![("items", 12, 17, (1, 13))], + vec![], + ); + } + + #[test] + fn test_allow_list_comprehension_complex() { + // Test list comprehension with: + // - Multiple 'for' clauses + // - Multiple 'if' conditions + // - Mix of local variables (x, y) and external variables (items, multiplier, min_val, max_val) + _test_transformation( + "[x * y * multiplier for x in items for y in x.values if x > min_val if y < max_val]", + "[x * y * variable(context, source, (9, 19), 'multiplier') for x in variable(context, source, (29, 34), 'items') for y in attribute(context, source, (44, 52), x, 'values') if x > variable(context, source, (60, 67), 'min_val') if y < variable(context, source, (75, 82), 'max_val')]", + vec![ + ("multiplier", 9, 19, (1, 10)), + ("items", 29, 34, (1, 30)), + ("min_val", 60, 67, (1, 61)), + ("max_val", 75, 82, (1, 76)), + ], + vec![], + ); + } + + #[test] + fn test_forbid_async_list_comprehension() { + let result = transform_expression_string("[x async for x in items]"); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.contains("Async comprehensions are not allowed")); + } + + #[test] + fn test_allow_set_comprehension() { + _test_transformation( + "{x for x in items}", + "{x for x in variable(context, source, (12, 17), 'items')}", + vec![("items", 12, 17, (1, 13))], + vec![], + ); + } + + #[test] + fn test_forbid_async_set_comprehension() { + let result = transform_expression_string("{x async for x in items}"); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.contains("Async comprehensions are not allowed")); + } + + #[test] + fn test_allow_dict_comprehension() { + _test_transformation( + "{x: x*2 for x in items}", + "{x: x * 2 for x in variable(context, source, (17, 22), 'items')}", + vec![("items", 17, 22, (1, 18))], + vec![], + ); + } + + #[test] + fn test_forbid_async_dict_comprehension() { + let result = transform_expression_string("{x: x*2 async for x in items}"); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.contains("Async comprehensions are not allowed")); + } + + #[test] + fn test_allow_generator_expression() { + _test_transformation( + "(x for x in items)", + "(x for x in variable(context, source, (12, 17), 'items'))", + vec![("items", 12, 17, (1, 13))], + vec![], + ); + } + + #[test] + fn test_forbid_async_generator_expression() { + let result = transform_expression_string("(x async for x in items)"); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.contains("Async comprehensions are not allowed")); + } + + #[test] + fn test_allow_multiple_comprehensions() { + _test_transformation( + "[x for x in items for y in x.children]", + "[x for x in variable(context, source, (12, 17), 'items') for y in attribute(context, source, (27, 37), x, 'children')]", + vec![("items", 12, 17, (1, 13))], + vec![], + ); + } + + #[test] + fn test_allow_comprehension_with_multiple_conditions() { + _test_transformation( + "[x for x in items if x > 0 if x < 10]", + "[x for x in variable(context, source, (12, 17), 'items') if x > 0 if x < 10]", + vec![("items", 12, 17, (1, 13))], + vec![], + ); + } + + #[test] + fn test_allow_nested_comprehension() { + _test_transformation( + "[[x for x in row] for row in matrix]", + "[[x for x in row] for row in variable(context, source, (29, 35), 'matrix')]", + vec![("matrix", 29, 35, (1, 30))], + vec![], + ); + } + + // === FUNCTION CALL TESTS === + + #[test] + fn test_transform_function_call_simple() { + _test_transformation( + "foo()", + "call(context, source, (0, 5), variable(context, source, (0, 3), 'foo'))", + vec![("foo", 0, 3, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_function_call_with_positional_args() { + _test_transformation( + "foo(x, 2, 3)", + "call(context, source, (0, 12), variable(context, source, (0, 3), 'foo'), variable(context, source, (4, 5), 'x'), 2, 3)", + vec![("foo", 0, 3, (1, 1)), ("x", 4, 5, (1, 5))], + vec![], + ); + } + + #[test] + fn test_transform_function_call_with_keyword_args() { + _test_transformation( + "foo(a=1, b=x)", + "call(context, source, (0, 13), variable(context, source, (0, 3), 'foo'), a=1, b=variable(context, source, (11, 12), 'x'))", + vec![("foo", 0, 3, (1, 1)), ("x", 11, 12, (1, 12))], + vec![], + ); + } + + #[test] + fn test_transform_function_call_with_mixed_args() { + _test_transformation( + "foo(1, x, a=y, b=4)", + "call(context, source, (0, 19), variable(context, source, (0, 3), 'foo'), 1, variable(context, source, (7, 8), 'x'), a=variable(context, source, (12, 13), 'y'), b=4)", + vec![ + ("foo", 0, 3, (1, 1)), + ("x", 7, 8, (1, 8)), + ("y", 12, 13, (1, 13)), + ], + vec![], + ); + } + + #[test] + fn test_transform_nested_function_calls() { + _test_transformation( + "foo(bar(1, x))", + "call(context, source, (0, 14), variable(context, source, (0, 3), 'foo'), call(context, source, (4, 13), variable(context, source, (4, 7), 'bar'), 1, variable(context, source, (11, 12), 'x')))", + vec![ + ("foo", 0, 3, (1, 1)), + ("bar", 4, 7, (1, 5)), + ("x", 11, 12, (1, 12)), + ], + vec![], + ); + } + + #[test] + fn test_transform_method_call() { + _test_transformation( + "obj.method()", + "call(context, source, (0, 12), attribute(context, source, (0, 10), variable(context, source, (0, 3), 'obj'), 'method'))", + vec![("obj", 0, 3, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_function_call_with_variable_args() { + _test_transformation( + "foo(x, y, z)", + "call(context, source, (0, 12), variable(context, source, (0, 3), 'foo'), variable(context, source, (4, 5), 'x'), variable(context, source, (7, 8), 'y'), variable(context, source, (10, 11), 'z'))", + vec![ + ("foo", 0, 3, (1, 1)), + ("x", 4, 5, (1, 5)), + ("y", 7, 8, (1, 8)), + ("z", 10, 11, (1, 11)), + ], + vec![], + ); + } + + #[test] + fn test_transform_function_call_with_variable_kwargs() { + _test_transformation( + "foo(a=x, b=y)", + "call(context, source, (0, 13), variable(context, source, (0, 3), 'foo'), a=variable(context, source, (6, 7), 'x'), b=variable(context, source, (11, 12), 'y'))", + vec![ + ("foo", 0, 3, (1, 1)), + ("x", 6, 7, (1, 7)), + ("y", 11, 12, (1, 12)), + ], + vec![], + ); + } + + #[test] + fn test_transform_function_call_with_spread_args() { + _test_transformation( + "foo(*args)", + "call(context, source, (0, 10), variable(context, source, (0, 3), 'foo'), *variable(context, source, (5, 9), 'args'))", + vec![("foo", 0, 3, (1, 1)), ("args", 5, 9, (1, 6))], + vec![], + ); + } + + #[test] + fn test_transform_function_call_with_spread_kwargs() { + _test_transformation( + "foo(**kwargs)", + "call(context, source, (0, 13), variable(context, source, (0, 3), 'foo'), **variable(context, source, (6, 12), 'kwargs'))", + vec![("foo", 0, 3, (1, 1)), ("kwargs", 6, 12, (1, 7))], + vec![], + ); + } + + #[test] + fn test_transform_function_call_with_mixed_spreads() { + _test_transformation( + "foo(1, *args, a=2, **kwargs)", + "call(context, source, (0, 28), variable(context, source, (0, 3), 'foo'), 1, *variable(context, source, (8, 12), 'args'), a=2, **variable(context, source, (21, 27), 'kwargs'))", + vec![ + ("foo", 0, 3, (1, 1)), + ("args", 8, 12, (1, 9)), + ("kwargs", 21, 27, (1, 22)), + ], + vec![], + ); + } + + #[test] + fn test_transform_function_call_with_nested_call_as_arg() { + _test_transformation( + "foo(a=get_item())", + "call(context, source, (0, 17), variable(context, source, (0, 3), 'foo'), a=call(context, source, (6, 16), variable(context, source, (6, 14), 'get_item')))", + vec![("foo", 0, 3, (1, 1)), ("get_item", 6, 14, (1, 7))], + vec![], + ); + } + + #[test] + fn test_transform_function_call_complex_signature() { + _test_transformation( + "foo(x, y, 3, *args, a=get_item(), b=5, **kwargs)", + "call(context, source, (0, 48), variable(context, source, (0, 3), 'foo'), variable(context, source, (4, 5), 'x'), variable(context, source, (7, 8), 'y'), 3, *variable(context, source, (14, 18), 'args'), a=call(context, source, (22, 32), variable(context, source, (22, 30), 'get_item')), b=5, **variable(context, source, (41, 47), 'kwargs'))", + vec![ + ("foo", 0, 3, (1, 1)), + ("x", 4, 5, (1, 5)), + ("y", 7, 8, (1, 8)), + ("args", 14, 18, (1, 15)), + ("get_item", 22, 30, (1, 23)), + ("kwargs", 41, 47, (1, 42)), + ], + vec![], + ); + } + + #[test] + fn test_transform_variable_as_callable() { + _test_transformation( + "my_func(1, 2)", + "call(context, source, (0, 13), variable(context, source, (0, 7), 'my_func'), 1, 2)", + vec![("my_func", 0, 7, (1, 1))], + vec![], + ); + } + + // === ATTRIBUTE ACCESS TESTS === + + #[test] + fn test_transform_attribute_access_simple() { + _test_transformation( + "obj.attr", + "attribute(context, source, (0, 8), variable(context, source, (0, 3), 'obj'), 'attr')", + vec![("obj", 0, 3, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_attribute_access_chained() { + _test_transformation( + "obj.foo.bar", + "attribute(context, source, (0, 11), attribute(context, source, (0, 7), variable(context, source, (0, 3), 'obj'), 'foo'), 'bar')", + vec![("obj", 0, 3, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_attribute_access_with_method_call() { + _test_transformation( + "obj.method()", + "call(context, source, (0, 12), attribute(context, source, (0, 10), variable(context, source, (0, 3), 'obj'), 'method'))", + vec![("obj", 0, 3, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_attribute_access_with_args() { + _test_transformation( + "obj.method(1, x)", + "call(context, source, (0, 16), attribute(context, source, (0, 10), variable(context, source, (0, 3), 'obj'), 'method'), 1, variable(context, source, (14, 15), 'x'))", + vec![("obj", 0, 3, (1, 1)), ("x", 14, 15, (1, 15))], + vec![], + ); + } + + #[test] + fn test_transform_nested_attribute_method_call() { + _test_transformation( + "obj.foo.bar.baz()", + "call(context, source, (0, 17), attribute(context, source, (0, 15), attribute(context, source, (0, 11), attribute(context, source, (0, 7), variable(context, source, (0, 3), 'obj'), 'foo'), 'bar'), 'baz'))", + vec![("obj", 0, 3, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_attribute_in_expression() { + _test_transformation( + "obj.value + 10", + "attribute(context, source, (0, 9), variable(context, source, (0, 3), 'obj'), 'value') + 10", + vec![("obj", 0, 3, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_attribute_in_comprehension() { + _test_transformation( + "[item.name for item in items]", + "[attribute(context, source, (1, 10), item, 'name') for item in variable(context, source, (23, 28), 'items')]", + vec![("items", 23, 28, (1, 24))], + vec![], + ); + } + + // NOTE: The transformation happens, but the runtime `attribute()` function + // will be responsible for blocking access to _private attributes + #[test] + fn test_transform_attribute_with_underscore() { + _test_transformation( + "obj._private", + "attribute(context, source, (0, 12), variable(context, source, (0, 3), 'obj'), '_private')", + vec![("obj", 0, 3, (1, 1))], + vec![], + ); + } + + // NOTE: The transformation happens, but the runtime `attribute()` function + // will be responsible for blocking access to __dunder__ attributes + #[test] + fn test_transform_attribute_with_dunder() { + _test_transformation( + "obj.__class__", + "attribute(context, source, (0, 13), variable(context, source, (0, 3), 'obj'), '__class__')", + vec![("obj", 0, 3, (1, 1))], + vec![], + ); + } + + // === SUBSCRIPT ACCESS TESTS === + + #[test] + fn test_transform_subscript_access_simple() { + _test_transformation( + "obj[0]", + "subscript(context, source, (0, 6), variable(context, source, (0, 3), 'obj'), 0)", + vec![("obj", 0, 3, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_subscript_access_with_variable_key() { + _test_transformation( + "obj[key]", + "subscript(context, source, (0, 8), variable(context, source, (0, 3), 'obj'), variable(context, source, (4, 7), 'key'))", + vec![("obj", 0, 3, (1, 1)), ("key", 4, 7, (1, 5))], + vec![], + ); + } + + #[test] + fn test_transform_subscript_access_with_string_key() { + _test_transformation( + "obj['name']", + "subscript(context, source, (0, 11), variable(context, source, (0, 3), 'obj'), 'name')", + vec![("obj", 0, 3, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_subscript_access_chained() { + _test_transformation( + "obj[0][1]", + "subscript(context, source, (0, 9), subscript(context, source, (0, 6), variable(context, source, (0, 3), 'obj'), 0), 1)", + vec![("obj", 0, 3, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_subscript_access_with_expression_key() { + _test_transformation( + "obj[x + 1]", + "subscript(context, source, (0, 10), variable(context, source, (0, 3), 'obj'), variable(context, source, (4, 5), 'x') + 1)", + vec![("obj", 0, 3, (1, 1)), ("x", 4, 5, (1, 5))], + vec![], + ); + } + + #[test] + fn test_transform_subscript_access_in_expression() { + _test_transformation( + "obj[0] + 10", + "subscript(context, source, (0, 6), variable(context, source, (0, 3), 'obj'), 0) + 10", + vec![("obj", 0, 3, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_subscript_access_in_comprehension() { + _test_transformation( + "[item[0] for item in items]", + "[subscript(context, source, (1, 8), item, 0) for item in variable(context, source, (21, 26), 'items')]", + vec![("items", 21, 26, (1, 22))], + vec![], + ); + } + + #[test] + fn test_transform_mixed_attribute_and_subscript() { + _test_transformation( + "obj.items[0]", + "subscript(context, source, (0, 12), attribute(context, source, (0, 9), variable(context, source, (0, 3), 'obj'), 'items'), 0)", + vec![("obj", 0, 3, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_subscript_then_attribute() { + _test_transformation( + "obj[0].name", + "attribute(context, source, (0, 11), subscript(context, source, (0, 6), variable(context, source, (0, 3), 'obj'), 0), 'name')", + vec![("obj", 0, 3, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_subscript_with_method_call() { + _test_transformation( + "obj[0].method()", + "call(context, source, (0, 15), attribute(context, source, (0, 13), subscript(context, source, (0, 6), variable(context, source, (0, 3), 'obj'), 0), 'method'))", + vec![("obj", 0, 3, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_complex_nested_access() { + _test_transformation( + "obj.items[key].value", + "attribute(context, source, (0, 20), subscript(context, source, (0, 14), attribute(context, source, (0, 9), variable(context, source, (0, 3), 'obj'), 'items'), variable(context, source, (10, 13), 'key')), 'value')", + vec![("obj", 0, 3, (1, 1)), ("key", 10, 13, (1, 11))], + vec![], + ); + } + + // === SLICE TESTS === + + #[test] + fn test_transform_slice_start_stop() { + _test_transformation( + "list[1:x]", + "subscript(context, source, (0, 9), variable(context, source, (0, 4), 'list'), slice(context, source, (5, 8), 1, variable(context, source, (7, 8), 'x'), None))", + vec![("list", 0, 4, (1, 1)), ("x", 7, 8, (1, 8))], + vec![], + ); + } + + #[test] + fn test_transform_slice_stop_only() { + _test_transformation( + "list[:x]", + "subscript(context, source, (0, 8), variable(context, source, (0, 4), 'list'), slice(context, source, (5, 7), None, variable(context, source, (6, 7), 'x'), None))", + vec![("list", 0, 4, (1, 1)), ("x", 6, 7, (1, 7))], + vec![], + ); + } + + #[test] + fn test_transform_slice_start_only() { + _test_transformation( + "list[1:]", + "subscript(context, source, (0, 8), variable(context, source, (0, 4), 'list'), slice(context, source, (5, 7), 1, None, None))", + vec![("list", 0, 4, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_slice_all() { + _test_transformation( + "list[:]", + "subscript(context, source, (0, 7), variable(context, source, (0, 4), 'list'), slice(context, source, (5, 6), None, None, None))", + vec![("list", 0, 4, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_slice_with_step() { + _test_transformation( + "list[::]", + "subscript(context, source, (0, 8), variable(context, source, (0, 4), 'list'), slice(context, source, (5, 7), None, None, None))", + vec![("list", 0, 4, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_slice_reverse() { + _test_transformation( + "list[::-1]", + "subscript(context, source, (0, 10), variable(context, source, (0, 4), 'list'), slice(context, source, (5, 9), None, None, -1))", + vec![("list", 0, 4, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_slice_full() { + _test_transformation( + "list[1:-2:1]", + "subscript(context, source, (0, 12), variable(context, source, (0, 4), 'list'), slice(context, source, (5, 11), 1, -2, 1))", + vec![("list", 0, 4, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_slice_start_with_step() { + _test_transformation( + "list[1::]", + "subscript(context, source, (0, 9), variable(context, source, (0, 4), 'list'), slice(context, source, (5, 8), 1, None, None))", + vec![("list", 0, 4, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_slice_start_stop_with_step() { + _test_transformation( + "list[1:2:]", + "subscript(context, source, (0, 10), variable(context, source, (0, 4), 'list'), slice(context, source, (5, 9), 1, 2, None))", + vec![("list", 0, 4, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_slice_with_variables() { + _test_transformation( + "list[start:end:step]", + "subscript(context, source, (0, 20), variable(context, source, (0, 4), 'list'), slice(context, source, (5, 19), variable(context, source, (5, 10), 'start'), variable(context, source, (11, 14), 'end'), variable(context, source, (15, 19), 'step')))", + vec![ + ("list", 0, 4, (1, 1)), + ("start", 5, 10, (1, 6)), + ("end", 11, 14, (1, 12)), + ("step", 15, 19, (1, 16)), + ], + vec![], + ); + } + + #[test] + fn test_transform_slice_with_expressions() { + _test_transformation( + "list[x + 1:y - 1]", + "subscript(context, source, (0, 17), variable(context, source, (0, 4), 'list'), slice(context, source, (5, 16), variable(context, source, (5, 6), 'x') + 1, variable(context, source, (11, 12), 'y') - 1, None))", + vec![ + ("list", 0, 4, (1, 1)), + ("x", 5, 6, (1, 6)), + ("y", 11, 12, (1, 12)), + ], + vec![], + ); + } + + #[test] + fn test_transform_slice_with_function_call() { + _test_transformation( + "list[get_start():get_end()]", + "subscript(context, source, (0, 27), variable(context, source, (0, 4), 'list'), slice(context, source, (5, 26), call(context, source, (5, 16), variable(context, source, (5, 14), 'get_start')), call(context, source, (17, 26), variable(context, source, (17, 24), 'get_end')), None))", + vec![ + ("list", 0, 4, (1, 1)), + ("get_start", 5, 14, (1, 6)), + ("get_end", 17, 24, (1, 18)), + ], + vec![], + ); + } + + #[test] + fn test_transform_slice_with_attribute_access() { + _test_transformation( + "list[obj.start:obj.end]", + "subscript(context, source, (0, 23), variable(context, source, (0, 4), 'list'), slice(context, source, (5, 22), attribute(context, source, (5, 14), variable(context, source, (5, 8), 'obj'), 'start'), attribute(context, source, (15, 22), variable(context, source, (15, 18), 'obj'), 'end'), None))", + vec![ + ("list", 0, 4, (1, 1)), + ("obj", 5, 8, (1, 6)), + ("obj", 15, 18, (1, 16)), + ], + vec![], + ); + } + + // === WALRUS OPERATOR (NAMED EXPRESSION) TESTS === + + #[test] + fn test_transform_walrus_simple() { + _test_transformation( + "(x := 5)", + "assign(context, source, (1, 7), 'x', 5)", + vec![], + vec![("x", 1, 2, (1, 2))], + ); + } + + #[test] + fn test_transform_walrus_with_variable() { + _test_transformation( + "(x := y)", + "assign(context, source, (1, 7), 'x', variable(context, source, (6, 7), 'y'))", + vec![("y", 6, 7, (1, 7))], + vec![("x", 1, 2, (1, 2))], + ); + } + + #[test] + fn test_transform_walrus_with_expression() { + _test_transformation( + "(x := y + 1)", + "assign(context, source, (1, 11), 'x', variable(context, source, (6, 7), 'y') + 1)", + vec![("y", 6, 7, (1, 7))], + vec![("x", 1, 2, (1, 2))], + ); + } + + #[test] + fn test_transform_walrus_with_function_call() { + _test_transformation( + "(result := get_value())", + "assign(context, source, (1, 22), 'result', call(context, source, (11, 22), variable(context, source, (11, 20), 'get_value')))", + vec![("get_value", 11, 20, (1, 12))], + vec![("result", 1, 7, (1, 2))], + ); + } + + #[test] + fn test_transform_walrus_with_attribute_access() { + _test_transformation( + "(x := obj.value)", + "assign(context, source, (1, 15), 'x', attribute(context, source, (6, 15), variable(context, source, (6, 9), 'obj'), 'value'))", + vec![("obj", 6, 9, (1, 7))], + vec![("x", 1, 2, (1, 2))], + ); + } + + #[test] + fn test_transform_walrus_in_if_expression() { + _test_transformation( + "(x := get_value()) if (x := get_value()) else 0", + "assign(context, source, (1, 17), 'x', call(context, source, (6, 17), variable(context, source, (6, 15), 'get_value'))) if assign(context, source, (23, 39), 'x', call(context, source, (28, 39), variable(context, source, (28, 37), 'get_value'))) else 0", + vec![("get_value", 6, 15, (1, 7)), ("get_value", 28, 37, (1, 29))], + vec![("x", 1, 2, (1, 2)), ("x", 23, 24, (1, 24))], + ); + } + + #[test] + fn test_transform_walrus_in_comprehension() { + _test_transformation( + "[y for x in items if (y := x.value)]", + "[variable(context, source, (1, 2), 'y') for x in variable(context, source, (12, 17), 'items') if assign(context, source, (22, 34), 'y', attribute(context, source, (27, 34), x, 'value'))]", + vec![("y", 1, 2, (1, 2)), ("items", 12, 17, (1, 13))], + vec![("y", 22, 23, (1, 23))], + ); + } + + #[test] + fn test_transform_walrus_chained() { + _test_transformation( + "(x := (y := 5))", + "assign(context, source, (1, 14), 'x', assign(context, source, (7, 13), 'y', 5))", + vec![], + vec![("x", 1, 2, (1, 2)), ("y", 7, 8, (1, 8))], + ); + } + + #[test] + fn test_transform_walrus_in_function_call() { + _test_transformation( + "foo(x := get_value())", + "call(context, source, (0, 21), variable(context, source, (0, 3), 'foo'), assign(context, source, (4, 20), 'x', call(context, source, (9, 20), variable(context, source, (9, 18), 'get_value'))))", + vec![("foo", 0, 3, (1, 1)), ("get_value", 9, 18, (1, 10))], + vec![("x", 4, 5, (1, 5))], + ); + } + + #[test] + fn test_transform_walrus_remains_accessible_after_scope() { + // NOTE: Previously, when a variable was assigned with walrus op, + // then we didn't have to call `variable(...)` to access it later. + // But that was not the right approach, because the assignment doesn't happen + // in the expression's scope, but inside `assign(...)` call. + // So even after `assign()`, we now expect to see `variable(...)`. + _test_transformation( + "foo([(a := i) for i in items], a)", + "call(context, source, (0, 33), variable(context, source, (0, 3), 'foo'), [assign(context, source, (6, 12), 'a', i) for i in variable(context, source, (23, 28), 'items')], variable(context, source, (31, 32), 'a'))", + vec![ + ("foo", 0, 3, (1, 1)), + ("items", 23, 28, (1, 24)), + ("a", 31, 32, (1, 32)), + ], + vec![("a", 6, 7, (1, 7))], + ); + } + + #[test] + fn test_transform_walrus_multiple_assignments() { + // NOTE: Previously, when a variable was assigned with walrus op, + // then we didn't have to call `variable(...)` to access it later. + // But that was not the right approach, because the assignment doesn't happen + // in the expression's scope, but inside `assign(...)` call. + // So even after `assign()`, we now expect to see `variable(...)`. + _test_transformation( + "[(x := i, y := i*2) for i in items] and x + y", + "[(assign(context, source, (2, 8), 'x', i), assign(context, source, (10, 18), 'y', i * 2)) for i in variable(context, source, (29, 34), 'items')] and variable(context, source, (40, 41), 'x') + variable(context, source, (44, 45), 'y')", + vec![ + ("items", 29, 34, (1, 30)), + ("x", 40, 41, (1, 41)), + ("y", 44, 45, (1, 45)), + ], + vec![("x", 2, 3, (1, 3)), ("y", 10, 11, (1, 11))], + ); + } + + #[test] + fn test_transform_walrus_sequential_usage() { + _test_transformation( + "(x := 5) + x", + "assign(context, source, (1, 7), 'x', 5) + variable(context, source, (11, 12), 'x')", + vec![("x", 11, 12, (1, 12))], + vec![("x", 1, 2, (1, 2))], + ); + } + + #[test] + fn test_transform_walrus_before_comprehension() { + _test_transformation( + "(limit := 10) and [x for x in items if x < limit]", + "assign(context, source, (1, 12), 'limit', 10) and [x for x in variable(context, source, (30, 35), 'items') if x < variable(context, source, (43, 48), 'limit')]", + vec![("items", 30, 35, (1, 31)), ("limit", 43, 48, (1, 44))], + vec![("limit", 1, 6, (1, 2))], + ); + } + + #[test] + fn test_transform_walrus_nested_scopes() { + // 'a' is assigned in inner comprehension, should be accessible in outer comprehension and after + // 'b' is assigned in outer comprehension, should be accessible after + // Both 'a' and 'b' at the end should NOT be transformed + // Breakdown in order of execution: + // - `(a := i)` + // - `i` known from comp + // - defines `a` + // - `(b := a + 1)` + // - `a` known from walrus + // - defines `b` + // - Left side `y + x + a + b + i` + // - `a`, `b` known from walrus + // - `x` known from comp + // - `y` unknown + // - `i` unknown (NOT in this scope). If `i` was defined then inner comp would be leaky. + // - Right side `y + x + a + b + i` + // - `a`, `b` known from walrus + // - `y` unknown + // - `i`, `x` unknown (NOT in this scope). If `i`, `x` were defined then inner comps would be leaky. + _test_transformation( + "[y + x + a + b + i for x in [(a := i) for i in items] if (b := a + 1)] and y + x + a + b + i", + "[variable(context, source, (1, 2), 'y') + x + variable(context, source, (9, 10), 'a') + variable(context, source, (13, 14), 'b') + variable(context, source, (17, 18), 'i') for x in [assign(context, source, (30, 36), 'a', i) for i in variable(context, source, (47, 52), 'items')] if assign(context, source, (58, 68), 'b', variable(context, source, (63, 64), 'a') + 1)] and variable(context, source, (75, 76), 'y') + variable(context, source, (79, 80), 'x') + variable(context, source, (83, 84), 'a') + variable(context, source, (87, 88), 'b') + variable(context, source, (91, 92), 'i')", + vec![ + ("y", 1, 2, (1, 2)), + ("a", 9, 10, (1, 10)), + ("b", 13, 14, (1, 14)), + ("i", 17, 18, (1, 18)), + ("items", 47, 52, (1, 48)), + ("a", 63, 64, (1, 64)), + ("y", 75, 76, (1, 76)), + ("x", 79, 80, (1, 80)), + ("a", 83, 84, (1, 84)), + ("b", 87, 88, (1, 88)), + ("i", 91, 92, (1, 92)), + ], + vec![("a", 30, 31, (1, 31)), ("b", 58, 59, (1, 59))], + ); + } + + #[test] + fn test_transform_walrus_overwriting_loop_argument_syntax_error() { + // Attempting to rebind comprehension iteration variable should raise SyntaxError + // [(n := n + 1) + n for n in [1, 2, 3]] # raises SyntaxError: assignment expression cannot rebind comprehension iteration variable 'n' + _test_forbidden_syntax("[(n := n + 1) + n for n in items]"); + } + + #[test] + fn test_tranform_walrus_overwriting_function_argument_allowed() { + // Overwriting function argument IS allowed in Python. + // (lambda x: (x := 3) and x**2)(0) # returns 9 + // However, for consistency with comprehensions, we disallow it here. + _test_forbidden_syntax("(lambda x: (x := 3) and x**2)"); + } + + #[test] + fn test_transform_walrus_in_lambda_leaks() { + // In Python, the walrus assignment inside lambda is scoped to that function, + // and will NOT be available to outer scope. + // However, we allow that so that in templates one can assign variables to the context + // even from inside callbacks, e.g. + // `fn_with_callback(on_done=lambda res: (data := res)) }}` + _test_transformation( + "(lambda: (y := 42) and y)", + r#"lambda: assign(context, source, (10, 17), 'y', 42) and variable(context, source, (23, 24), 'y')"#, + vec![("y", 23, 24, (1, 24))], + vec![("y", 10, 11, (1, 11))], + ); + } + + #[test] + fn test_transform_walrus_in_lambda_in_comprehension() { + // In Python, the walrus assignment inside lambda is scoped to that function, + // and will NOT be available to outer scope. + // However, we allow that so that in templates one can assign variables to the context + // even from inside callbacks, e.g. + // `fn_with_callback(on_done=lambda res: (data := res)) }}` + _test_transformation( + "[lambda: (temp := item * 2) for item in items] and temp + item", + r#"[lambda: assign(context, source, (10, 26), 'temp', item * 2) for item in variable(context, source, (40, 45), 'items')] and variable(context, source, (51, 55), 'temp') + variable(context, source, (58, 62), 'item')"#, + vec![ + ("items", 40, 45, (1, 41)), + ("temp", 51, 55, (1, 52)), + ("item", 58, 62, (1, 59)), + ], + vec![("temp", 10, 14, (1, 11))], + ); + } + + #[test] + fn test_transform_walrus_in_comprehension_condition_in_lambda() { + // In Python, the walrus assignment inside lambda is scoped to that function, + // and will NOT be available to outer scope. + // However, we allow that so that in templates one can assign variables to the context + // even from inside callbacks, e.g. + // `fn_with_callback(on_done=lambda res: (data := res)) }}` + // Note that the `+ [y]` at the end is STILL part of lambda and should NOT be transformed. + _test_transformation( + "lambda: [y for x in items if (y := x * 2)] + [y]", + r#"lambda: [variable(context, source, (9, 10), 'y') for x in variable(context, source, (20, 25), 'items') if assign(context, source, (30, 40), 'y', x * 2)] + [variable(context, source, (46, 47), 'y')]"#, + vec![ + ("y", 9, 10, (1, 10)), + ("items", 20, 25, (1, 21)), + ("y", 46, 47, (1, 47)), + ], + vec![("y", 30, 31, (1, 31))], + ); + } + + #[test] + fn test_transform_walrus_in_comprehension_condition_in_lambda_2() { + // In Python, the walrus assignment inside lambda is scoped to that function, + // and will NOT be available to outer scope. + // However, we allow that so that in templates one can assign variables to the context + // even from inside callbacks, e.g. + // `fn_with_callback(on_done=lambda res: (data := res)) }}` + // Note that the `+ [y]` at the end is STILL part of lambda and should NOT be transformed. + _test_transformation( + "(lambda: [y for x in items if (y := x * 2)] + [y])", + r#"lambda: [variable(context, source, (10, 11), 'y') for x in variable(context, source, (21, 26), 'items') if assign(context, source, (31, 41), 'y', x * 2)] + [variable(context, source, (47, 48), 'y')]"#, + vec![ + ("y", 10, 11, (1, 11)), + ("items", 21, 26, (1, 22)), + ("y", 47, 48, (1, 48)), + ], + vec![("y", 31, 32, (1, 32))], + ); + } + + #[test] + fn test_transform_walrus_in_comprehension_condition_in_lambda_3() { + // In Python, the walrus assignment inside lambda is scoped to that function, + // and will NOT be available to outer scope. + // However, we allow that so that in templates one can assign variables to the context + // even from inside callbacks, e.g. + // `fn_with_callback(on_done=lambda res: (data := res)) }}` + _test_transformation( + "(lambda: [y for x in items if (y := x * 2)]) + y", + r#"(lambda: [variable(context, source, (10, 11), 'y') for x in variable(context, source, (21, 26), 'items') if assign(context, source, (31, 41), 'y', x * 2)]) + variable(context, source, (47, 48), 'y')"#, + vec![ + ("y", 10, 11, (1, 11)), + ("items", 21, 26, (1, 22)), + ("y", 47, 48, (1, 48)), + ], + vec![("y", 31, 32, (1, 32))], + ); + } + + #[test] + fn test_walrus_conflict_with_comprehension_variable() { + // [(n := n + 1) + n for n in [1, 2, 3]] + // Raises `SyntaxError: assignment expression cannot rebind comprehension iteration variable 'n'` + _test_forbidden_syntax("[(n := n + 1) + n for n in items]"); + } + + #[test] + fn test_walrus_conflict_with_nested_comprehension_variable() { + // Test that even outer comprehension variables cannot be rebound + // [[(x := x + 1) for y in inner] for x in outer] # should raise SyntaxError + _test_forbidden_syntax("[[(x := x + 1) for y in inner] for x in outer]"); + } + + #[test] + fn test_walrus_conflict_with_lambda_parameter() { + // (lambda x: (x := 3) and x**2)(0) + // Raises `SyntaxError: assignment expression cannot rebind lambda parameter 'x'` + _test_forbidden_syntax("(lambda x: (x := 3) and x**2)"); + } + + #[test] + fn test_walrus_conflict_with_nested_lambda_outer_parameter() { + // Test that outer lambda parameters cannot be rebound + // (lambda x: lambda y: (x := 3))(0) # should raise SyntaxError for x + _test_forbidden_syntax("(lambda x: lambda y: (x := 3))"); + } + + #[test] + fn test_walrus_conflict_with_nested_lambda_inner_parameter() { + // Test that inner lambda parameters cannot be rebound + // (lambda x: lambda y: (y := 3))(0) # should raise SyntaxError for y + _test_forbidden_syntax("(lambda x: lambda y: (y := 3))"); + } + + // === F-STRING AND T-STRING TESTS === + + #[test] + fn test_transform_fstring_simple() { + // f-strings are transformed to format() function calls + // The format() call itself is NOT transformed (it's a safe built-in) + // Only the arguments (interpolated expressions) are transformed + _test_transformation( + "f'Hello {name}'", + "format(context, source, (0, 15), 'Hello {}', (variable(context, source, (9, 13), 'name'), None, ''))", + vec![("name", 9, 13, (1, 10))], + vec![], + ); + } + + #[test] + fn test_transform_fstring_with_expression() { + _test_transformation( + "f'Result: {x + 1}'", + "format(context, source, (0, 18), 'Result: {}', (variable(context, source, (11, 12), 'x') + 1, None, ''))", + vec![("x", 11, 12, (1, 12))], + vec![], + ); + } + + #[test] + fn test_transform_fstring_with_function_call() { + _test_transformation( + "f'Value: {get_value()}'", + "format(context, source, (0, 23), 'Value: {}', (call(context, source, (10, 21), variable(context, source, (10, 19), 'get_value')), None, ''))", + vec![("get_value", 10, 19, (1, 11))], + vec![], + ); + } + + #[test] + fn test_transform_fstring_with_attribute() { + _test_transformation( + "f'Name: {obj.name}'", + "format(context, source, (0, 19), 'Name: {}', (attribute(context, source, (9, 17), variable(context, source, (9, 12), 'obj'), 'name'), None, ''))", + vec![("obj", 9, 12, (1, 10))], + vec![], + ); + } + + #[test] + fn test_transform_fstring_multiple_interpolations() { + _test_transformation( + "f'{x} and {y}'", + "format(context, source, (0, 14), '{} and {}', (variable(context, source, (3, 4), 'x'), None, ''), (variable(context, source, (11, 12), 'y'), None, ''))", + vec![("x", 3, 4, (1, 4)), ("y", 11, 12, (1, 12))], + vec![], + ); + } + + #[test] + fn test_transform_fstring_nested_expression() { + _test_transformation( + "f'start {obj.method(x, y)} end'", + "format(context, source, (0, 31), 'start {} end', (call(context, source, (9, 25), attribute(context, source, (9, 19), variable(context, source, (9, 12), 'obj'), 'method'), variable(context, source, (20, 21), 'x'), variable(context, source, (23, 24), 'y')), None, ''))", + vec![ + ("obj", 9, 12, (1, 10)), + ("x", 20, 21, (1, 21)), + ("y", 23, 24, (1, 24)), + ], + vec![], + ); + } + + #[test] + fn test_transform_fstring_with_format_spec() { + // Format spec is applied using format(value, ".2f") + _test_transformation( + "f'start {value:.2f} end'", + "format(context, source, (0, 24), 'start {} end', (variable(context, source, (9, 14), 'value'), None, '.2f'))", + vec![("value", 9, 14, (1, 10))], + vec![], + ); + } + + #[test] + fn test_transform_fstring_with_format_spec_alignment() { + _test_transformation( + "f'start {name:>10} end'", + "format(context, source, (0, 23), 'start {} end', (variable(context, source, (9, 13), 'name'), None, '>10'))", + vec![("name", 9, 13, (1, 10))], + vec![], + ); + } + + #[test] + fn test_transform_fstring_with_conversion() { + // Conversion !r is applied using repr() + _test_transformation( + "f'start {value!r} end'", + "format(context, source, (0, 22), 'start {} end', (variable(context, source, (9, 14), 'value'), 'r', ''))", + vec![("value", 9, 14, (1, 10))], + vec![], + ); + } + + #[test] + fn test_transform_fstring_with_conversion_str() { + _test_transformation( + "f'start {value!s} end'", + "format(context, source, (0, 22), 'start {} end', (variable(context, source, (9, 14), 'value'), 's', ''))", + vec![("value", 9, 14, (1, 10))], + vec![], + ); + } + + #[test] + fn test_transform_fstring_with_conversion_and_format() { + // Both conversion and format spec are applied + _test_transformation( + "f'start {value!r:>20} end'", + "format(context, source, (0, 26), 'start {} end', (variable(context, source, (9, 14), 'value'), 'r', '>20'))", + vec![("value", 9, 14, (1, 10))], + vec![], + ); + } + + #[test] + fn test_transform_fstring_with_dynamic_format_spec() { + // Dynamic format spec: the format spec itself is built using .format() + _test_transformation( + "f'start {value:{width}.{precision}f} end'", + "format(context, source, (0, 41), 'start {} end', (variable(context, source, (9, 14), 'value'), None, ('{}.{}f', variable(context, source, (16, 21), 'width'), variable(context, source, (24, 33), 'precision'))))", + vec![ + ("value", 9, 14, (1, 10)), + ("width", 16, 21, (1, 17)), + ("precision", 24, 33, (1, 25)), + ], + vec![], + ); + } + + #[test] + fn test_transform_percent_formatting() { + _test_transformation( + "'text %s' % var", + "'text %s' % variable(context, source, (12, 15), 'var')", + vec![("var", 12, 15, (1, 13))], + vec![], + ); + } + + #[test] + fn test_transform_percent_formatting_tuple() { + _test_transformation( + "'%s and %s' % (x, y)", + "'%s and %s' % (variable(context, source, (15, 16), 'x'), variable(context, source, (18, 19), 'y'))", + vec![("x", 15, 16, (1, 16)), ("y", 18, 19, (1, 19))], + vec![], + ); + } + + // === T-STRING TESTS === + + #[test] + fn test_transform_tstring_simple() { + // t-strings become template() function calls + _test_transformation( + "t'Hello {name}'", + "template(context, source, (0, 15), 'Hello ', interpolation(context, source, (8, 14), variable(context, source, (9, 13), 'name'), '', None, ''))", + vec![("name", 9, 13, (1, 10))], + vec![], + ); + } + + #[test] + fn test_transform_tstring_with_expression() { + _test_transformation( + "t'Result: {x + 1}'", + "template(context, source, (0, 18), 'Result: ', interpolation(context, source, (10, 17), variable(context, source, (11, 12), 'x') + 1, '', None, ''))", + vec![("x", 11, 12, (1, 12))], + vec![], + ); + } + + #[test] + fn test_transform_tstring_multiple_interpolations() { + _test_transformation( + "t'{x} and {y}'", + "template(context, source, (0, 14), interpolation(context, source, (2, 5), variable(context, source, (3, 4), 'x'), '', None, ''), ' and ', interpolation(context, source, (10, 13), variable(context, source, (11, 12), 'y'), '', None, ''))", + vec![("x", 3, 4, (1, 4)), ("y", 11, 12, (1, 12))], + vec![], + ); + } + + #[test] + fn test_transform_tstring_with_format_spec() { + // Format spec is stored in the interpolation object + _test_transformation( + "t'start {value:.2f} end'", + "template(context, source, (0, 24), 'start ', interpolation(context, source, (8, 19), variable(context, source, (9, 14), 'value'), '', None, '.2f'), ' end')", + vec![("value", 9, 14, (1, 10))], + vec![], + ); + } + + #[test] + fn test_transform_tstring_with_conversion() { + // Conversion flag is stored in the interpolation object + _test_transformation( + "t'start {value!r} end'", + "template(context, source, (0, 22), 'start ', interpolation(context, source, (8, 17), variable(context, source, (9, 14), 'value'), '', 'r', ''), ' end')", + vec![("value", 9, 14, (1, 10))], + vec![], + ); + } + + #[test] + fn test_transform_tstring_with_conversion_and_format() { + // Both conversion and format spec are stored in the interpolation object + _test_transformation( + "t'start {value!r:>20} end'", + "template(context, source, (0, 26), 'start ', interpolation(context, source, (8, 21), variable(context, source, (9, 14), 'value'), '', 'r', '>20'), ' end')", + vec![("value", 9, 14, (1, 10))], + vec![], + ); + } + + #[test] + fn test_transform_tstring_with_dynamic_format_spec() { + // Dynamic format spec: the format spec itself is built using .format() + _test_transformation( + "t'start {value:{width}.{precision}f} end'", + "template(context, source, (0, 41), 'start ', interpolation(context, source, (8, 36), variable(context, source, (9, 14), 'value'), '', None, '{}.{}f'.format(variable(context, source, (16, 21), 'width'), variable(context, source, (24, 33), 'precision'))), ' end')", + vec![ + ("value", 9, 14, (1, 10)), + ("width", 16, 21, (1, 17)), + ("precision", 24, 33, (1, 25)), + ], + vec![], + ); + } + + // === VARIABLE/IDENTIFIER TESTS === + + #[test] + fn test_allow_simple_variable() { + _test_transformation( + "x", + "variable(context, source, (0, 1), 'x')", + vec![("x", 0, 1, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_variable_in_binary_operation() { + _test_transformation( + "x + y", + "variable(context, source, (0, 1), 'x') + variable(context, source, (4, 5), 'y')", + vec![("x", 0, 1, (1, 1)), ("y", 4, 5, (1, 5))], + vec![], + ); + } + + #[test] + fn test_transform_variable_in_comparison() { + _test_transformation( + "x > 5", + "variable(context, source, (0, 1), 'x') > 5", + vec![("x", 0, 1, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_variable_in_boolean_operation() { + _test_transformation( + "x and y", + "variable(context, source, (0, 1), 'x') and variable(context, source, (6, 7), 'y')", + vec![("x", 0, 1, (1, 1)), ("y", 6, 7, (1, 7))], + vec![], + ); + } + + #[test] + fn test_transform_variable_in_list() { + _test_transformation( + "[x, y, z]", + "[variable(context, source, (1, 2), 'x'), variable(context, source, (4, 5), 'y'), variable(context, source, (7, 8), 'z')]", + vec![ + ("x", 1, 2, (1, 2)), + ("y", 4, 5, (1, 5)), + ("z", 7, 8, (1, 8)), + ], + vec![], + ); + } + + #[test] + fn test_transform_variable_in_dict() { + _test_transformation( + "{'key': x}", + "{'key': variable(context, source, (8, 9), 'x')}", + vec![("x", 8, 9, (1, 9))], + vec![], + ); + } + + #[test] + fn test_transform_variable_in_unary_operation() { + _test_transformation( + "-x", + "-variable(context, source, (1, 2), 'x')", + vec![("x", 1, 2, (1, 2))], + vec![], + ); + } + + #[test] + fn test_transform_variable_in_complex_expression() { + _test_transformation( + "x + y * z > 10", + "variable(context, source, (0, 1), 'x') + variable(context, source, (4, 5), 'y') * variable(context, source, (8, 9), 'z') > 10", + vec![ + ("x", 0, 1, (1, 1)), + ("y", 4, 5, (1, 5)), + ("z", 8, 9, (1, 9)), + ], + vec![], + ); + } + + #[test] + fn test_transform_variable_names_with_underscores() { + _test_transformation( + "my_variable", + "variable(context, source, (0, 11), 'my_variable')", + vec![("my_variable", 0, 11, (1, 1))], + vec![], + ); + } + + #[test] + fn test_transform_variable_names_with_numbers() { + _test_transformation( + "var123", + "variable(context, source, (0, 6), 'var123')", + vec![("var123", 0, 6, (1, 1))], + vec![], + ); + } + + // === LAMBDA TESTS === + + #[test] + fn test_lambda_simple() { + _test_transformation( + "lambda x: x + 1 + y", + "lambda x: x + 1 + variable(context, source, (18, 19), 'y')", + vec![("y", 18, 19, (1, 19))], + vec![], + ); + } + + #[test] + fn test_lambda_no_params() { + _test_transformation( + "lambda: 42 + y", + "lambda: 42 + variable(context, source, (13, 14), 'y')", + vec![("y", 13, 14, (1, 14))], + vec![], + ); + } + + #[test] + fn test_lambda_multiple_params() { + _test_transformation( + "lambda x, y: x + y + z", + "lambda x, y: x + y + variable(context, source, (21, 22), 'z')", + vec![("z", 21, 22, (1, 22))], + vec![], + ); + } + + #[test] + fn test_lambda_with_defaults() { + _test_transformation( + "lambda x=1, y=c: x + y + c", + r#"lambda x=1, y=variable(context, source, (14, 15), 'c'): x + y + variable(context, source, (25, 26), 'c')"#, + vec![("c", 14, 15, (1, 15)), ("c", 25, 26, (1, 26))], + vec![], + ); + } + + #[test] + fn test_lambda_with_posonly_defaults() { + // Positional-only parameters with defaults (Python 3.8+) + // The '/' separator marks parameters before it as positional-only + _test_transformation( + "lambda x=a, y=b, /: x + y + a + b", + r#"lambda x=variable(context, source, (9, 10), 'a'), y=variable(context, source, (14, 15), 'b'), /: x + y + variable(context, source, (28, 29), 'a') + variable(context, source, (32, 33), 'b')"#, + vec![ + ("a", 9, 10, (1, 10)), + ("b", 14, 15, (1, 15)), + ("a", 28, 29, (1, 29)), + ("b", 32, 33, (1, 33)), + ], + vec![], + ); + } + + #[test] + fn test_lambda_with_kwonly_defaults() { + // Keyword-only parameters with defaults + // The '*' separator marks parameters after it as keyword-only + _test_transformation( + "lambda *, x=a, y=b: x + y + a + b", + r#"lambda *, x=variable(context, source, (12, 13), 'a'), y=variable(context, source, (17, 18), 'b'): x + y + variable(context, source, (28, 29), 'a') + variable(context, source, (32, 33), 'b')"#, + vec![ + ("a", 12, 13, (1, 13)), + ("b", 17, 18, (1, 18)), + ("a", 28, 29, (1, 29)), + ("b", 32, 33, (1, 33)), + ], + vec![], + ); + } + + #[test] + fn test_lambda_with_mixed_params_and_defaults() { + // Mix of positional-only, regular, and keyword-only with various defaults + // Note: After a parameter with a default, all following positional params must have defaults too + _test_transformation( + "lambda a, /, b, c=y, *args, d, e=z, **kwargs: a + b + c + d + e + args + kwargs + y + z", + r#"lambda a, /, b, c=variable(context, source, (18, 19), 'y'), *args, d, e=variable(context, source, (33, 34), 'z'), **kwargs: a + b + c + d + e + args + kwargs + variable(context, source, (82, 83), 'y') + variable(context, source, (86, 87), 'z')"#, + vec![ + ("y", 18, 19, (1, 19)), + ("z", 33, 34, (1, 34)), + ("y", 82, 83, (1, 83)), + ("z", 86, 87, (1, 87)), + ], + vec![], + ); + } + + #[test] + fn test_lambda_with_varargs() { + _test_transformation( + "lambda *args: len(args)", + "lambda *args: call(context, source, (14, 23), variable(context, source, (14, 17), 'len'), args)", + vec![("len", 14, 17, (1, 15))], + vec![], + ); + } + + #[test] + fn test_lambda_with_kwargs() { + _test_transformation( + "lambda **kwargs: len(kwargs)", + "lambda **kwargs: call(context, source, (17, 28), variable(context, source, (17, 20), 'len'), kwargs)", + vec![("len", 17, 20, (1, 18))], + vec![], + ); + } + + #[test] + fn test_lambda_with_external_variable() { + // Lambda parameter 'x' should NOT be transformed + // but external variable 'items' SHOULD be transformed + _test_transformation( + "lambda x: x in items", + r#"lambda x: x in variable(context, source, (15, 20), 'items')"#, + vec![("items", 15, 20, (1, 16))], + vec![], + ); + } + + #[test] + fn test_nested_lambda() { + // Both 'x' and 'y' should NOT be transformed as they are lambda parameters + // but 'z' SHOULD be transformed as it's an external variable + _test_transformation( + "lambda x: lambda y: x + y + z", + r#"lambda x: lambda y: x + y + variable(context, source, (28, 29), 'z')"#, + vec![("z", 28, 29, (1, 29))], + vec![], + ); + } + + #[test] + fn test_lambda_in_comprehension() { + // Lambda used within a list comprehension + // The comprehension variable 'item' should not be transformed + // The lambda parameter 'x' should not be transformed + // External variable 'n' should be transformed in both contexts + _test_transformation( + "[lambda x: x * item * n for item in items]", + r#"[lambda x: x * item * variable(context, source, (22, 23), 'n') for item in variable(context, source, (36, 41), 'items')]"#, + vec![("n", 22, 23, (1, 23)), ("items", 36, 41, (1, 37))], + vec![], + ); + } + + #[test] + fn test_comprehension_in_lambda() { + // List comprehension used within a lambda body + // Lambda parameter 'n' should not be transformed + // Comprehension variable 'i' should not be transformed + // Both should be accessible in their respective scopes + _test_transformation( + "lambda n: [i * n * c for i in items]", + r#"lambda n: [i * n * variable(context, source, (19, 20), 'c') for i in variable(context, source, (30, 35), 'items')]"#, + vec![("c", 19, 20, (1, 20)), ("items", 30, 35, (1, 31))], + vec![], + ); + } + + #[test] + fn test_nested_lambda_and_comprehension() { + // Complex nesting: lambda -> comprehension -> lambda + // 'x' (outer lambda param) should not be transformed + // 'item' (comprehension var) should not be transformed + // 'y' (inner lambda param) should not be transformed + // 'c' (external var) should be transformed + _test_transformation( + "lambda x: [lambda y: x + y + item + c for item in items]", + r#"lambda x: [lambda y: x + y + item + variable(context, source, (36, 37), 'c') for item in variable(context, source, (50, 55), 'items')]"#, + vec![("c", 36, 37, (1, 37)), ("items", 50, 55, (1, 51))], + vec![], + ); + } + + #[test] + fn test_comprehension_with_lambda_filter() { + // Comprehension using a lambda in the filter condition + _test_transformation( + "lambda: [item for item in items if (lambda x: x > threshold)(item)]", + r#"lambda: [item for item in variable(context, source, (26, 31), 'items') if call(context, source, (35, 66), lambda x: x > variable(context, source, (50, 59), 'threshold'), item)]"#, + vec![("items", 26, 31, (1, 27)), ("threshold", 50, 59, (1, 51))], + vec![], + ); + } + + // === TERNARY IF EXPRESSION TESTS === + + #[test] + fn test_ternary_if_simple() { + _test_transformation( + "x + 1 if condition else y + 2", + r#"variable(context, source, (0, 1), 'x') + 1 if variable(context, source, (9, 18), 'condition') else variable(context, source, (24, 25), 'y') + 2"#, + vec![ + ("x", 0, 1, (1, 1)), + ("condition", 9, 18, (1, 10)), + ("y", 24, 25, (1, 25)), + ], + vec![], + ); + } + + #[test] + fn test_ternary_if_nested() { + // Nested ternary expressions + _test_transformation( + "a if x else b if y else c", + r#"variable(context, source, (0, 1), 'a') if variable(context, source, (5, 6), 'x') else variable(context, source, (12, 13), 'b') if variable(context, source, (17, 18), 'y') else variable(context, source, (24, 25), 'c')"#, + vec![ + ("a", 0, 1, (1, 1)), + ("x", 5, 6, (1, 6)), + ("b", 12, 13, (1, 13)), + ("y", 17, 18, (1, 18)), + ("c", 24, 25, (1, 25)), + ], + vec![], + ); + } + + #[test] + fn test_ternary_if_in_expression() { + // Ternary used within a larger expression + _test_transformation( + "result + (x if flag else y) * 2", + r#"variable(context, source, (0, 6), 'result') + (variable(context, source, (10, 11), 'x') if variable(context, source, (15, 19), 'flag') else variable(context, source, (25, 26), 'y')) * 2"#, + vec![ + ("result", 0, 6, (1, 1)), + ("x", 10, 11, (1, 11)), + ("flag", 15, 19, (1, 16)), + ("y", 25, 26, (1, 26)), + ], + vec![], + ); + } + + #[test] + fn test_ternary_if_with_function_calls() { + // Ternary with function calls + _test_transformation( + "max(a, b) if a > 0 else min(a, b)", + r#"call(context, source, (0, 9), variable(context, source, (0, 3), 'max'), variable(context, source, (4, 5), 'a'), variable(context, source, (7, 8), 'b')) if variable(context, source, (13, 14), 'a') > 0 else call(context, source, (24, 33), variable(context, source, (24, 27), 'min'), variable(context, source, (28, 29), 'a'), variable(context, source, (31, 32), 'b'))"#, + vec![ + ("max", 0, 3, (1, 1)), + ("a", 4, 5, (1, 5)), + ("b", 7, 8, (1, 8)), + ("a", 13, 14, (1, 14)), + ("min", 24, 27, (1, 25)), + ("a", 28, 29, (1, 29)), + ("b", 31, 32, (1, 32)), + ], + vec![], + ); + } + + #[test] + fn test_ternary_if_in_comprehension() { + // Ternary used in list comprehension + _test_transformation( + "[x if x > 0 else 0 for x in items]", + r#"[x if x > 0 else 0 for x in variable(context, source, (28, 33), 'items')]"#, + vec![("items", 28, 33, (1, 29))], + vec![], + ); + } + + #[test] + fn test_ternary_if_in_lambda() { + // Ternary in lambda body + _test_transformation( + "lambda x: x if x > threshold else 0", + r#"lambda x: x if x > variable(context, source, (19, 28), 'threshold') else 0"#, + vec![("threshold", 19, 28, (1, 20))], + vec![], + ); + } + + #[test] + fn test_ternary_if_with_walrus() { + // Ternary with walrus operator in condition + _test_transformation( + "x if (y := compute()) else default", + r#"variable(context, source, (0, 1), 'x') if assign(context, source, (6, 20), 'y', call(context, source, (11, 20), variable(context, source, (11, 18), 'compute'))) else variable(context, source, (27, 34), 'default')"#, + vec![ + ("x", 0, 1, (1, 1)), + ("compute", 11, 18, (1, 12)), + ("default", 27, 34, (1, 28)), + ], + vec![("y", 6, 7, (1, 7))], + ); + } + + #[test] + fn test_ternary_if_with_walrus_order() { + // condition branch runs first, so y in either branch should be already known + _test_transformation( + "y if (y := compute()) else y", + r#"variable(context, source, (0, 1), 'y') if assign(context, source, (6, 20), 'y', call(context, source, (11, 20), variable(context, source, (11, 18), 'compute'))) else variable(context, source, (27, 28), 'y')"#, + vec![ + ("y", 0, 1, (1, 1)), + ("compute", 11, 18, (1, 12)), + ("y", 27, 28, (1, 28)), + ], + vec![("y", 6, 7, (1, 7))], + ); + } + + #[test] + fn test_ternary_if_with_attribute_access() { + // Ternary with attribute access + _test_transformation( + "obj.value if obj.is_valid else obj.default", + r#"attribute(context, source, (0, 9), variable(context, source, (0, 3), 'obj'), 'value') if attribute(context, source, (13, 25), variable(context, source, (13, 16), 'obj'), 'is_valid') else attribute(context, source, (31, 42), variable(context, source, (31, 34), 'obj'), 'default')"#, + vec![ + ("obj", 0, 3, (1, 1)), + ("obj", 13, 16, (1, 14)), + ("obj", 31, 34, (1, 32)), + ], + vec![], + ); + } + + #[test] + fn test_ternary_if_with_subscript() { + // Ternary with subscript access + _test_transformation( + "data[key] if key in data else None", + r#"subscript(context, source, (0, 9), variable(context, source, (0, 4), 'data'), variable(context, source, (5, 8), 'key')) if variable(context, source, (13, 16), 'key') in variable(context, source, (20, 24), 'data') else None"#, + vec![ + ("data", 0, 4, (1, 1)), + ("key", 5, 8, (1, 6)), + ("key", 13, 16, (1, 14)), + ("data", 20, 24, (1, 21)), + ], + vec![], + ); + } + + // === FORBIDDEN SYNTAX TESTS === + + #[test] + fn test_forbid_assignment() { + // NOTE: Use walrus operator := instead + _test_forbidden_syntax("x = 1"); + } + + #[test] + fn test_forbid_augmented_assignment() { + _test_forbidden_syntax("x += 1"); + } + + #[test] + fn test_forbid_annotated_assignment() { + _test_forbidden_syntax("x: int = 1"); + } + + #[test] + fn test_forbid_delete() { + _test_forbidden_syntax("del x"); + } + + #[test] + fn test_forbid_multiple_delete() { + _test_forbidden_syntax("del x, y, z"); + } + + #[test] + fn test_forbid_raise() { + _test_forbidden_syntax("raise ValueError('error')"); + } + + #[test] + fn test_forbid_raise_bare() { + _test_forbidden_syntax("raise 'Oops'"); + } + + #[test] + fn test_forbid_assert() { + _test_forbidden_syntax("assert x > 0"); + } + + #[test] + fn test_forbid_assert_with_message() { + _test_forbidden_syntax("assert x > 0, 'x must be positive'"); + } + + #[test] + fn test_forbid_pass() { + _test_forbidden_syntax("pass"); + } + + #[test] + fn test_forbid_type_alias() { + _test_forbidden_syntax("type Point = tuple[float, float]"); + } + + #[test] + fn test_forbid_for() { + _test_forbidden_syntax("for i in range(10): print(i"); + } + + #[test] + fn test_forbid_while() { + _test_forbidden_syntax("while i < 10: print(i)"); + } + + #[test] + fn test_forbid_break() { + _test_forbidden_syntax("for i in range(10): break"); + } + + #[test] + fn test_forbid_continue() { + _test_forbidden_syntax("for i in range(10): continue"); + } + + #[test] + fn test_forbid_if() { + _test_forbidden_syntax("if x > 0: print(1)"); + } + + #[test] + fn test_forbid_if_else() { + _test_forbidden_syntax("if x > 0: print(1)\nelif 2: print(2)\nelse: print(3)"); + } + + #[test] + fn test_forbid_try_except() { + _test_forbidden_syntax("try: x\nexcept: pass"); + } + + #[test] + fn test_forbid_try_except_specific() { + _test_forbidden_syntax("try: x\nexcept ValueError: pass"); + } + + #[test] + fn test_forbid_try_except_finally() { + _test_forbidden_syntax("try: x\nexcept: pass\nfinally: pass"); + } + + #[test] + fn test_forbid_except_star() { + _test_forbidden_syntax("try: x\nexcept* ValueError: pass"); + } + + #[test] + fn test_forbid_with() { + _test_forbidden_syntax("with open('f') as f: pass"); + } + + #[test] + fn test_forbid_with_multiple() { + _test_forbidden_syntax("with open('f1') as f1, open('f2') as f2: pass"); + } + + #[test] + fn test_forbid_async_with_in_async_fn() { + _test_forbidden_syntax("async def fn():\n async with x as y: pass"); + } + + #[test] + fn test_forbid_import() { + _test_forbidden_syntax("import os"); + } + + #[test] + fn test_forbid_import_from() { + _test_forbidden_syntax("from os import path"); + } + + #[test] + fn test_forbid_import_from_as() { + _test_forbidden_syntax("from os import path as p"); + } + + #[test] + fn test_forbid_class() { + _test_forbidden_syntax("class MyClass: pass"); + } + + #[test] + fn test_forbid_fn() { + _test_forbidden_syntax("def fn(): 1"); + } + + #[test] + fn test_forbid_return() { + _test_forbidden_syntax("def fn(): return 42"); + } + + #[test] + fn test_forbid_global() { + _test_forbidden_syntax("def fn(): global x"); + } + + #[test] + fn test_forbid_nonlocal() { + _test_forbidden_syntax("def fn(): nonlocal x"); + } + + #[test] + fn test_forbid_yield() { + _test_forbidden_syntax("def fn(): yield x"); + } + + #[test] + fn test_forbid_yield_from() { + _test_forbidden_syntax("def fn(): yield from x"); + } + + #[test] + fn test_forbid_decorator() { + _test_forbidden_syntax("@decorator\ndef fn(): pass"); + } + + #[test] + fn test_forbid_async_fn() { + _test_forbidden_syntax("async def fn(): await x"); + } + + #[test] + fn test_forbid_async_for() { + _test_forbidden_syntax("async for x in y"); + } + + #[test] + fn test_forbid_async_with() { + _test_forbidden_syntax("async with x as y: pass"); + } + + #[test] + fn test_forbid_match() { + _test_forbidden_syntax("match x:\n case 1: pass"); + } + + #[test] + fn test_forbid_match_singleton() { + _test_forbidden_syntax("match x:\n case None: pass\n case True: pass"); + } + + #[test] + fn test_forbid_match_sequence() { + _test_forbidden_syntax("match x:\n case [1, 2, 3]: pass"); + } + + #[test] + fn test_forbid_match_star() { + _test_forbidden_syntax("match x:\n case [1, *rest]: pass"); + } + + #[test] + fn test_forbid_match_mapping() { + _test_forbidden_syntax("match x:\n case {'key': value}: pass"); + } + + #[test] + fn test_forbid_match_class() { + _test_forbidden_syntax("match x:\n case Point(x=0, y=0): pass"); + } + + #[test] + fn test_forbid_match_as() { + _test_forbidden_syntax("match x:\n case [1, 2] as pair: pass"); + } + + #[test] + fn test_forbid_match_or() { + _test_forbidden_syntax("match x:\n case 1 | 2 | 3: pass"); + } + + #[test] + fn test_forbid_match_wildcard() { + _test_forbidden_syntax("match x:\n case _: pass"); + } + + #[test] + fn test_forbid_match_guard() { + _test_forbidden_syntax("match x:\n case n if n > 0: pass"); + } + + #[test] + fn test_forbid_typevar() { + _test_forbidden_syntax("type T = int"); + } + + #[test] + fn test_forbid_typevar_union() { + _test_forbidden_syntax("type StringOrInt = str | int"); + } + + #[test] + fn test_forbid_generic_function() { + _test_forbidden_syntax("def func[T](x: T) -> T: return x"); + } + + #[test] + fn test_forbid_paramspec() { + _test_forbidden_syntax( + "def decorator[**P, T](func: Callable[P, T]) -> Callable[P, T]: return func", + ); + } + + #[test] + fn test_forbid_typevartuple() { + _test_forbidden_syntax("def func[*Ts](*args: *Ts) -> tuple[*Ts]: return args"); + } + + // === VARIABLE IN COMPREHENSION === + + #[test] + fn test_local_variable_tracking_in_comprehensions() { + _test_transformation( + "[x for x in items]", + "[x for x in variable(context, source, (12, 17), 'items')]", + vec![("items", 12, 17, (1, 13))], + vec![], + ); + } + + #[test] + fn test_local_variable_tracking_in_nested_comprehensions() { + _test_transformation( + "[[x for x in row] for row in matrix]", + "[[x for x in row] for row in variable(context, source, (29, 35), 'matrix')]", + vec![("matrix", 29, 35, (1, 30))], + vec![], + ); + } + + #[test] + fn test_local_variable_tracking_in_multiple_comprehensions() { + _test_transformation( + "[x for x in items for y in x.children]", + "[x for x in variable(context, source, (12, 17), 'items') for y in attribute(context, source, (27, 37), x, 'children')]", + vec![("items", 12, 17, (1, 13))], + vec![], + ); + } + + #[test] + fn test_local_variable_tracking_in_comprehension_conditions() { + _test_transformation( + "[x for x in items if x > 0]", + "[x for x in variable(context, source, (12, 17), 'items') if x > 0]", + vec![("items", 12, 17, (1, 13))], + vec![], + ); + } + + #[test] + fn test_local_variable_tracking_in_multiple_comprehension_conditions() { + _test_transformation( + "[x for x in items for y in x.children if y > 0]", + "[x for x in variable(context, source, (12, 17), 'items') for y in attribute(context, source, (27, 37), x, 'children') if y > 0]", + vec![("items", 12, 17, (1, 13))], + vec![], + ); + } +} diff --git a/djc_core/__init__.py b/djc_core/__init__.py index 2dfb9ee..d8e01a5 100644 --- a/djc_core/__init__.py +++ b/djc_core/__init__.py @@ -7,3 +7,10 @@ __doc__ = djc_core.__doc__ if hasattr(djc_core, "__all__"): __all__ = djc_core.__all__ + +# OVERRIDES START HERE +# Add here any additional public API that we defined purely in Python +from djc_core.djc_safe_eval import safe_eval, unsafe, SecurityError + +if hasattr(djc_core, "__all__"): + __all__ += ["safe_eval", "unsafe", "SecurityError"] diff --git a/djc_core/__init__.pyi b/djc_core/__init__.pyi index 1774962..99b93fe 100644 --- a/djc_core/__init__.pyi +++ b/djc_core/__init__.pyi @@ -1 +1,2 @@ from djc_core.djc_html_transformer import * +from djc_core.djc_safe_eval import * diff --git a/djc_core/djc_safe_eval/__init__.py b/djc_core/djc_safe_eval/__init__.py new file mode 100644 index 0000000..6302a1c --- /dev/null +++ b/djc_core/djc_safe_eval/__init__.py @@ -0,0 +1,8 @@ +from .sandbox import unsafe +from .eval import safe_eval, SecurityError + +__all__ = [ + "safe_eval", + "SecurityError", + "unsafe", +] \ No newline at end of file diff --git a/djc_core/djc_safe_eval/__init__.pyi b/djc_core/djc_safe_eval/__init__.pyi new file mode 100644 index 0000000..2b9528a --- /dev/null +++ b/djc_core/djc_safe_eval/__init__.pyi @@ -0,0 +1,79 @@ +# ruff: noqa +from typing import Any, Callable, Dict, Optional, TypeVar + +F = TypeVar("F", bound=Callable[..., Any]) + +class SecurityError(Exception): + """An error raised when a security violation occurs.""" + + +def safe_eval( + source: str, + *, + validate_variable: Optional[Callable[[str], bool]] = None, + validate_attribute: Optional[Callable[[Any, str], bool]] = None, + validate_subscript: Optional[Callable[[Any, Any], bool]] = None, + validate_callable: Optional[Callable[[Callable], bool]] = None, + validate_assign: Optional[Callable[[str, Any], bool]] = None, +) -> Callable[[Dict[str, Any]], Any]: + """ + Compile a Python expression string into a safe evaluation function. + + This function takes a Python expression string and transforms it into safe code + by wrapping potentially unsafe operations (like variable access, function calls, + attribute access, etc.) with sandboxed function calls. + + This is the re-implementation of Jinja's sandboxed evaluation logic. + + Args: + source: The Python expression string to transform. + validate_variable: Optional extra validation for variable lookups. + validate_attribute: Optional extra validation for attribute access. + validate_subscript: Optional extra validation for subscript access. + validate_callable: Optional extra validation for function calls. + validate_assign: Optional extra validation for variable assignments. + + Returns: + A compiled function that takes a context dictionary and evaluates the expression. + The function signature is: `func(context: Dict[str, Any]) -> Any` + + The returned function may raise SecurityError if the expression is unsafe. + + Raises: + SyntaxError: If the input is not valid Python syntax or contains forbidden constructs. + + Example: + >>> compiled = safe_eval("my_var + 1") + >>> result = compiled({"my_var": 5}) + >>> print(result) + 6 + + >>> compiled = safe_eval("lambda x: x + my_var") + >>> func = compiled({"my_var": 10}) + >>> print(func(5)) + 15 + + >>> compiled = safe_eval("unsafe_var + 1", validate_variable=lambda name: name != "unsafe_var") + >>> result = compiled({"unsafe_var": 5}) + SecurityError: variable 'unsafe_var' is unsafe + """ + + +def unsafe(f: F) -> F: + """ + Marks a function or method as unsafe. + + Example: + ```python + @unsafe + def delete(self): + pass + ``` + """ + + +__all__ = [ + "safe_eval", + "SecurityError", + "unsafe", +] diff --git a/djc_core/djc_safe_eval/error.py b/djc_core/djc_safe_eval/error.py new file mode 100644 index 0000000..96873ee --- /dev/null +++ b/djc_core/djc_safe_eval/error.py @@ -0,0 +1,155 @@ +import functools +from typing import Any, Callable, TypeVar + +T = TypeVar("T", bound=Callable) + + +def _format_error_with_context( + error: Exception, + source: str, + start_index: int, + end_index: int, + func_name: str, + add_prefix: bool = True, +) -> None: + """ + Format an error with underlined source code context. + + Modifies the exception's message to include: + - Up to 2 preceding lines + - The lines containing the error (start_index to end_index) + - Up to 2 following lines + - Underlined code with ^^^ characters + + Example: + ``` + (a := 1 + my_var) + ^^^^^^ + NameError: name 'my_var' is not defined + ``` + """ + # Convert source to lines with line numbers + lines = source.split("\n") + + # Find which lines contain the error + line_starts = [0] # Cumulative character count at start of each line + for line in lines[:-1]: + line_starts.append(line_starts[-1] + len(line) + 1) # +1 for newline + + # Find start and end line numbers (0-indexed) + start_line = 0 + for i, line_start in enumerate(line_starts): + if line_start > start_index: + start_line = max(0, i - 1) + break + else: + start_line = len(lines) - 1 + + end_line = start_line + for i, line_start in enumerate(line_starts): + if line_start > end_index: + end_line = max(0, i - 1) + break + else: + end_line = len(lines) - 1 + + # Calculate column positions within each line + def get_column(line_num: int, char_index: int) -> int: + """Get column number (0-indexed) for a character index in a specific line.""" + if line_num >= len(line_starts): + return 0 + line_start = line_starts[line_num] + return max(0, char_index - line_start) + + start_col = get_column(start_line, start_index) + end_col = get_column(end_line, end_index) + + # Collect lines to show (up to 2 before and 2 after) + show_start = max(0, start_line - 2) + show_end = min(len(lines), end_line + 3) # +3 because end is inclusive + + # Build the formatted error message + error_lines = [] + if add_prefix: + error_lines.append(f"Error in {func_name}: {type(error).__name__}: {error}") + else: + # Just use the original error message + error_lines.append(str(error)) + error_lines.append("") + + # Add source lines with line numbers + for line_num in range(show_start, show_end): + line_content = lines[line_num] + line_display_num = line_num + 1 # 1-indexed for display + + # Calculate underline range for this line + underline_start = 0 + underline_end = len(line_content) + + if line_num == start_line == end_line: + # Error spans single line + underline_start = start_col + underline_end = min(len(line_content), end_col) + elif line_num == start_line: + # Error starts on this line + underline_start = start_col + underline_end = len(line_content) + elif line_num == end_line: + # Error ends on this line + underline_start = 0 + underline_end = min(len(line_content), end_col) + elif start_line < line_num < end_line: + # Error spans this entire line + underline_start = 0 + underline_end = len(line_content) + else: + # No error on this line, don't underline + underline_start = -1 + underline_end = -1 + + # Add line with number + line_prefix = f" {line_display_num:4d} | " + error_lines.append(line_prefix + line_content) + + # Add underline if error is on this line + if underline_start >= 0: + # Create underline: prefix spaces + spaces to column + ^ characters + prefix_len = len(line_prefix) # " 4 | " = 9 characters + underline = " " * (prefix_len + underline_start) + "^" * max( + 1, underline_end - underline_start + ) + error_lines.append(underline) + + # Update exception message + error.args = ("\n".join(error_lines),) + # Mark that this error has been processed by error_context + error._safe_eval_error_processed = True # type: ignore[attr-defined] + + +def error_context(func_name: str) -> Callable[[T], T]: + """ + Decorator that wraps functions to add error context reporting. + + Extracts token tuple (start_index, end_index) from the third positional argument, + and on error, formats the error with underlined source code. + """ + + def decorator(func: T) -> T: + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + # Functions that use this decorator have signature + # (context: Mapping[str, Any], source: str, token: tuple[int, int], *args: Any, **kwargs: Any) -> Any + source = args[1] + start_index, end_index = args[2] + + try: + # On success return result normally + return func(*args, **kwargs) + except Exception as e: + # On error, modify the error message to include the source code context + _format_error_with_context(e, source, start_index, end_index, func_name) + raise + + return wrapper + + return decorator diff --git a/djc_core/djc_safe_eval/eval.py b/djc_core/djc_safe_eval/eval.py new file mode 100644 index 0000000..f59ac86 --- /dev/null +++ b/djc_core/djc_safe_eval/eval.py @@ -0,0 +1,423 @@ +import builtins +from typing import Any, Callable, Dict, Mapping, Optional, Tuple + +from djc_core.djc_core import safe_eval as safe_eval_rust +from djc_core.djc_safe_eval.error import error_context, _format_error_with_context +from djc_core.djc_safe_eval.sandbox import ( + is_safe_attribute, + is_safe_callable, + is_safe_variable, +) + + +class SecurityError(Exception): + """An error raised when a security violation occurs.""" + + pass + + +def safe_eval( + source: str, + *, + validate_variable: Optional[Callable[[str], bool]] = None, + validate_attribute: Optional[Callable[[Any, str], bool]] = None, + validate_subscript: Optional[Callable[[Any, Any], bool]] = None, + validate_callable: Optional[Callable[[Callable], bool]] = None, + validate_assign: Optional[Callable[[str, Any], bool]] = None, +) -> Callable[[Dict[str, Any]], Any]: + """ + Compile a Python expression string into a safe evaluation function. + + This function takes a Python expression string and transforms it into safe code + by wrapping potentially unsafe operations (like variable access, function calls, + attribute access, etc.) with sandboxed function calls. + + This is the re-implementation of Jinja's sandboxed evaluation logic. + + Args: + source: The Python expression string to transform. + validate_variable: Optional extra validation for variable lookups. + validate_attribute: Optional extra validation for attribute access. + validate_subscript: Optional extra validation for subscript access. + validate_callable: Optional extra validation for function calls. + validate_assign: Optional extra validation for variable assignments. + + Returns: + A compiled function that takes a context dictionary and evaluates the expression. + The function signature is: `func(context: Dict[str, Any]) -> Any` + + The returned function may raise SecurityError if the expression is unsafe. + + Raises: + SyntaxError: If the input is not valid Python syntax or contains forbidden constructs. + + Example: + >>> compiled = safe_eval("my_var + 1") + >>> result = compiled({"my_var": 5}) + >>> print(result) + 6 + + >>> compiled = safe_eval("lambda x: x + my_var") + >>> func = compiled({"my_var": 10}) + >>> print(func(5)) + 15 + + >>> compiled = safe_eval("unsafe_var + 1", validate_variable=lambda name: name != "unsafe_var") + >>> result = compiled({"unsafe_var": 5}) + SecurityError: variable 'unsafe_var' is unsafe + """ + # If user specified extra validation functions, wrap the original functions with them + if validate_variable is not None: + + @error_context("variable") + def variable_fn( + __context: Mapping[str, Any], + __source: str, + __token: Tuple[int, int], + var_name: str, + ) -> Any: + if not validate_variable(var_name): + raise SecurityError(f"variable '{var_name}' is unsafe") + return variable(__context, __source, __token, var_name) + else: + variable_fn = variable + + if validate_attribute is not None: + + @error_context("attribute") + def attribute_fn( + __context: Mapping[str, Any], + __source: str, + __token: Tuple[int, int], + obj: Any, + attr_name: str, + ) -> Any: + if not validate_attribute(obj, attr_name): + raise SecurityError( + f"attribute '{attr_name}' on object '{type(obj)}' is unsafe" + ) + return attribute(__context, __source, __token, obj, attr_name) + else: + attribute_fn = attribute + + if validate_subscript is not None: + + @error_context("subscript") + def subscript_fn( + __context: Mapping[str, Any], + __source: str, + __token: Tuple[int, int], + obj: Any, + key: Any, + ) -> Any: + if not validate_subscript(obj, key): + raise SecurityError(f"key '{key}' on object '{type(obj)}' is unsafe") + return subscript(__context, __source, __token, obj, key) + else: + subscript_fn = subscript + + if validate_callable is not None: + + @error_context("call") + def call_fn( + __context: Mapping[str, Any], + __source: str, + __token: Tuple[int, int], + func: Callable, + *args: Any, + **kwargs: Any, + ) -> Any: + if not validate_callable(func): + raise SecurityError(f"function '{func!r}' is unsafe") + return call(__context, __source, __token, func, *args, **kwargs) + else: + call_fn = call + + if validate_assign is not None: + + @error_context("assign") + def assign_fn( + __context: Mapping[str, Any], + __source: str, + __token: Tuple[int, int], + var_name: str, + value: Any, + ) -> Any: + if not validate_assign(var_name, value): + raise SecurityError(f"assignment to variable '{var_name}' is unsafe") + return assign(__context, __source, __token, var_name, value) + else: + assign_fn = assign + + # Create evaluation namespace with wrapped functions + # These are captured in the closure of the lambda function we'll create + eval_namespace = { + "variable": variable_fn, + "attribute": attribute_fn, + "subscript": subscript_fn, + "call": call_fn, + "assign": assign_fn, + "slice": slice, + "interpolation": interpolation, + "template": template, + "format": format, + # Pass through the source string. This way we won't have to re-define + # the functions on each evaluation. + "source": source, + } + + # Get transformed code from Rust + transformed_code = safe_eval_rust(source) + + # Wrap the transformed code in a lambda that captures the helper functions + # This avoids the overhead of calling eval() and creating a dict on each evaluation + lambda_code = f"lambda context: ({transformed_code})" + + try: + # Compile the code but don't execute it + compiled_code = compile(lambda_code, f"Expression <{source}>", "eval") + # Actually execute the code + eval_func = eval(compiled_code, eval_namespace, {}) + except Exception as e: + # If the error hasn't been processed by error_context decorator, + # include the whole expression in the error message (without the "Error in..." prefix) + if not getattr(e, "_safe_eval_error_processed", False): + _format_error_with_context( + e, source, 0, len(source), "expression", add_prefix=False + ) + # Mark it as processed to avoid double-formatting if re-raised + e._safe_eval_error_processed = True # type: ignore[attr-defined] + raise + + # Return a function that calls the compiled lambda directly + # This is much faster than calling eval() on each evaluation + def evaluate(context: Dict[str, Any]) -> Any: + """ + Evaluate the compiled expression with the given context. + + Args: + context: Dictionary of variables to use in evaluation. + + Returns: + The result of evaluating the expression. + """ + try: + return eval_func(context) + except Exception as e: + # If the error hasn't been processed by error_context decorator, + # include the whole expression in the error message (without the "Error in..." prefix) + if not getattr(e, "_safe_eval_error_processed", False): + _format_error_with_context( + e, source, 0, len(source), "expression", add_prefix=False + ) + # Mark it as processed to avoid double-formatting if re-raised + e._safe_eval_error_processed = True # type: ignore[attr-defined] + raise + + return evaluate + + +# Following are the operations that we intercept. These functions are called by the transformed code. +# +# E.g. +# ```python +# my_var +# obj := {"attr": 2} +# ``` +# +# is transformed into: +# ```python +# variable((0, 4), source, context, "my_var") +# assign((0, 18), source, context, "obj", {"attr": 2}) +# ``` +# +# Each interceptor function receives the same 3 first positional arguments: +# - __context: Mapping[str, Any] - The evaluation context +# - __source: str - The source code +# - __token: Tuple[int, int] - The token tuple (start_index, end_index) +# +# The __source and __token arguments are used by `@error_context` decorator to add the position +# where the error occurred to the error message. +# E.g. +# ``` +# obj := eval("unsafe code") +# ^^^^^^^^^^^^^^^^^^^ +# SecurityError: is unsafe +# ``` + + +@error_context("variable") +def variable( + __context: Mapping[str, Any], __source: str, __token: Tuple[int, int], var_name: str +) -> Any: + """Look up a variable in the evaluation context, e.g. `my_var`""" + if not is_safe_variable(var_name): + raise SecurityError(f"variable '{var_name}' is unsafe") + return __context[var_name] + + +@error_context("attribute") +def attribute( + __context: Mapping[str, Any], + __source: str, + __token: Tuple[int, int], + obj: Any, + attr_name: str, +) -> Any: + """Access an attribute of an object, e.g. `obj.attr`""" + if not is_safe_attribute(obj, attr_name): + raise SecurityError( + f"attribute '{attr_name}' on object '{type(obj)}' is unsafe" + ) + return getattr(obj, attr_name) + + +@error_context("subscript") +def subscript( + __context: Mapping[str, Any], + __source: str, + __token: Tuple[int, int], + obj: Any, + key: Any, +) -> Any: + """Access a key of an object, e.g. `obj[key]`""" + # NOTE: Right now subscript uses the same logic as attribute + if not is_safe_attribute(obj, key): + raise SecurityError(f"key '{key}' on object '{type(obj)}' is unsafe") + return obj[key] + + +# NOTE: Our internal args are prefixed with `__` to avoid keyword argument conflicts with the original input. +@error_context("call") +def call( + __context: Mapping[str, Any], + __source: str, + __token: Tuple[int, int], + func: Callable, + *args: Any, + **kwargs: Any, +) -> Any: + """Call a function, e.g. `func(arg1, arg2, ...)`""" + is_safe, replacement_message = is_safe_callable(func) + if not is_safe: + error_msg = f"function '{func!r}' is unsafe" + if replacement_message: + error_msg += f". {replacement_message}" + raise SecurityError(error_msg) + return func(*args, **kwargs) + + +@error_context("assign") +def assign( + __context: Mapping[str, Any], + __source: str, + __token: Tuple[int, int], + var_name: str, + value: Any, +) -> Any: + """Assign a value to a variable in the evaluation context, e.g. `(x := 5)`""" + if not is_safe_variable(var_name): + raise SecurityError(f"variable '{var_name}' is unsafe") + __context[var_name] = value + return value + + +# NOTE: We don't need to validate the slice arguments as they are always safe. +# Slice had to be redefined from bracket syntax `obj[lower:upper:step]` to function call syntax +# `slice(lower, upper, step)` because we convert brackets to function calls `subscript(obj, key)`. +# But since we had to intercept it, this function ensures we show the correct position in the error message. +@error_context("slice") +def slice( + __context: Mapping[str, Any], + __source: str, + __token: Tuple[int, int], + lower: Any = None, + upper: Any = None, + step: Any = None, +) -> builtins.slice: + """Create a slice object, e.g. `obj[lower:upper:step]`""" + return builtins.slice(lower, upper, step) + + +# For compatiblity with Python 3.14+: +# - on 3.14+, t-strings are created as normal +# - on >=3.13, using t-strings raises an error +# See: https://docs.python.org/3.14/library/string.templatelib.html#template-strings +@error_context("interpolation") +def interpolation( + __context: Mapping[str, Any], + __source: str, + __token: Tuple[int, int], + value: Any, + expression: str, + conversion: Optional[str], + format_spec: str, +) -> Any: + """Process t-string interpolation.""" + try: + from string.templatelib import Interpolation # type: ignore[import-untyped] + except ImportError: + raise NotImplementedError("t-string interpolation is not supported") + return Interpolation(value, expression, conversion, format_spec) + + +@error_context("template") +def template( + __context: Mapping[str, Any], __source: str, __token: Tuple[int, int], *parts: Any +) -> Any: + """Construct a template from parts.""" + try: + from string.templatelib import Template # type: ignore[import-untyped] + except ImportError: + raise NotImplementedError("t-string template construction is not supported") + return Template(*parts) + + +@error_context("format") +def format( + __context: Mapping[str, Any], + __source: str, + __token: Tuple[int, int], + template_string: str, + *args: Any, +) -> str: + """ + Format a template string with arguments. + + Each argument is a tuple (value, conversion_flag, format_spec) where: + - value: The expression value to format + - conversion_flag: "r", "s", "a", or None + - format_spec: A string for static specs, or a tuple (template, *args) for dynamic specs + + This wraps the built-in str.format() method so that errors inside f-strings get nice + error reporting with underlining via the `@error_context` decorator. + """ + processed_args = [] + for value, conversion_flag, format_spec in args: + # Apply conversion flag if present + if conversion_flag == "r": + value = repr(value) + elif conversion_flag == "s": + value = str(value) + elif conversion_flag == "a": + value = ascii(value) + # If None, keep value as-is + + # Apply format spec if present (non-empty string or tuple) + if format_spec: + if isinstance(format_spec, tuple): + # Dynamic format spec: (template, *args) + spec_template, *spec_args = format_spec + # Format the spec template with its args + format_spec_str = spec_template.format(*spec_args) + else: + # Static format spec: already a string + format_spec_str = format_spec + + # Only apply format spec if it's non-empty + if format_spec_str: + value = builtins.format(value, format_spec_str) + + processed_args.append(value) + + return template_string.format(*processed_args) diff --git a/djc_core/djc_safe_eval/sandbox.py b/djc_core/djc_safe_eval/sandbox.py new file mode 100644 index 0000000..6290edd --- /dev/null +++ b/djc_core/djc_safe_eval/sandbox.py @@ -0,0 +1,227 @@ +""" +A sandbox layer that ensures unsafe operations cannot be performed. +Useful when the Python expression itself comes from an untrusted source. + +Based on the Jinja v3.1.6 sandbox implementation. +See https://github.com/pallets/jinja/blob/5ef70112a1ff19c05324ff889dd30405b1002044/src/jinja2/sandbox.py + +We do NOT support: +- Builtins. If you need to use `len()`, `str()`, `int()`, `list()`, `dict()`, etc., + you'll have to pass them as variables. +- "safe" range. Jinja puts limit on the number of items a range may produce. + We don't expose `range()` function to the sandboxed code at all. +- "Immutable" sandbox (e.g. raising when mutating a list). +- Async functions, coroutines, etc. +- `str.format` and `str.format_map` are not allowed as they can be used to access unsafe variables. + Use f-strings instead. + +We add these safety features not present in Jinja: +- Prevent users from calling unsafe builtins like `eval` even if they were passed as variables. + +To mark custom functions as unsafe, use the `@unsafe` decorator. + +Example: +```python +@unsafe +def delete(self): + pass +``` +""" + +import builtins +import types +from typing import Any, Callable, Dict, Optional, Set, Tuple, TypeVar + +F = TypeVar("F", bound=Callable[..., Any]) + +#: Unsafe function attributes. +UNSAFE_FUNCTION_ATTRIBUTES: Set[str] = set() + +#: Unsafe method attributes. Function attributes are unsafe for methods too. +UNSAFE_METHOD_ATTRIBUTES: Set[str] = set() + +#: unsafe generator attributes. +UNSAFE_GENERATOR_ATTRIBUTES = {"gi_frame", "gi_code"} + +#: unsafe attributes on coroutines +UNSAFE_COROUTINE_ATTRIBUTES = {"cr_frame", "cr_code"} + +#: unsafe attributes on async generators +UNSAFE_ASYNC_GENERATOR_ATTRIBUTES = {"ag_code", "ag_frame"} + +# Builtin functions that users have no business calling in sandboxed code. +# We check for these as it may happen that these functons were passed as variables, +# and the user may try to call them. +# Dictionary mapping unsafe functions to replacement messages. +_UNSAFE_BUILTIN_FUNCTION_NAMES = { + "__build_class__": None, + "__import__": None, + "__loader__": None, + "aiter": None, + "anext": None, + "breakpoint": None, + "classmethod": None, + "compile": None, + "delattr": None, + "eval": None, + "exec": None, + "exit": None, + "getattr": None, + "globals": None, + "help": None, + "input": None, + "locals": None, + "memoryview": None, + "open": None, + "property": None, + "quit": None, + "setattr": None, + "staticmethod": None, + "super": None, + "vars": None, +} +UNSAFE_BUILTIN_FUNCTIONS: Dict[Any, Optional[str]] = { + getattr(builtins, attr): replacement + for attr, replacement in _UNSAFE_BUILTIN_FUNCTION_NAMES.items() + if hasattr(builtins, attr) +} + +# These are not allowed as they can be used to access unsafe variables, +# e.g. `"a{0.b.__builtins__[__import__]}b".format({"b": 42})` +# Use f-strings instead. +UNSAFE_FUNCTIONS: Dict[Any, Optional[str]] = { + str.format: "Use f-strings instead.", + str.format_map: "Use f-strings instead.", +} + + +def unsafe(f: F) -> F: + """ + Marks a function or method as unsafe. + + Example: + ```python + @unsafe + def delete(self): + pass + ``` + """ + f.unsafe_callable = True # type: ignore + return f + + +def _is_internal_attribute(obj: Any, attr: str) -> bool: + """ + Test if the attribute is an internal Python attribute. + + >>> _is_internal_attribute(str, "mro") + True + >>> _is_internal_attribute(str, "upper") + False + """ + if isinstance(obj, types.FunctionType): + if attr in UNSAFE_FUNCTION_ATTRIBUTES: + return True + elif isinstance(obj, types.MethodType): + if attr in UNSAFE_FUNCTION_ATTRIBUTES or attr in UNSAFE_METHOD_ATTRIBUTES: + return True + elif isinstance(obj, type): + if attr == "mro": + return True + elif isinstance(obj, (types.CodeType, types.TracebackType, types.FrameType)): + return True + elif isinstance(obj, types.GeneratorType): + if attr in UNSAFE_GENERATOR_ATTRIBUTES: + return True + elif hasattr(types, "CoroutineType") and isinstance(obj, types.CoroutineType): + if attr in UNSAFE_COROUTINE_ATTRIBUTES: + return True + elif hasattr(types, "AsyncGeneratorType") and isinstance( + obj, types.AsyncGeneratorType + ): + if attr in UNSAFE_ASYNC_GENERATOR_ATTRIBUTES: + return True + return attr.startswith("__") + + +def is_safe_attribute(obj: Any, attr: str) -> bool: + """ + Check if the attribute of an object is safe to access. + + Unsafe attributes are: + - Starting with an underscore `_` + - Internal attributes as set by `_is_internal_attribute`. + """ + # Non-string subscripts should be fine, as they should be found only + # as list slices. + if not isinstance(attr, str): + return True + return not (attr.startswith("_") or _is_internal_attribute(obj, attr)) + + +def is_safe_callable(obj: Any) -> Tuple[bool, Optional[str]]: + """ + Check if an object is safely callable. + + Returns: + Tuple of (is_safe, replacement_message) + - is_safe: True if safe to call, False otherwise + - replacement_message: Optional string suggesting a replacement, None if not applicable + + Unsafe callables are: + - Decorated with `@unsafe` + - Marked with `obj.alters_data = True` (Django convention) + - Unsafe builtins (e.g. `eval`) + - `str.format` or `str.format_map` (use f-strings instead) + """ + # Check for bound methods (e.g., "string".format()) + # Handle both regular methods (types.MethodType) and built-in methods (builtin_function_or_method) + underlying_func = None + if isinstance(obj, types.MethodType): + # Regular Python method - has __func__ attribute + underlying_func = obj.__func__ + elif ( + hasattr(obj, "__self__") + and hasattr(obj, "__name__") + and not hasattr(obj, "__func__") + ): + # Built-in method descriptor (e.g., str.format, str.format_map) + # These are bound methods that don't have __func__, but we can get the original descriptor + try: + underlying_func = getattr(type(obj.__self__), obj.__name__) + except (AttributeError, TypeError): + pass + + if underlying_func is not None: + # Check marks on inner function (decorated with @unsafe or alters_data) + if getattr(underlying_func, "unsafe_callable", False) or getattr( + underlying_func, "alters_data", False + ): + return (False, None) + # Check if the underlying function is in our unsafe dictionaries + if underlying_func in UNSAFE_FUNCTIONS: + return (False, UNSAFE_FUNCTIONS[underlying_func]) + if underlying_func in UNSAFE_BUILTIN_FUNCTIONS: + return (False, UNSAFE_BUILTIN_FUNCTIONS[underlying_func]) + + # Check marks on the outer function (decorated with @unsafe or alters_data) + if getattr(obj, "unsafe_callable", False) or getattr(obj, "alters_data", False): + return (False, None) + + # Check identity for unbound functions + if obj in UNSAFE_FUNCTIONS: + return (False, UNSAFE_FUNCTIONS[obj]) + if obj in UNSAFE_BUILTIN_FUNCTIONS: + return (False, UNSAFE_BUILTIN_FUNCTIONS[obj]) + + return (True, None) + + +def is_safe_variable(var_name: str) -> bool: + """ + Check if a variable is safe to access. + + Unsafe variables are: + - Starting with an underscore `_` + """ + return not var_name.startswith("_") diff --git a/tests/test_safe_eval.py b/tests/test_safe_eval.py new file mode 100644 index 0000000..366061f --- /dev/null +++ b/tests/test_safe_eval.py @@ -0,0 +1,2276 @@ +# ruff: noqa: E731 +import re +from dataclasses import dataclass, field +from typing import Any, Dict, List, NamedTuple + +import pytest +from djc_core import SecurityError, safe_eval, unsafe + +# Check if t-strings are supported (Python 3.14+) +try: + from string.templatelib import Template # type: ignore[import-untyped] + + TSTRINGS_SUPPORTED = True +except ImportError: + TSTRINGS_SUPPORTED = False + + +Value = NamedTuple("Value", [("value", int)]) + + +@dataclass +class Nested: + inner: str = field(default="inner_val") + + +@dataclass +class Obj: + attr: str = field(default="value") + name: str = field(default="test") + value: int = field(default=42) + start: int = 1 + end: int = 5 + nested: Nested = field(default_factory=Nested) + items: Dict[Any, Value] = field( + default_factory=lambda: {"test": Value(value=42), 0: Value(value=10)} + ) + + _private = "secret" + + def method(self, a: int, b: int) -> int: + return a + b + + +class TestSyntax: + # === LITERALS === + + def test_allow_literal_string(self): + compiled = safe_eval("'hello'") + context = {} + result = compiled(context) + assert result == "hello" + assert context == {} + + def test_allow_literal_bytes(self): + compiled = safe_eval("b'hello'") + context = {} + result = compiled(context) + assert result == b"hello" + assert context == {} + + def test_allow_literal_integer(self): + compiled = safe_eval("42") + context = {} + result = compiled(context) + assert result == 42 + assert context == {} + + def test_allow_literal_integer_negative(self): + compiled = safe_eval("-42") + context = {} + result = compiled(context) + assert result == -42 + assert context == {} + + def test_allow_literal_float(self): + compiled = safe_eval("3.14") + context = {} + result = compiled(context) + assert result == 3.14 + assert context == {} + + def test_allow_literal_float_negative(self): + compiled = safe_eval("-3.14") + context = {} + result = compiled(context) + assert result == -3.14 + assert context == {} + + def test_allow_literal_float_scientific(self): + compiled = safe_eval("-1e10") + context = {} + result = compiled(context) + assert result == -10000000000.0 + assert context == {} + + def test_allow_literal_boolean_true(self): + compiled = safe_eval("True") + context = {} + result = compiled(context) + assert result is True + assert context == {} + + def test_allow_literal_boolean_false(self): + compiled = safe_eval("False") + context = {} + result = compiled(context) + assert result is False + assert context == {} + + def test_allow_literal_none(self): + compiled = safe_eval("None") + context = {} + result = compiled(context) + assert result is None + assert context == {} + + def test_allow_literal_ellipsis(self): + compiled = safe_eval("...") + context = {} + result = compiled(context) + assert result is ... + assert context == {} + + def test_allow_list_with_literals(self): + compiled = safe_eval("[1, 2, 3]") + context = {} + result = compiled(context) + assert result == [1, 2, 3] + assert context == {} + + def test_allow_tuple_with_literals(self): + compiled = safe_eval("(1, 2, 3)") + context = {} + result = compiled(context) + assert result == (1, 2, 3) + assert context == {} + + def test_allow_set_literal(self): + compiled = safe_eval("{1, 2, 3}") + context = {} + result = compiled(context) + assert result == {1, 2, 3} + assert context == {} + + def test_allow_dict_with_literals(self): + compiled = safe_eval("{'a': 1, 'b': 2}") + context = {} + result = compiled(context) + assert result == {"a": 1, "b": 2} + assert context == {} + + # === DATA STRUCTURES === + + def test_allow_list_empty(self): + compiled = safe_eval("[]") + context = {} + result = compiled(context) + assert result == [] + + def test_allow_tuple_empty(self): + compiled = safe_eval("()") + context = {} + result = compiled(context) + assert result == () + + def test_allow_set_empty(self): + compiled = safe_eval("set()") + # NOTE: `set()`, `list()`, etc. must exposed to be able to call it as a function + with pytest.raises(TypeError, match=r"'NoneType' object is not callable"): + context = {"set": None} + result = compiled(context) + + context = {"set": set} + result = compiled(context) + assert result == set() + + def test_allow_dict_empty(self): + compiled = safe_eval("{}") + context = {} + result = compiled(context) + assert result == {} + + def test_allow_nested_data_structures(self): + compiled = safe_eval("[1, [2, 3], {'a': 4}]") + context = {} + result = compiled(context) + assert result == [1, [2, 3], {"a": 4}] + + def test_allow_list_comprehension(self): + compiled = safe_eval("[x for x in items]") + context = {"items": [1, 2, 3]} + result = compiled(context) + assert result == [1, 2, 3] + + def test_allow_list_comprehension_with_condition(self): + compiled = safe_eval("[x for x in items if x > 1]") + context = {"items": [1, 2, 3]} + result = compiled(context) + assert result == [2, 3] + + def test_allow_list_comprehension_complex(self): + compiled = safe_eval( + "[x[0] * y * multiplier for x in items for y in x[1] if x[0] > min_val if y < max_val]" + ) + context = { + "items": [(1, [2.1, 2.2]), (2, [3.1, 3.2]), (4, [4.1, 4.2])], + "max_val": 5, + "min_val": 1, + "multiplier": 2, + } + result = compiled(context) + assert result == [12.4, 12.8, 32.8, 33.6] + + def test_allow_set_comprehension(self): + compiled = safe_eval("{x for x in items}") + context = {"items": [1, 2, 2, 3]} + result = compiled(context) + assert result == {1, 2, 3} # Sets remove duplicates + assert context == {"items": [1, 2, 2, 3]} + + def test_allow_dict_comprehension(self): + compiled = safe_eval("{x: x*2 for x in items}") + context = {"items": [1, 2, 3]} + result = compiled(context) + assert result == {1: 2, 2: 4, 3: 6} + assert context == {"items": [1, 2, 3]} + + # === UNARY OPERATORS === + + def test_allow_unary_plus(self): + compiled = safe_eval("+42") + context = {} + result = compiled(context) + assert result == 42 + assert context == {} + + def test_allow_unary_minus(self): + compiled = safe_eval("-42") + context = {} + result = compiled(context) + assert result == -42 + assert context == {} + + def test_allow_unary_not(self): + compiled = safe_eval("not True") + context = {} + result = compiled(context) + assert result is False + assert context == {} + + def test_allow_unary_invert(self): + compiled = safe_eval("~42") + context = {} + result = compiled(context) + assert result == -43 # Bitwise NOT: ~42 = -(42 + 1) = -43 + assert context == {} + + def test_allow_nested_unary_operators(self): + compiled = safe_eval("--42") + context = {} + result = compiled(context) + assert result == 42 # Double negation: -(-42) = 42 + assert context == {} + + def test_transform_variable_in_unary_operation(self): + compiled = safe_eval("-x") + context = {"x": 10} + result = compiled(context) + assert result == -10 + assert context == {"x": 10} + + # === BINARY OPERATORS === + + def test_allow_binary_add(self): + compiled = safe_eval("1 + 2") + context = {} + result = compiled(context) + assert result == 3 + assert context == {} + + def test_allow_binary_subtract(self): + compiled = safe_eval("5 - 3") + context = {} + result = compiled(context) + assert result == 2 + assert context == {} + + def test_allow_binary_multiply(self): + compiled = safe_eval("4 * 5") + context = {} + result = compiled(context) + assert result == 20 + assert context == {} + + def test_allow_binary_divide(self): + compiled = safe_eval("10 / 2") + context = {} + result = compiled(context) + assert result == 5.0 + assert context == {} + + def test_allow_binary_modulo(self): + compiled = safe_eval("10 % 3") + context = {} + result = compiled(context) + assert result == 1 + assert context == {} + + def test_allow_binary_power(self): + compiled = safe_eval("2 ** 3") + context = {} + result = compiled(context) + assert result == 8 + assert context == {} + + def test_allow_binary_equality(self): + compiled = safe_eval("1 == 1") + context = {} + result = compiled(context) + assert result is True + assert context == {} + + def test_allow_binary_inequality(self): + compiled = safe_eval("1 != 2") + context = {} + result = compiled(context) + assert result is True + assert context == {} + + def test_allow_binary_less_than(self): + compiled = safe_eval("1 < 2") + context = {} + result = compiled(context) + assert result is True + assert context == {} + + def test_allow_binary_greater_than(self): + compiled = safe_eval("3 > 2") + context = {} + result = compiled(context) + assert result is True + assert context == {} + + def test_allow_binary_less_equal(self): + compiled = safe_eval("2 <= 3") + context = {} + result = compiled(context) + assert result is True + assert context == {} + + def test_allow_binary_greater_equal(self): + compiled = safe_eval("3 >= 2") + context = {} + result = compiled(context) + assert result is True + assert context == {} + + def test_allow_nested_binary_operations(self): + compiled = safe_eval("1 + 2 * 3") + context = {} + result = compiled(context) + assert result == 7 # Multiplication has precedence: 1 + (2 * 3) = 1 + 6 = 7 + assert context == {} + + def test_transform_variable_in_binary_operation(self): + compiled = safe_eval("x + y") + context = {"x": 10, "y": 20} + result = compiled(context) + assert result == 30 + assert context == {"x": 10, "y": 20} + + # === BOOLEAN OPERATORS === + + def test_allow_boolean_and(self): + compiled = safe_eval("True and False") + context = {} + result = compiled(context) + assert result is False + assert context == {} + + def test_allow_boolean_or(self): + compiled = safe_eval("True or False") + context = {} + result = compiled(context) + assert result is True + assert context == {} + + def test_allow_boolean_chained_and(self): + compiled = safe_eval("True and False and True") + context = {} + result = compiled(context) + assert result is False # Short-circuits on first False + assert context == {} + + def test_allow_boolean_chained_or(self): + compiled = safe_eval("False or True or False") + context = {} + result = compiled(context) + assert result is True # Short-circuits on first True + assert context == {} + + def test_allow_boolean_mixed_operators(self): + compiled = safe_eval("True and False or True") + context = {} + result = compiled(context) + assert result is True # (True and False) or True = False or True = True + assert context == {} + + def test_allow_boolean_with_comparisons(self): + compiled = safe_eval("1 < 2 and 3 > 4") + context = {} + result = compiled(context) + assert result is False # True and False = False + assert context == {} + + def test_transform_variable_in_boolean_operation(self): + compiled = safe_eval("x and y") + context = {"x": 10, "y": 20} + result = compiled(context) + assert ( + result == 20 + ) # In Python, 'and' returns the last truthy value or first falsy value + assert context == {"x": 10, "y": 20} + + # === COMPARISONS === + + def test_allow_comparison_less_than(self): + compiled = safe_eval("1 < 2") + context = {} + result = compiled(context) + assert result is True + assert context == {} + + def test_allow_comparison_less_equal(self): + compiled = safe_eval("1 <= 2") + context = {} + result = compiled(context) + assert result is True + assert context == {} + + def test_allow_comparison_greater_than(self): + compiled = safe_eval("3 > 2") + context = {} + result = compiled(context) + assert result is True + assert context == {} + + def test_allow_comparison_greater_equal(self): + compiled = safe_eval("3 >= 2") + context = {} + result = compiled(context) + assert result is True + assert context == {} + + def test_allow_comparison_equality(self): + compiled = safe_eval("1 == 1") + context = {} + result = compiled(context) + assert result is True + assert context == {} + + def test_allow_comparison_inequality(self): + compiled = safe_eval("1 != 2") + context = {} + result = compiled(context) + assert result is True + assert context == {} + + def test_allow_comparison_in(self): + compiled = safe_eval("1 in [1, 2, 3]") + context = {} + result = compiled(context) + assert result is True + assert context == {} + + def test_allow_comparison_not_in(self): + compiled = safe_eval("4 not in [1, 2, 3]") + context = {} + result = compiled(context) + assert result is True + assert context == {} + + def test_allow_comparison_is(self): + compiled = safe_eval("x is None") + context = {"x": 10} + result = compiled(context) + assert result is False + assert context == {"x": 10} + + # Test when x is actually None + context = {"x": None} + result = compiled(context) + assert result is True + assert context == {"x": None} + + def test_allow_comparison_is_not(self): + compiled = safe_eval("x is not None") + context = {"x": 10} + result = compiled(context) + assert result is True + assert context == {"x": 10} + + # Test when x is actually None + context = {"x": None} + result = compiled(context) + assert result is False + assert context == {"x": None} + + def test_allow_comparison_chained(self): + compiled = safe_eval("1 < 2 < 3") + context = {} + result = compiled(context) + assert result is True + assert context == {} + + def test_allow_comparison_mixed_types(self): + compiled = safe_eval("'hello' == 'world'") + context = {} + result = compiled(context) + assert result is False + assert context == {} + + def test_transform_variable_in_comparison(self): + compiled = safe_eval("x > 5") + context = {"x": 10} + result = compiled(context) + assert result is True + assert context == {"x": 10} + + # Test when x is less than 5 + context = {"x": 3} + result = compiled(context) + assert result is False + assert context == {"x": 3} + + # === COMPREHENSIONS === + + def test_allow_multiple_comprehensions(self): + compiled = safe_eval("[(x.name, y) for x in items for y in x.children]") + + @dataclass + class Item: + name: str + children: List[int] + + items = [Item("a", [1, 2]), Item("b", [3, 4])] + context = {"items": items} + result = compiled(context) + assert result == [("a", 1), ("a", 2), ("b", 3), ("b", 4)] + assert context == {"items": items} + + def test_allow_comprehension_with_multiple_conditions(self): + compiled = safe_eval("[x for x in items if x > 0 if x < 10]") + context = {"items": [-5, 1, 5, 15, 20]} + result = compiled(context) + assert result == [1, 5] + assert context == {"items": [-5, 1, 5, 15, 20]} + + def test_allow_nested_comprehension(self): + compiled = safe_eval("[[x+1 for x in row] for row in matrix]") + context = {"matrix": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]} + result = compiled(context) + assert result == [[2, 3, 4], [5, 6, 7], [8, 9, 10]] + assert context == {"matrix": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]} + + def test_transform_attribute_in_comprehension(self): + compiled = safe_eval("[item.name for item in items]") + items = [Obj() for _ in range(3)] + context = {"items": items} + result = compiled(context) + assert result == ["test", "test", "test"] + assert context == {"items": items} + + def test_transform_subscript_access_in_comprehension(self): + compiled = safe_eval("[item[0] for item in items]") + context = {"items": [[10, 20], [30, 40], [50, 60]]} + result = compiled(context) + assert result == [10, 30, 50] + assert context == {"items": [[10, 20], [30, 40], [50, 60]]} + + def test_transform_walrus_in_comprehension(self): + compiled = safe_eval("[y for x in items if (y := x.value)]") + items = [Obj() for _ in range(3)] + context = {"items": items, "y": 10} + result = compiled(context) + assert result == [42, 42, 42] + assert context == {"items": items, "y": 42} + + def test_transform_walrus_before_comprehension(self): + compiled = safe_eval("(limit := 10) and [x for x in items if x < limit]") + context = {"items": [1, 2, 15, 20]} + result = compiled(context) + assert result == [1, 2] + assert context == {"items": [1, 2, 15, 20], "limit": 10} + + # Also check what happens when we don't set the limit - should fail + compiled = safe_eval("[x for x in items if x < limit]") + context = {"items": [1, 2, 15, 20]} + with pytest.raises(KeyError, match="'limit'"): + compiled(context) + + # Or when we set it AFTER - also should fail + compiled = safe_eval("[x for x in items if x < limit] and (limit := 10)") + context = {"items": [1, 2, 15, 20]} + with pytest.raises(KeyError, match="'limit'"): + compiled(context) + + def test_local_variable_tracking_in_comprehensions(self): + compiled = safe_eval("[x for x in items]") + context = {"items": [1, 2, 3], "x": 100} + result = compiled(context) + assert result == [1, 2, 3] + # x in comprehension is local, so outer x should remain unchanged + assert context == {"items": [1, 2, 3], "x": 100} + + def test_local_variable_tracking_in_nested_comprehensions(self): + compiled = safe_eval("[[x for x in row] for row in matrix]") + context = {"matrix": [[1, 2, 3], [4, 5, 6]], "x": 100, "row": 200} + result = compiled(context) + assert result == [[1, 2, 3], [4, 5, 6]] + # x and row in comprehensions are local, so outer values should remain unchanged + assert context == {"matrix": [[1, 2, 3], [4, 5, 6]], "x": 100, "row": 200} + + def test_local_variable_tracking_in_multiple_comprehensions(self): + compiled = safe_eval("[(x.name, y) for x in items for y in x.children]") + + @dataclass + class Item: + name: str + children: List[int] + + items = [Item("a", [1]), Item("b", [2])] + context = {"items": items, "x": 100, "y": 200} + result = compiled(context) + assert result == [("a", 1), ("b", 2)] + # x and y in comprehensions are local, so outer values should remain unchanged + assert context == {"items": items, "x": 100, "y": 200} + + def test_local_variable_tracking_in_comprehension_conditions(self): + compiled = safe_eval("[x for x in items if x > 0]") + context = {"items": [-5, 1, 2, 3], "x": 100} + result = compiled(context) + assert result == [1, 2, 3] + # x in comprehension is local, so outer x should remain unchanged + assert context == {"items": [-5, 1, 2, 3], "x": 100} + + def test_local_variable_tracking_in_multiple_comprehension_conditions(self): + compiled = safe_eval( + "[(x.name, y) for x in items for y in x.children if y > 0]" + ) + + @dataclass + class Item: + name: str + children: List[int] + + items = [Item("a", [-1, 1]), Item("b", [-2, 2])] + context = {"items": items, "x": 100, "y": 200} + result = compiled(context) + assert result == [("a", 1), ("b", 2)] + # x and y in comprehensions are local, so outer values should remain unchanged + assert context == {"items": items, "x": 100, "y": 200} + + # === FUNCTION CALLS === + + def test_transform_function_call_simple(self): + compiled = safe_eval("foo()") + foo = lambda: 42 + context = {"foo": foo} + result = compiled(context) + assert result == 42 + assert context == {"foo": foo} + + def test_transform_function_call_with_positional_args(self): + compiled = safe_eval("foo(x, 2, 3)") + foo = lambda a, b, c: (a, b, c) + context = {"foo": foo, "x": 10} + result = compiled(context) + assert result == (10, 2, 3) + assert context == {"foo": foo, "x": 10} + + def test_transform_function_call_with_keyword_args(self): + compiled = safe_eval("foo(a=1, b=x)") + foo = lambda a, b: (a, b) + context = {"foo": foo, "x": 10} + result = compiled(context) + assert result == (1, 10) + assert context == {"foo": foo, "x": 10} + + def test_transform_function_call_with_mixed_args(self): + compiled = safe_eval("foo(1, x, a=y, b=4)") + foo = lambda pos1, pos2, a, b: (pos1, pos2, a, b) + context = {"foo": foo, "x": 10, "y": 5} + result = compiled(context) + assert result == (1, 10, 5, 4) + assert context == {"foo": foo, "x": 10, "y": 5} + + def test_transform_nested_function_calls(self): + compiled = safe_eval("foo(bar(1, x))") + bar = lambda a, b: a + b + foo = lambda x: x * 2 + context = {"bar": bar, "foo": foo, "x": 10} + result = compiled(context) + assert result == 22 + assert context == {"bar": bar, "foo": foo, "x": 10} + + def test_transform_method_call(self): + compiled = safe_eval("obj.method(1, 2)") + context = {"obj": Obj()} + result = compiled(context) + assert result == 3 + assert context == {"obj": Obj()} + + def test_transform_function_call_with_variable_args(self): + compiled = safe_eval("foo(x, y, z)") + foo = lambda *args: args + context = {"foo": foo, "x": 10, "y": 20, "z": 30} + result = compiled(context) + assert result == (10, 20, 30) + assert context == {"foo": foo, "x": 10, "y": 20, "z": 30} + + def test_transform_function_call_with_variable_kwargs(self): + compiled = safe_eval("foo(a=x, b=y)") + foo = lambda **kwargs: kwargs + context = {"foo": foo, "x": 10, "y": 20} + result = compiled(context) + assert result == {"a": 10, "b": 20} + assert context == {"foo": foo, "x": 10, "y": 20} + + def test_transform_function_call_with_spread_args(self): + compiled = safe_eval("foo(*args)") + foo = lambda *args: args + context = {"args": [1, 2, 3], "foo": foo} + result = compiled(context) + assert result == (1, 2, 3) + assert context == {"args": [1, 2, 3], "foo": foo} + + def test_transform_function_call_with_spread_kwargs(self): + compiled = safe_eval("foo(**kwargs)") + foo = lambda **kwargs: kwargs + context = {"foo": foo, "kwargs": {"a": 10, "b": 20}} + result = compiled(context) + assert result == {"a": 10, "b": 20} + assert context == {"foo": foo, "kwargs": {"a": 10, "b": 20}} + + def test_transform_function_call_with_mixed_spreads(self): + compiled = safe_eval("foo(1, *args, a=2, **kwargs)") + foo = lambda *args, **kwargs: (args, kwargs) + context = {"args": [10, 20], "foo": foo, "kwargs": {"c": 30}} + result = compiled(context) + assert result == ((1, 10, 20), {"a": 2, "c": 30}) + assert context == {"args": [10, 20], "foo": foo, "kwargs": {"c": 30}} + + def test_transform_function_call_with_nested_call_as_arg(self): + compiled = safe_eval("foo(a=get_item())") + get_item = lambda: 42 + foo = lambda **kwargs: kwargs + context = {"foo": foo, "get_item": get_item} + result = compiled(context) + assert result == {"a": 42} + assert context == {"foo": foo, "get_item": get_item} + + def test_transform_function_call_complex_signature(self): + compiled = safe_eval("foo(x, y, 3, *args, a=get_item(), b=5, **kwargs)") + get_item = lambda: 10 + foo = lambda *args, **kwargs: (args, kwargs) + context = { + "args": [1, 2], + "foo": foo, + "get_item": get_item, + "kwargs": {"c": 100}, + "x": 10, + "y": 20, + } + result = compiled(context) + assert result == ((10, 20, 3, 1, 2), {"a": 10, "b": 5, "c": 100}) + assert context == { + "args": [1, 2], + "foo": foo, + "get_item": get_item, + "kwargs": {"c": 100}, + "x": 10, + "y": 20, + } + + def test_transform_subscript_with_method_call(self): + compiled = safe_eval("obj[0](1, 2)") + obj = [lambda a, b: (a, b)] + context = {"obj": obj} + result = compiled(context) + assert result == (1, 2) + assert context == {"obj": obj} + + def test_transform_slice_with_function_call(self): + compiled = safe_eval("list[get_start():get_end()]") + get_start = lambda: 1 + get_end = lambda: 5 + context = { + "get_end": get_end, + "get_start": get_start, + "list": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + } + result = compiled(context) + assert result == [1, 2, 3, 4] + assert context == { + "get_end": get_end, + "get_start": get_start, + "list": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + } + + def test_transform_walrus_with_function_call(self): + compiled = safe_eval("(result := get_value())") + get_value = lambda: 42 + context = {"get_value": get_value} + result = compiled(context) + assert result == 42 + assert context == {"get_value": get_value, "result": 42} + + def test_transform_walrus_in_function_call(self): + compiled = safe_eval("foo(x := get_value())") + get_value = lambda: 42 + foo = lambda x: x * 2 + context = {"foo": foo, "get_value": get_value, "x": 10} + result = compiled(context) + assert result == 84 + assert context == {"foo": foo, "get_value": get_value, "x": 42} + + def test_transform_fstring_with_function_call(self): + compiled = safe_eval("f'Value: {get_value()}'") + get_value = lambda: 42 + context = {"get_value": get_value} + result = compiled(context) + assert result == "Value: 42" + assert context == {"get_value": get_value} + + # === ATTRIBUTE ACCESS === + + def test_transform_attribute_access_simple(self): + compiled = safe_eval("obj.attr") + + context = {"obj": Obj()} + result = compiled(context) + assert result == "value" + assert context == {"obj": Obj()} + + def test_transform_attribute_access_chained(self): + compiled = safe_eval("obj.nested.inner") + context = {"obj": Obj()} + result = compiled(context) + assert result == "inner_val" + assert context == {"obj": Obj()} + + def test_transform_attribute_access_with_args(self): + compiled = safe_eval("obj.method(1, x)") + context = {"obj": Obj(), "x": 10} + result = compiled(context) + assert result == 11 + assert context == {"obj": Obj(), "x": 10} + + def test_transform_attribute_in_expression(self): + compiled = safe_eval("obj.value + 10") + context = {"obj": Obj()} + result = compiled(context) + assert result == 52 + assert context == {"obj": Obj()} + + def test_transform_attribute_with_underscore(self): + compiled = safe_eval("obj._private") + context = {"obj": Obj()} + with pytest.raises( + SecurityError, + match="attribute '_private' on object '' is unsafe", + ): + compiled(context) + assert context == {"obj": Obj()} + + def test_transform_attribute_with_dunder(self): + compiled = safe_eval("obj.__class__") + context = {"obj": Obj()} + with pytest.raises( + SecurityError, + match="attribute '__class__' on object '' is unsafe", + ): + compiled(context) + assert context == {"obj": Obj()} + + def test_transform_mixed_attribute_and_subscript(self): + compiled = safe_eval("obj.items[0]") + context = {"obj": Obj()} + result = compiled(context) + assert result == Value(value=10) + assert context == {"obj": Obj()} + + def test_transform_subscript_then_attribute(self): + compiled = safe_eval("obj[0].name") + context = {"obj": [Obj()]} + result = compiled(context) + assert result == "test" + assert context == {"obj": [Obj()]} + + def test_transform_slice_with_attribute_access(self): + compiled = safe_eval("list[obj.start:obj.end]") + context = { + "list": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + "obj": Obj(), + } + result = compiled(context) + assert result == [1, 2, 3, 4] + assert context == {"list": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "obj": Obj()} + + def test_transform_walrus_with_attribute_access(self): + compiled = safe_eval("(x := obj.value)") + context = {"obj": Obj(), "x": 10} + result = compiled(context) + assert result == 42 + assert context == {"obj": Obj(), "x": 42} + + def test_transform_fstring_with_attribute(self): + compiled = safe_eval("f'Name: {obj.name}'") + context = {"obj": Obj()} + result = compiled(context) + assert result == "Name: test" + assert context == {"obj": Obj()} + + # === SUBSCRIPT ACCESS === + + def test_transform_subscript_access_simple(self): + compiled = safe_eval("obj[2]") + context = {"obj": [10, 20, 30, 40]} + result = compiled(context) + assert result == 30 + assert context == {"obj": [10, 20, 30, 40]} + + def test_transform_subscript_access_with_variable_key(self): + compiled = safe_eval("obj[key]") + context = {"key": "name", "obj": {"attr": "value", "name": "test", "value": 42}} + result = compiled(context) + assert result == "test" + assert context == { + "key": "name", + "obj": {"attr": "value", "name": "test", "value": 42}, + } + + def test_transform_subscript_access_with_string_key(self): + compiled = safe_eval("obj['name']") + context = {"obj": {"attr": "value", "name": "test", "value": 42}} + result = compiled(context) + assert result == "test" + assert context == {"obj": {"attr": "value", "name": "test", "value": 42}} + + def test_transform_subscript_access_chained(self): + compiled = safe_eval("obj[0][1]") + context = {"obj": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]} + result = compiled(context) + assert result == 2 + assert context == {"obj": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]} + + def test_transform_subscript_access_with_expression_key(self): + compiled = safe_eval("obj[x + 1]") + context = {"obj": [0, 5, 10, 15], "x": 1} + result = compiled(context) + assert result == 10 + assert context == {"obj": [0, 5, 10, 15], "x": 1} + + def test_transform_subscript_access_in_expression(self): + compiled = safe_eval("obj[0] + 10") + context = {"obj": [5, 15, 25]} + result = compiled(context) + assert result == 15 + assert context == {"obj": [5, 15, 25]} + + # === SLICES === + + def test_transform_slice_start_stop(self): + compiled = safe_eval("list[1:x]") + context = {"list": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "x": 5} + result = compiled(context) + assert result == [1, 2, 3, 4] + + def test_transform_slice_stop_only(self): + compiled = safe_eval("list[:x]") + context = {"list": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "x": 5} + result = compiled(context) + assert result == [0, 1, 2, 3, 4] + + def test_transform_slice_start_only(self): + compiled = safe_eval("list[1:]") + context = {"list": [0, 1, 2, 3, 4, 5]} + result = compiled(context) + assert result == [1, 2, 3, 4, 5] + + def test_transform_slice_all(self): + compiled = safe_eval("list[:]") + context = {"list": [0, 1, 2, 3, 4, 5]} + result = compiled(context) + assert result == [0, 1, 2, 3, 4, 5] + # Ensure it's a copy, not the same object + assert result is not context["list"] + + def test_transform_slice_with_step(self): + compiled = safe_eval("list[::]") + context = {"list": [0, 1, 2, 3, 4, 5]} + result = compiled(context) + assert result == [0, 1, 2, 3, 4, 5] + # Ensure it's a copy, not the same object + assert result is not context["list"] + + def test_transform_slice_reverse(self): + compiled = safe_eval("list[::-1]") + context = {"list": [0, 1, 2, 3, 4, 5]} + result = compiled(context) + assert result == [5, 4, 3, 2, 1, 0] + + def test_transform_slice_full(self): + compiled = safe_eval("list[1:-2:1]") + context = {"list": [0, 1, 2, 3, 4, 5]} + result = compiled(context) + assert result == [1, 2, 3] + + def test_transform_slice_start_with_step(self): + compiled = safe_eval("list[1::]") + context = {"list": [0, 1, 2, 3, 4, 5]} + result = compiled(context) + assert result == [1, 2, 3, 4, 5] + + def test_transform_slice_start_stop_with_step(self): + compiled = safe_eval("list[1:2:]") + context = {"list": [0, 1, 2, 3, 4, 5]} + result = compiled(context) + assert result == [1] + + def test_transform_slice_with_variables(self): + compiled = safe_eval("list[start:end:step]") + context = {"end": 4, "list": [0, 1, 2, 3, 4, 5], "start": 1, "step": 2} + result = compiled(context) + assert result == [1, 3] + + def test_transform_slice_with_expressions(self): + compiled = safe_eval("list[x + 1:y - 1]") + context = {"list": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "x": 1, "y": 5} + result = compiled(context) + assert result == [2, 3] + + # === WALRUS OPERATOR === + + def test_transform_walrus_simple(self): + compiled = safe_eval("(x := 5)") + + context = {"x": 10} + result = compiled(context) + assert result == 5 + assert context == {"x": 5} + + context = {"y": "a"} + result = compiled(context) + assert result == 5 + assert context == {"y": "a", "x": 5} + + def test_transform_walrus_with_variable(self): + compiled = safe_eval("(x := y)") + context = {"y": 5} + result = compiled(context) + assert result == 5 + assert context == {"x": 5, "y": 5} + + context = {"x": 10, "y": "a"} + result = compiled(context) + assert result == "a" + assert context == {"x": "a", "y": "a"} + + def test_transform_walrus_with_expression(self): + compiled = safe_eval("(x := y + 1)") + + context = {"x": 10, "y": 5} + result = compiled(context) + assert result == 6 + assert context == {"x": 6, "y": 5} + + context = {"y": "a"} + with pytest.raises(TypeError): + _ = compiled(context) + + def test_transform_walrus_in_if_expression(self): + compiled = safe_eval("(x := get_value()) if (x := get_value()) else -1") + + # Hit the truthy branch + items = [1, 2] + items_iter = iter(items) + get_value = lambda: next(items_iter) + context = {"get_value": get_value} + result = compiled(context) + assert result == 2 + # x assigned from left-most `x := get_value()` + assert context == {"x": 2, "get_value": get_value} + + # Hit the falsy branch + items = [0, 1] + items_iter = iter(items) + get_value = lambda: next(items_iter) + context = {"get_value": get_value} + result = compiled(context) + assert result == -1 + # x assigned from right-most `x := get_value()` + assert context == {"x": 0, "get_value": get_value} + + def test_transform_walrus_chained(self): + compiled = safe_eval("(x := (y := 5))") + context = {} + result = compiled(context) + assert result == 5 + assert context == {"x": 5, "y": 5} + + def test_transform_walrus_remains_accessible_after_scope(self): + compiled = safe_eval("foo([(a := i + 1) for i in items], a)") + foo = lambda lst, a: lst + [a] + context = {"foo": foo, "items": [1, 2, 3]} + result = compiled(context) + assert result == [2, 3, 4, 4] + assert context == {"foo": foo, "items": [1, 2, 3], "a": 4} + + def test_transform_walrus_multiple_assignments(self): + compiled = safe_eval("[(x := i, y := i*2) for i in items] + [(x, y)]") + context = {"items": [1, 2, 3]} + result = compiled(context) + assert result == [(1, 2), (2, 4), (3, 6), (3, 6)] + assert context == {"items": [1, 2, 3], "x": 3, "y": 6} + + def test_transform_walrus_sequential_usage(self): + compiled = safe_eval("(x := 5) + x") + context = {} + result = compiled(context) + assert result == 10 + assert context == {"x": 5} + + def test_transform_walrus_nested_scopes(self): + compiled = safe_eval( + "[(y, x, a, b, i) for x in [(a := i) for i in items] if (b := a + 1)] + [(y, x, a, b, i)]" + ) + context = {"i": -1, "a": -1, "b": -1, "items": [1, 2, 3], "x": 5, "y": 10} + result = compiled(context) + assert result == [ + # x as loop var + (10, 1, 3, 4, -1), + (10, 2, 3, 4, -1), + (10, 3, 3, 4, -1), + # x=5 from outside context + (10, 5, 3, 4, -1), + ] + assert context == { + # Unchanged + "i": -1, + "items": [1, 2, 3], + "x": 5, + "y": 10, + # a and b are set by walrus op in the last loop + "a": 3, + "b": 4, + } + + def test_transform_walrus_in_comprehension_leaks(self): + compiled = safe_eval("[(x := y) for y in items]") + context = {"items": [1, 2, 3]} + result = compiled(context) + assert result == [1, 2, 3] + # x should be accessible outside the comprehension (matches Python behavior) + assert context == {"items": [1, 2, 3], "x": 3} + + def test_transform_walrus_in_comprehension_conflicts_with_variable(self): + with pytest.raises( + SyntaxError, + match="assignment expression cannot rebind comprehension iteration variable 'n'", + ): + safe_eval("[(n := n + 1) + n for n in items]") + + def test_transform_walrus_in_comprehension_conflicts_with_variable_nested(self): + with pytest.raises( + SyntaxError, + match="assignment expression cannot rebind comprehension iteration variable 'x'", + ): + safe_eval("[[(x := x + 1) for y in inner] for x in outer]") + + # NOTE: This diverges from Python, which allows overwriting function args by walrus + def test_transform_walrus_in_lambda_leaks(self): + compiled = safe_eval("lambda x: (y := x + 1) + y") + context = {} + fn = compiled(context) + result = fn(5) + assert result == 12 # (y := 5 + 1) + y = 6 + 6 = 12 + + # The important part: y SHOULD be in the context + assert context == {"y": 6} + + # And should change again after another call + result2 = fn(10) + assert result2 == 22 # (y := 10 + 1) + y = 11 + 11 = 22 + assert context == {"y": 11} + + # NOTE: This diverges from Python, which allows overwriting function args by walrus + def test_transform_walrus_in_lambda_conflicts_with_variable(self): + with pytest.raises( + SyntaxError, + match="assignment expression cannot rebind lambda parameter 'x'", + ): + safe_eval("(lambda x: (x := 3) and x**2)") + + # NOTE: This diverges from Python, which allows overwriting function args by walrus + def test_transform_walrus_in_lambda_conflicts_with_variable_nested_outer(self): + with pytest.raises( + SyntaxError, + match="assignment expression cannot rebind lambda parameter 'x'", + ): + safe_eval("(lambda x: lambda y: (x := 3))") + + # NOTE: This diverges from Python, which allows overwriting function args by walrus + def test_transform_walrus_in_lambda_conflicts_with_variable_nested_inner(self): + with pytest.raises( + SyntaxError, + match="assignment expression cannot rebind lambda parameter 'y'", + ): + safe_eval("(lambda x: lambda y: (y := 3))") + + # === F-STRINGS === + + def test_transform_fstring_simple(self): + compiled = safe_eval("f'Hello {name}'") + context = {"name": "test"} + result = compiled(context) + assert result == "Hello test" + assert context == {"name": "test"} + + def test_transform_fstring_with_expression(self): + compiled = safe_eval("f'Result: {x + 1}'") + context = {"x": 10} + result = compiled(context) + assert result == "Result: 11" + assert context == {"x": 10} + + context = {"x": "a"} + with pytest.raises(TypeError): + compiled(context) + + def test_transform_fstring_multiple_interpolations(self): + compiled = safe_eval("f'{x} and {y}'") + context = {"x": 10, "y": 11} + result = compiled(context) + assert result == "10 and 11" + assert context == {"x": 10, "y": 11} + + def test_transform_fstring_nested_expression(self): + compiled = safe_eval("f'start {obj.method(x, y)} end'") + + context = { + "obj": Obj(), + "x": 10, + "y": 11, + } + result = compiled(context) + assert result == "start 21 end" + assert context == {"obj": Obj(), "x": 10, "y": 11} + + def test_transform_fstring_with_format_spec(self): + compiled = safe_eval("f'start {value:.2f} end'") + context = {"value": 100} + result = compiled(context) + assert result == "start 100.00 end" + assert context == {"value": 100} + + def test_transform_fstring_with_format_spec_alignment(self): + compiled = safe_eval("f'start {name:>10} end'") + context = {"name": "test"} + result = compiled(context) + assert result == "start test end" + assert context == {"name": "test"} + + def test_transform_fstring_with_conversion(self): + compiled = safe_eval("f'start {value!r} end'") + + class Value: + def __repr__(self): + return "" + + def __eq__(self, other): + return isinstance(other, Value) + + context = {"value": Value()} + result = compiled(context) + assert result == "start end" + assert context == {"value": Value()} + + def test_transform_fstring_with_conversion_str(self): + compiled = safe_eval("f'start {value!s} end'") + + class Value: + def __str__(self): + return "" + + def __eq__(self, other): + return isinstance(other, Value) + + context = {"value": Value()} + result = compiled(context) + assert result == "start end" + assert context == {"value": Value()} + + def test_transform_fstring_with_conversion_and_format(self): + compiled = safe_eval("f'start {value!r:>20} end'") + + class Value: + def __repr__(self): + return "" + + def __eq__(self, other): + return isinstance(other, Value) + + context = {"value": Value()} + result = compiled(context) + assert result == "start end" + assert context == {"value": Value()} + + # === T-STRINGS === + + def test_transform_tstring_simple(self): + compiled = safe_eval("t'Hello {name}'") + context = {"name": "test"} + if TSTRINGS_SUPPORTED: + # On Python 3.14+, t-strings are supported and return Template objects + result = compiled(context) + assert isinstance(result, Template) + else: + with pytest.raises(NotImplementedError): + compiled(context) + + def test_transform_tstring_with_expression(self): + compiled = safe_eval("t'Result: {x + 1}'") + context = {"x": 10} + if TSTRINGS_SUPPORTED: + result = compiled(context) + assert isinstance(result, Template) + else: + with pytest.raises(NotImplementedError): + compiled(context) + + def test_transform_tstring_multiple_interpolations(self): + compiled = safe_eval("t'{x} and {y}'") + context = {"x": 10, "y": 10} + if TSTRINGS_SUPPORTED: + result = compiled(context) + assert isinstance(result, Template) + else: + with pytest.raises(NotImplementedError): + compiled(context) + + def test_transform_tstring_with_format_spec(self): + compiled = safe_eval("t'start {value:.2f} end'") + context = {"value": 100} + if TSTRINGS_SUPPORTED: + result = compiled(context) + assert isinstance(result, Template) + else: + with pytest.raises(NotImplementedError): + compiled(context) + + def test_transform_tstring_with_conversion(self): + compiled = safe_eval("t'start {value!r} end'") + context = {"value": 100} + if TSTRINGS_SUPPORTED: + result = compiled(context) + assert isinstance(result, Template) + else: + with pytest.raises(NotImplementedError): + compiled(context) + + def test_transform_tstring_with_conversion_and_format(self): + compiled = safe_eval("t'start {value!r:>20} end'") + context = {"value": 100} + if TSTRINGS_SUPPORTED: + result = compiled(context) + assert isinstance(result, Template) + else: + with pytest.raises(NotImplementedError): + compiled(context) + + # === VARIABLES === + + def test_transform_variable_as_callable(self): + compiled = safe_eval("my_func(1, 2)") + + context = {"my_func": None} + with pytest.raises(TypeError): + result = compiled(context) + + context = {"my_func": lambda x, y: x + y} + result = compiled(context) + assert result == 3 + + def test_allow_simple_variable(self): + compiled = safe_eval("x") + context = {"x": 10} + result = compiled(context) + assert result == 10 + + def test_transform_variable_in_list(self): + compiled = safe_eval("[x, y, z]") + context = {"x": 10, "y": 11, "z": 12} + result = compiled(context) + assert result == [10, 11, 12] + + def test_transform_variable_in_dict(self): + compiled = safe_eval("{'key': x}") + context = {"x": 10} + result = compiled(context) + assert result == {"key": 10} + + def test_transform_variable_in_complex_expression(self): + compiled = safe_eval("x + y * z > 10") + context = {"x": 10, "y": 11, "z": 12} + result = compiled(context) + assert result == (10 + 11 * 12 > 10) + + def test_transform_variable_names_with_underscores(self): + compiled = safe_eval("my_variable") + context = {"my_variable": None} + result = compiled(context) + assert result is None + + context = {"my_variable": 10} + result = compiled(context) + assert result == 10 + + def test_transform_variable_names_with_numbers(self): + compiled = safe_eval("var123") + context = {"var123": None} + result = compiled(context) + assert result is None + + context = {"var123": 10} + result = compiled(context) + assert result == 10 + + # === LAMBDAS === + + def test_lambda_simple(self): + compiled = safe_eval("lambda x: x + 1 + y") + + context = {"y": 10} + fn = compiled(context) + assert callable(fn) + result = fn(1) + assert result == 12 + assert context == {"y": 10} # Should be unchanged + + # Make the lambda raise an error + context2 = {"y": "a"} + fn2 = compiled(context2) + assert callable(fn2) + with pytest.raises(TypeError): + fn2(1) + assert context2 == {"y": "a"} # Should be unchanged + + def test_lambda_no_params(self): + compiled = safe_eval("lambda: 42 + y") + + context = {"y": 10} + fn = compiled(context) + assert callable(fn) + result = fn() + assert result == 52 + assert context == {"y": 10} # Should be unchanged + + # Make the lambda raise an error + with pytest.raises(TypeError): + result = fn(1) + assert context == {"y": 10} # Should be unchanged + + def test_lambda_multiple_params(self): + compiled = safe_eval("lambda x, y: x + y + z") + context = {"z": 10} + fn = compiled(context) + result = fn(1, 2) + assert result == 13 + assert context == {"z": 10} # Should be unchanged + + def test_lambda_with_varargs(self): + compiled = safe_eval("lambda *args: len(args)") + context = {"len": len} + fn = compiled(context) + + result = fn(1, 2) + assert result == 2 + assert context == {"len": len} # Should be unchanged + + result = fn(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) + assert result == 10 + assert context == {"len": len} # Should be unchanged + + def test_lambda_with_kwargs(self): + compiled = safe_eval("lambda **kwargs: len(kwargs)") + context = {"len": len} + fn = compiled(context) + + result = fn(a=1, b=2, c=3) + assert result == 3 + assert context == {"len": len} # Should be unchanged + + result = fn(a=1, b=2, c=3, d=4, e=5, f=6, g=7, h=8, i=9, j=10) + assert result == 10 + assert context == {"len": len} # Should be unchanged + + # === FORBIDDEN SYNTAX === + + def test_forbid_assignment(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("x = 1") + + def test_forbid_augmented_assignment(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("x += 1") + + def test_forbid_annotated_assignment(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("x: int = 1") + + def test_forbid_delete(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("del x") + + def test_forbid_multiple_delete(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("del x, y, z") + + def test_forbid_raise(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("raise ValueError('error')") + + def test_forbid_raise_bare(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("raise 'Oops'") + + def test_forbid_assert(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("assert x > 0") + + def test_forbid_assert_with_message(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("assert x > 0, 'x must be positive'") + + def test_forbid_pass(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("pass") + + def test_forbid_type_alias(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("type Point = tuple[float, float]") + + def test_forbid_for(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("for i in range(10): print(i") + + def test_forbid_while(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("while i < 10: print(i)") + + def test_forbid_break(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("for i in range(10): break") + + def test_forbid_continue(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("for i in range(10): continue") + + def test_forbid_if(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("if x > 0: print(1)") + + def test_forbid_if_else(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("if x > 0: print(1)\nelif 2: print(2)\nelse: print(3)") + + def test_forbid_try_except(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("try: x\nexcept: pass") + + def test_forbid_try_except_specific(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("try: x\nexcept ValueError: pass") + + def test_forbid_try_except_finally(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("try: x\nexcept: pass\nfinally: pass") + + def test_forbid_except_star(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("try: x\nexcept* ValueError: pass") + + def test_forbid_with(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("with open('f') as f: pass") + + def test_forbid_with_multiple(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("with open('f1') as f1, open('f2') as f2: pass") + + def test_forbid_async_with_in_async_fn(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("async def fn():\n async with x as y: pass") + + def test_forbid_import(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("import os") + + def test_forbid_import_from(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("from os import path") + + def test_forbid_import_from_as(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("from os import path as p") + + def test_forbid_class(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("class MyClass: pass") + + def test_forbid_fn(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("def fn(): 1") + + def test_forbid_return(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("def fn(): return 42") + + def test_forbid_global(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("def fn(): global x") + + def test_forbid_nonlocal(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("def fn(): nonlocal x") + + def test_forbid_yield(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("def fn(): yield x") + + def test_forbid_yield_from(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("def fn(): yield from x") + + def test_forbid_decorator(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("@decorator\ndef fn(): pass") + + def test_forbid_async_fn(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("async def fn(): await x") + + def test_forbid_async_for(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("async for x in y") + + def test_forbid_async_with(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("async with x as y: pass") + + def test_forbid_match(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("match x:\n case 1: pass") + + def test_forbid_match_singleton(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("match x:\n case None: pass\n case True: pass") + + def test_forbid_match_sequence(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("match x:\n case [1, 2, 3]: pass") + + def test_forbid_match_star(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("match x:\n case [1, *rest]: pass") + + def test_forbid_match_mapping(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("match x:\n case {'key': value}: pass") + + def test_forbid_match_class(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("match x:\n case Point(x=0, y=0): pass") + + def test_forbid_match_as(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("match x:\n case [1, 2] as pair: pass") + + def test_forbid_match_or(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("match x:\n case 1 | 2 | 3: pass") + + def test_forbid_match_wildcard(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("match x:\n case _: pass") + + def test_forbid_match_guard(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("match x:\n case n if n > 0: pass") + + def test_forbid_typevar(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("type T = int") + + def test_forbid_typevar_union(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("type StringOrInt = str | int") + + def test_forbid_generic_function(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("def func[T](x: T) -> T: return x") + + def test_forbid_typevartuple(self): + with pytest.raises((SyntaxError, ValueError)): + safe_eval("def func[*Ts](*args: *Ts) -> tuple[*Ts]: return args") + + # === OTHER === + + def test_allow_comments(self): + compiled = safe_eval("1 # comment") + context = {} + result = compiled(context) + assert result == 1 + + def test_allow_generator_expression(self): + compiled = safe_eval("(x for x in items)") + context = {"items": [1, 2, 3]} + result = compiled(context) + assert isinstance(result, type((x for x in []))) + assert list(result) == [1, 2, 3] + + def test_transform_complex_nested_access(self): + compiled = safe_eval("obj.items[key].value") + + context1 = { + "key": "test", + "obj": {"attr": "value", "name": "test", "value": 42}, + } + with pytest.raises(TypeError): + compiled(context1) + + context2 = {"key": "test", "obj": Obj()} + result = compiled(context2) + assert result == 42 + + def test_transform_percent_formatting(self): + compiled = safe_eval("'text %s' % var") + context = {"var": None} + result = compiled(context) + assert result == "text None" + + def test_transform_percent_formatting_tuple(self): + compiled = safe_eval("'%s and %s' % (x, y)") + context = {"x": 10, "y": 11} + result = compiled(context) + assert result == "10 and 11" + + +class TestUsage: + def test_allow_multiple_evaluations(self): + compiled = safe_eval("1") + context = {} + result = compiled(context) + assert result == 1 + assert context == {} + + def test_missing_variable(self): + compiled = safe_eval("x") + context = {} + with pytest.raises(KeyError, match="'x'"): + compiled(context) + + def test_variable_none(self): + compiled = safe_eval("x") + context = {"x": None} + result = compiled(context) + assert result is None + + def test_syntax_error(self): + with pytest.raises( + SyntaxError, + match="Unexpected token at the end of an expression at byte range 2..4", + ): + safe_eval("x := [1)") + + +class TestSecurity: + def test_block_unsafe_builtin_eval(self): + compiled = safe_eval("eval('1+1')") + context = {"eval": eval} + with pytest.raises( + SecurityError, match="function '' is unsafe" + ): + compiled(context) + + def test_block_unsafe_builtin_passed_as_variable(self): + compiled = safe_eval("totally_no_e_val('1+1')") + context = {"totally_no_e_val": eval} + with pytest.raises( + SecurityError, match="function '' is unsafe" + ): + compiled(context) + + def test_block_unsafe_decorated_function(self): + @unsafe + def dangerous_function(): + return "dangerous" + + compiled = safe_eval("dangerous_function()") + context = {"dangerous_function": dangerous_function} + with pytest.raises( + SecurityError, match="function '.*dangerous_function.*' is unsafe" + ): + compiled(context) + + def test_block_django_alters_data_function(self): + class DjangoModel: + def delete(self): + pass + + delete.alters_data = True # type: ignore + + obj = DjangoModel() + compiled = safe_eval("obj.delete()") + context = {"obj": obj} + with pytest.raises(SecurityError, match="function '.*delete.*' is unsafe"): + compiled(context) + + def test_block_private_attribute(self): + compiled = safe_eval("obj._private") + context = {"obj": Obj()} + with pytest.raises( + SecurityError, + match="attribute '_private' on object '' is unsafe", + ): + compiled(context) + + def test_block_dunder_attribute(self): + compiled = safe_eval("obj.__class__") + context = {"obj": object()} + with pytest.raises( + SecurityError, + match="attribute '__class__' on object '' is unsafe", + ): + compiled(context) + + def test_block_internal_mro_attribute(self): + compiled = safe_eval("str.mro") + context = {"str": str} + with pytest.raises( + SecurityError, match="attribute 'mro' on object '' is unsafe" + ): + compiled(context) + + def test_block_generator_internal_attributes(self): + def gen(): + yield 1 + + g = gen() + compiled = safe_eval("g.gi_frame") + context = {"g": g} + with pytest.raises( + SecurityError, + match="attribute 'gi_frame' on object '' is unsafe", + ): + compiled(context) + + def test_block_code_type_access(self): + compiled = safe_eval("func.__code__") + + def func(): + pass + + context = {"func": func} + with pytest.raises( + SecurityError, + match="attribute '__code__' on object '' is unsafe", + ): + compiled(context) + + def test_block_unsafe_variable_access(self): + compiled = safe_eval("_private_var") + context = {"_private_var": 42} + with pytest.raises(SecurityError, match="variable '_private_var' is unsafe"): + compiled(context) + + def test_block_unsafe_variable_assignment(self): + compiled = safe_eval("(_private := 42)") + context = {} + with pytest.raises(SecurityError, match="variable '_private' is unsafe"): + compiled(context) + + +# These tests were based on Jinja2s `test_security.py` file, Jinja v3.1.6 +# https://github.com/pallets/jinja/blob/5ef70112a1ff19c05324ff889dd30405b1002044/tests/test_security.py +class TestSecurityJinjaCompat: + def test_subclasses_method(self): + compiled = safe_eval("obj.__class__.__subclasses__()") + context = {"obj": 42} + # Should be blocked at __class__ access, not at __subclasses__ + with pytest.raises( + SecurityError, + match="attribute '__class__' on object.*is unsafe", + ): + compiled(context) + + def test_private_method_call(self): + class ObjWithPrivate: + def _foo(self): + return "secret" + + def public(self): + return "public" + + obj = ObjWithPrivate() + # Private method call should be blocked + compiled = safe_eval("obj._foo()") + context = {"obj": obj} + with pytest.raises( + SecurityError, + match="attribute '_foo' on object.*is unsafe", + ): + compiled(context) + + # Public method should work + compiled = safe_eval("obj.public()") + context = {"obj": obj} + result = compiled(context) + assert result == "public" + + # Unlike Jinja, we allow to call methods that mutate objects in place. + # If we want to add immutable sandbox support, these should be blocked + def test_mutable_operations(self): + compiled = safe_eval("lst.append(42)") + context = {"lst": [1, 2, 3]} + result = compiled(context) + assert result is None # append returns None + assert context["lst"] == [1, 2, 3, 42] # Modified + + compiled = safe_eval("lst.pop()") + context = {"lst": [1, 2, 3]} + result = compiled(context) + assert result == 3 + assert context["lst"] == [1, 2] # Modified + + compiled = safe_eval("dct.clear()") + context = {"dct": {"a": 1, "b": 2}} + result = compiled(context) + assert result is None # clear returns None + assert context["dct"] == {} # Modified + + # Unlike Jinja, we do NOT block access to func_code attribute. + # Because we support only Python 3. + # func_code is an attribute of a function object in Python 2. + def test_func_code_attribute(self): + compiled = safe_eval("func.func_code") + + def func(): + pass + + context = {"func": func} + with pytest.raises( + AttributeError, + match="'function' object has no attribute 'func_code'", + ): + compiled(context) + + # `str.format()` and `str.format_map()` can be used to expose unsafe variables, + # e.g. `"{a.__class__}".format(a=42)` returns `""` + # While Jinja allows to use `str.format()` and `str.format_map()`, + # we simply block them instead and ask users to use f-strings instead, + # which is handled by our transformer instead. + def test_format_method(self): + compiled = safe_eval('"a{0[\'b\']}b".format({"b": 42})') + context = {} + with pytest.raises( + SecurityError, + match="function '.*format.*' is unsafe\\. Use f-strings instead\\.", + ): + compiled(context) + + def test_format_map_method(self): + compiled = safe_eval('"a{x.__class__}b".format_map({"x": {"b": 42}})') + context = {} + with pytest.raises( + SecurityError, + match="function '.*format_map.*' is unsafe\\. Use f-strings instead\\.", + ): + compiled(context) + + def test_indirect_call_via_attr(self): + # Mimic the exploit - first get the str.format and assign to a variable + compiled = safe_eval("(format_func := str.format)") + context = {"str": str} + compiled(context) + assert context == {"str": str, "format_func": str.format} + + # Reusing the context object (what would happen in the template), + # use `format_func` to call `str.format` + compiled = safe_eval('format_func("{b.__class__}", b=42)') + with pytest.raises( + SecurityError, + match="function '.*format.*' is unsafe\\. Use f-strings instead\\.", + ): + compiled(context) + + # Same for format_map + compiled = safe_eval("(format_func := str.format_map)") + context = {"str": str} + compiled(context) + assert context == {"str": str, "format_func": str.format_map} + + # Reusing the context object (what would happen in the template), + # use `format_func` to call `str.format` + compiled = safe_eval('format_func("{b.__class__}", {"b": 42})') + with pytest.raises( + SecurityError, + match="function '.*format_map.*' is unsafe\\. Use f-strings instead\\.", + ): + compiled(context) + + +class TestCustomValidators: + def test_validate_callable(self): + # Create a function that blocks functions with "danger" in their name + def is_safe_callable(func): + func_name = getattr(func, "__name__", "") + return not func_name.lower().startswith("danger") + + # Success case + def safe_func(x): + return x + 1 + + compiled = safe_eval("safe_func(5)", validate_callable=is_safe_callable) + context = {"safe_func": safe_func} + result = compiled(context) + assert result == 6 + + # Failure case + def danger_func(x): + return x * 2 + + compiled = safe_eval("danger_func(5)", validate_callable=is_safe_callable) + context = {"danger_func": danger_func} + with pytest.raises(SecurityError, match="function '.*danger_func.*' is unsafe"): + compiled(context) + + def test_validate_attribute(self): + # Create a validator that blocks attributes starting with "secret" + def is_safe_attr(obj, attr_name): + return not attr_name.lower().startswith("secret") + + class DataClass: + def __init__(self): + self.public = "public_data" + self.secret_data = "secret_info" + + obj = DataClass() + + # Success case + compiled = safe_eval("obj.public", validate_attribute=is_safe_attr) + context = {"obj": obj} + result = compiled(context) + assert result == "public_data" + + # Failure case + compiled = safe_eval("obj.secret_data", validate_attribute=is_safe_attr) + context = {"obj": obj} + with pytest.raises( + SecurityError, + match="attribute 'secret_data' on object '.*DataClass.*' is unsafe", + ): + compiled(context) + + def test_validate_subscript(self): + # Create a validator that blocks integer keys >= 100 + def is_safe_subscript(obj, key): + if isinstance(key, int): + return key < 100 + return True + + data = {1: "one", 2: "two", 100: "hundred", 200: "two_hundred"} + + # Success case + compiled = safe_eval("data[1]", validate_subscript=is_safe_subscript) + context = {"data": data} + result = compiled(context) + assert result == "one" + + # Failure case + compiled = safe_eval("data[100]", validate_subscript=is_safe_subscript) + context = {"data": data} + with pytest.raises( + SecurityError, match="key '100' on object '.*dict.*' is unsafe" + ): + compiled(context) + + # String keys are allowed + data_str = {"a": "alpha", "b": "beta"} + compiled = safe_eval("data_str['a']", validate_subscript=is_safe_subscript) + context = {"data_str": data_str} + result = compiled(context) + assert result == "alpha" + + def test_validate_assign(self): + # Create a validator that blocks assignments to variables ending with "_config" + def is_safe_assign(var_name, value): + return not var_name.endswith("_config") + + # Success case + compiled = safe_eval("(user_id := 42)", validate_assign=is_safe_assign) + context = {} + result = compiled(context) + assert result == 42 + assert context == {"user_id": 42} + + # Failure case + compiled = safe_eval( + "(app_config := {'key': 'value'})", validate_assign=is_safe_assign + ) + context = {} + with pytest.raises( + SecurityError, match="assignment to variable 'app_config' is unsafe" + ): + compiled(context) + assert context == {} + + def test_validate_variable(self): + # Create a validator that blocks variables containing "internal" + def is_safe_variable(var_name): + return "internal" not in var_name.lower() + + # Success cases + compiled = safe_eval("public_var", validate_variable=is_safe_variable) + context = {"public_var": "public_value"} + result = compiled(context) + assert result == "public_value" + + compiled = safe_eval("inter_nal_data", validate_variable=is_safe_variable) + context = {"inter_nal_data": "internal_value"} + result = compiled(context) + assert result == "internal_value" + + # Failure cases + compiled = safe_eval("internal_var", validate_variable=is_safe_variable) + context = {"internal_var": "secret"} + with pytest.raises(SecurityError, match="variable 'internal_var' is unsafe"): + compiled(context) + + compiled = safe_eval("my_internal_data", validate_variable=is_safe_variable) + context = {"my_internal_data": "data"} + with pytest.raises( + SecurityError, match="variable 'my_internal_data' is unsafe" + ): + compiled(context) + + +class TestErrorReporting: + def test_error_variable(self): + compiled = safe_eval("1 + _unsafe_var + 1") + context = {"_unsafe_var": 42} + with pytest.raises( + SecurityError, + match=re.escape( + "Error in variable: SecurityError: variable '_unsafe_var' is unsafe\n\n" + " 1 | 1 + _unsafe_var + 1\n" + " ^^^^^^^^^^^" + ), + ): + compiled(context) + + def test_error_attribute(self): + compiled = safe_eval("1 + obj._private + 1") + context = {"obj": Obj()} + with pytest.raises( + SecurityError, + match=re.escape( + "Error in attribute: SecurityError: attribute '_private' on object '' is unsafe\n\n" + " 1 | 1 + obj._private + 1\n" + " ^^^^^^^^^^^" + ), + ): + compiled(context) + + def test_error_subscript(self): + def is_safe_subscript(obj, key): + return False + + compiled = safe_eval( + "1 + data['key'] + 1", validate_subscript=is_safe_subscript + ) + context = {"data": {"key": "value"}} + with pytest.raises( + SecurityError, + match=re.escape( + "Error in subscript: SecurityError: key 'key' on object '' is unsafe\n\n" + " 1 | 1 + data['key'] + 1\n" + " ^^^^^^^^^^^" + ), + ): + compiled(context) + + def test_error_call(self): + compiled = safe_eval("1 + eval('1+1') + 1") + context = {"eval": eval} + with pytest.raises( + SecurityError, + match=re.escape( + "Error in call: SecurityError: function '' is unsafe\n\n" + " 1 | 1 + eval('1+1') + 1\n" + " ^^^^^^^^^^^" + ), + ): + compiled(context) + + def test_error_assign(self): + compiled = safe_eval("1 + (_unsafe := 42) + 1") + context = {} + with pytest.raises( + SecurityError, + match=re.escape( + "Error in assign: SecurityError: variable '_unsafe' is unsafe\n\n" + " 1 | 1 + (_unsafe := 42) + 1\n" + " ^^^^^^^^^^^^^" + ), + ): + compiled(context) + + def test_error_slice(self): + compiled = safe_eval("1 + list[x + 1:y - 1] + 1") + context = {"list": [1, 2, 3, 4, 5], "x": None, "y": None} + with pytest.raises( + TypeError, + match=re.escape( + "unsupported operand type(s) for +: 'NoneType' and 'int'\n\n" + " 1 | 1 + list[x + 1:y - 1] + 1\n" + ), + ): + compiled(context) + + def test_error_fstring(self): + compiled = safe_eval("1 + f'{a + 2}' + 1") + context = {"a": "a"} + with pytest.raises( + TypeError, + match=re.escape( + 'can only concatenate str (not "int") to str\n\n' + " 1 | 1 + f'{a + 2}' + 1\n" + " ^^^^^^^^^^^^^^^^^^" + ), + ): + compiled(context) + + def test_error_multi_line(self): + source = "1 + fn([\n 1,\n 2,\n 3,\n]) + 1" + compiled = safe_eval(source) + context = {"fn": eval} + with pytest.raises( + SecurityError, + match=re.escape( + "Error in call: SecurityError: function '' is unsafe\n\n" + " 1 | 1 + fn([\n" + " ^^^^\n" + " 2 | 1,\n" + " ^^^^^^\n" + " 3 | 2,\n" + " ^^^^^^\n" + " 4 | 3,\n" + " ^^^^^^\n" + " 5 | ]) + 1\n" + " ^^" + ), + ): + compiled(context)