From 02dbcf4e996873a18060f470a4a7c96bfef7a43d Mon Sep 17 00:00:00 2001 From: "J.J. Tolton" Date: Sun, 30 Nov 2025 13:52:12 -0500 Subject: [PATCH 01/10] Add clj-kondo support for libpython-clj2 namespaces - Add hook for libpython-clj2.require/import-python (v2 namespace) - Add lint-as rules for require-python (v1 and v2) - Add :unresolved-namespace exclusions for python.* namespaces - Extend :unused-namespace exclusions to include python.* aliases The existing config only covered the legacy libpython-clj namespace. Users of libpython-clj2 were getting various warnings: - "Unresolved namespace python" when using python.str, python.list, etc. - "Unknown require option: :bind-ns" from require-python Note: py., py.., py.-, py*, py** macros are now natively supported in clj-kondo 2025.04.07+ (issue #2512) and don't need special config. Closes #268 --- .../clj-python/libpython-clj/config.edn | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn b/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn index cab3e49..134d32d 100644 --- a/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn +++ b/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn @@ -2,12 +2,43 @@ {:analyze-call {libpython-clj.jna.base/def-pylib-fn hooks.libpython-clj.jna.base.def-pylib-fn/def-pylib-fn libpython-clj.require/import-python + hooks.libpython-clj.require.import-python/import-python + libpython-clj2.require/import-python hooks.libpython-clj.require.import-python/import-python}} - :linters {:unused-namespace {:exclude [builtins.list - builtins.dict - builtins.set - builtins.tuple - builtins.frozenset - builtins.str - builtins]}} -} + + :lint-as + {libpython-clj.require/require-python clojure.core/require + libpython-clj2.require/require-python clojure.core/require} + + :linters + {:unresolved-namespace + {:exclude [python + python.list + python.dict + python.set + python.tuple + python.frozenset + python.str + builtins + builtins.list + builtins.dict + builtins.set + builtins.tuple + builtins.frozenset + builtins.str]} + + :unused-namespace + {:exclude [builtins.list + builtins.dict + builtins.set + builtins.tuple + builtins.frozenset + builtins.str + builtins + python + python.list + python.dict + python.set + python.tuple + python.frozenset + python.str]}}} From 700e12532c9bceabe4ce9deb9008319ae3f2d386 Mon Sep 17 00:00:00 2001 From: "J.J. Tolton" Date: Sun, 30 Nov 2025 14:31:40 -0500 Subject: [PATCH 02/10] Add clj-kondo hook for require-python macro - Add require_python.clj hook that transforms require-python calls into standard require forms that clj-kondo can analyze - Strip libpython-specific flags: :bind-ns, :reload, :no-arglists - Generate def forms for :bind-ns vars and :refer symbols - Handle :refer with vectors, :all, or :* - Support both v1 (libpython-clj.require) and v2 (libpython-clj2.require) - Replace :lint-as approach with proper analyze-call hooks This eliminates false positive "Unknown require option" warnings for libpython-clj's Python interop macros. --- .../clj-python/libpython-clj/config.edn | 10 +- .../libpython_clj/require/require_python.clj | 106 ++++++++++++++++++ 2 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 resources/clj-kondo.exports/clj-python/libpython-clj/hooks/libpython_clj/require/require_python.clj diff --git a/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn b/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn index 134d32d..d9b6adb 100644 --- a/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn +++ b/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn @@ -1,14 +1,16 @@ {:hooks {:analyze-call {libpython-clj.jna.base/def-pylib-fn hooks.libpython-clj.jna.base.def-pylib-fn/def-pylib-fn + libpython-clj.require/import-python hooks.libpython-clj.require.import-python/import-python libpython-clj2.require/import-python - hooks.libpython-clj.require.import-python/import-python}} + hooks.libpython-clj.require.import-python/import-python - :lint-as - {libpython-clj.require/require-python clojure.core/require - libpython-clj2.require/require-python clojure.core/require} + libpython-clj.require/require-python + hooks.libpython-clj.require.require-python/require-python + libpython-clj2.require/require-python + hooks.libpython-clj.require.require-python/require-python}} :linters {:unresolved-namespace diff --git a/resources/clj-kondo.exports/clj-python/libpython-clj/hooks/libpython_clj/require/require_python.clj b/resources/clj-kondo.exports/clj-python/libpython-clj/hooks/libpython_clj/require/require_python.clj new file mode 100644 index 0000000..59b6668 --- /dev/null +++ b/resources/clj-kondo.exports/clj-python/libpython-clj/hooks/libpython_clj/require/require_python.clj @@ -0,0 +1,106 @@ +(ns hooks.libpython-clj.require.require-python + (:require [clj-kondo.hooks-api :as api])) + +(def ^:private libpython-unary-flags + #{:bind-ns :reload :no-arglists}) + +(def ^:private libpython-binary-flags + #{:refer}) + +(defn- get-sexpr [node] + (when node + (try + (api/sexpr node) + (catch Exception _ nil)))) + +(defn- quoted-form? + [sexpr] + (and (seq? sexpr) + (= 'quote (first sexpr)))) + +(defn- unquote-sexpr + [sexpr] + (if (quoted-form? sexpr) + (second sexpr) + sexpr)) + +(defn- extract-bind-ns-var + [spec-data] + (let [pairs (partition 2 (rest spec-data)) + bind-ns-val (some (fn [[k v]] (when (= :bind-ns k) v)) pairs) + as-val (some (fn [[k v]] (when (= :as k) v)) pairs)] + (when bind-ns-val + (or as-val + (when-let [first-sym (first spec-data)] + (symbol (last (clojure.string/split (str first-sym) #"\.")))))))) + +(defn- extract-refer-symbols + [spec-data] + (let [pairs (partition 2 (rest spec-data)) + refer-val (some (fn [[k v]] (when (= :refer k) v)) pairs)] + (when (and refer-val (vector? refer-val)) + refer-val))) + +(defn- filter-spec-data + [spec-data] + (loop [result [] + remaining (rest spec-data)] + (if (empty? remaining) + (vec (cons (first spec-data) result)) + (let [item (first remaining) + next-item (second remaining)] + (cond + (libpython-unary-flags item) + (if (boolean? next-item) + (recur result (drop 2 remaining)) + (recur result (rest remaining))) + + (libpython-binary-flags item) + (recur result (drop 2 remaining)) + + :else + (recur (conj result item) (rest remaining))))))) + +(defn- process-spec-data + [sexpr] + (let [unquoted (unquote-sexpr sexpr)] + (cond + (vector? unquoted) + {:spec-data (filter-spec-data unquoted) + :bind-ns-var (extract-bind-ns-var unquoted) + :refer-symbols (extract-refer-symbols unquoted)} + + (symbol? unquoted) + {:spec-data unquoted + :bind-ns-var nil + :refer-symbols nil} + + :else + {:spec-data unquoted + :bind-ns-var nil + :refer-symbols nil}))) + +(defn- make-require-form + [specs] + (list* 'require + (map (fn [spec] (list 'quote spec)) specs))) + +(defn- make-def-form + [var-name] + (list 'def var-name nil)) + +(defn require-python + [{:keys [node]}] + (let [form (get-sexpr node) + args (rest form) + processed (map process-spec-data args) + spec-data-list (map :spec-data processed) + bind-ns-vars (filter some? (map :bind-ns-var processed)) + refer-symbols (mapcat :refer-symbols processed) + require-form (make-require-form spec-data-list) + def-forms (map make-def-form (concat bind-ns-vars refer-symbols)) + result-form (if (seq def-forms) + (list* 'do require-form def-forms) + require-form) + result-node (api/coerce result-form)] + {:node (with-meta result-node (meta node))})) From 958002933192588fa0adb03f6c57060927cad492 Mon Sep 17 00:00:00 2001 From: "J.J. Tolton" Date: Sun, 30 Nov 2025 14:39:53 -0500 Subject: [PATCH 03/10] Add CI tests for clj-kondo hooks - Add test fixtures exercising all require-python patterns - Add test-clj-kondo-hooks.sh for direct hook testing - Add test-copy-configs.sh for end-to-end config testing - Add clj-kondo-hooks CI job (runs before unit tests, no JVM/Python needed) Test coverage includes: - :bind-ns, :reload, :no-arglists flags - :refer with vectors, :all, and :* - Multiple specs in single require-python call - import-python builtin namespace aliases --- .github/workflows/test.yml | 17 +++ script/test-clj-kondo-hooks.sh | 66 ++++++++++++ script/test-copy-configs.sh | 100 ++++++++++++++++++ .../fixtures/require_python_edge_cases.clj | 18 ++++ .../fixtures/require_python_test.clj | 49 +++++++++ 5 files changed, 250 insertions(+) create mode 100755 script/test-clj-kondo-hooks.sh create mode 100755 script/test-copy-configs.sh create mode 100644 test/clj_kondo/fixtures/require_python_edge_cases.clj create mode 100644 test/clj_kondo/fixtures/require_python_test.clj diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 06bbc99..3912c0f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,23 @@ on: pull_request: jobs: + clj-kondo-hooks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install clj-kondo + run: | + curl -sLO https://raw.githubusercontent.com/clj-kondo/clj-kondo/master/script/install-clj-kondo + chmod +x install-clj-kondo + sudo ./install-clj-kondo + + - name: Test clj-kondo hooks + run: ./script/test-clj-kondo-hooks.sh + + - name: Test end-to-end config + run: ./script/test-copy-configs.sh + unit-test: runs-on: ${{matrix.os}} strategy: diff --git a/script/test-clj-kondo-hooks.sh b/script/test-clj-kondo-hooks.sh new file mode 100755 index 0000000..0a5d2e6 --- /dev/null +++ b/script/test-clj-kondo-hooks.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +CONFIG_DIR="$PROJECT_ROOT/resources/clj-kondo.exports/clj-python/libpython-clj" +FIXTURES_DIR="$PROJECT_ROOT/test/clj_kondo/fixtures" + +echo "=== clj-kondo Hook Tests ===" +echo "Config dir: $CONFIG_DIR" +echo "Fixtures dir: $FIXTURES_DIR" +echo "" + +if ! command -v clj-kondo &> /dev/null; then + echo "ERROR: clj-kondo not found in PATH" + exit 1 +fi + +echo "clj-kondo version: $(clj-kondo --version)" +echo "" + +FAILED=0 + +run_test() { + local name="$1" + local file="$2" + + echo -n "Testing $name... " + + OUTPUT=$(clj-kondo --lint "$file" --config-dir "$CONFIG_DIR" 2>&1) || true + + REQUIRE_PYTHON_ERRORS=$(echo "$OUTPUT" | grep -cE "(Unknown require option|:bind-ns|:reload|:no-arglists)" || true) + + if [[ "$REQUIRE_PYTHON_ERRORS" -gt 0 ]]; then + echo "FAILED" + echo " Found require-python related errors/warnings:" + echo "$OUTPUT" | grep -E "(Unknown require option|:bind-ns|:reload|:no-arglists)" | sed 's/^/ /' + FAILED=1 + return 1 + fi + + UNRESOLVED_SYMBOL_ERRORS=$(echo "$OUTPUT" | grep -cE "Unresolved symbol: (webpush|secure_filename|urlencode|urlparse)" || true) + + if [[ "$UNRESOLVED_SYMBOL_ERRORS" -gt 0 ]]; then + echo "FAILED" + echo " Found unresolved symbol errors for referred symbols:" + echo "$OUTPUT" | grep -E "Unresolved symbol:" | sed 's/^/ /' + FAILED=1 + return 1 + fi + + echo "PASSED" + return 0 +} + +run_test "require_python_test.clj" "$FIXTURES_DIR/require_python_test.clj" +run_test "require_python_edge_cases.clj" "$FIXTURES_DIR/require_python_edge_cases.clj" + +echo "" +if [[ "$FAILED" -eq 0 ]]; then + echo "=== All tests passed ===" + exit 0 +else + echo "=== Some tests failed ===" + exit 1 +fi diff --git a/script/test-copy-configs.sh b/script/test-copy-configs.sh new file mode 100755 index 0000000..a52a047 --- /dev/null +++ b/script/test-copy-configs.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +CONFIG_SOURCE="$PROJECT_ROOT/resources/clj-kondo.exports/clj-python/libpython-clj" +TEST_PROJECT="/tmp/libpython-clj-kondo-test-$$" + +echo "=== End-to-End clj-kondo Config Test ===" +echo "Project root: $PROJECT_ROOT" +echo "Config source: $CONFIG_SOURCE" +echo "Test project: $TEST_PROJECT" +echo "" +echo "Note: This test simulates what 'clj-kondo --copy-configs' does when" +echo " libpython-clj is distributed as a JAR (which includes resources/)." +echo "" + +cleanup() { + if [[ -d "$TEST_PROJECT" ]]; then + rm -rf "$TEST_PROJECT" + fi +} +trap cleanup EXIT + +mkdir -p "$TEST_PROJECT/src" +mkdir -p "$TEST_PROJECT/.clj-kondo/clj-python" + +cp -r "$CONFIG_SOURCE" "$TEST_PROJECT/.clj-kondo/clj-python/libpython-clj" + +cat > "$TEST_PROJECT/src/test_app.clj" << 'EOF' +(ns test-app + (:require [libpython-clj2.require :refer [require-python import-python]])) + +(import-python) + +(require-python '[numpy :as np] + '[pathlib :bind-ns true] + '[pywebpush :bind-ns true :refer [webpush]] + '[werkzeug.utils :refer [secure_filename]] + '[sklearn :reload true] + '[pandas :no-arglists true]) + +(defn main [] + (pathlib/Path "/tmp") + (webpush {}) + (secure_filename "test.txt") + (np/array [1 2 3]) + (python/len [1 2 3])) +EOF + +echo "Step 1: Created test project with copied configs" +echo "" + +cd "$TEST_PROJECT" + +echo "Step 2: Verifying config structure..." +if [[ -f "$TEST_PROJECT/.clj-kondo/clj-python/libpython-clj/config.edn" ]]; then + echo " ✓ config.edn exists" +else + echo " ✗ config.edn NOT found" + exit 1 +fi + +if [[ -f "$TEST_PROJECT/.clj-kondo/clj-python/libpython-clj/hooks/libpython_clj/require/require_python.clj" ]]; then + echo " ✓ require_python.clj hook exists" +else + echo " ✗ require_python.clj hook NOT found" + exit 1 +fi + +if [[ -f "$TEST_PROJECT/.clj-kondo/clj-python/libpython-clj/hooks/libpython_clj/require/import_python.clj" ]]; then + echo " ✓ import_python.clj hook exists" +else + echo " ✗ import_python.clj hook NOT found" + exit 1 +fi +echo "" + +echo "Step 3: Running clj-kondo on test file..." +OUTPUT=$(clj-kondo --lint "$TEST_PROJECT/src/test_app.clj" 2>&1) || true +echo "$OUTPUT" +echo "" + +REQUIRE_PYTHON_ERRORS=$(echo "$OUTPUT" | grep -cE "(Unknown require option|:bind-ns|:reload|:no-arglists)" || true) +UNRESOLVED_REFER_ERRORS=$(echo "$OUTPUT" | grep -cE "Unresolved symbol: (webpush|secure_filename)" || true) + +if [[ "$REQUIRE_PYTHON_ERRORS" -gt 0 ]]; then + echo "✗ FAILED: Found require-python related errors" + exit 1 +fi + +if [[ "$UNRESOLVED_REFER_ERRORS" -gt 0 ]]; then + echo "✗ FAILED: Found unresolved symbol errors for :refer symbols" + exit 1 +fi + +echo "=== All end-to-end tests passed ===" +echo "" +echo "The clj-kondo config will work correctly when libpython-clj is" +echo "installed via clj-kondo --copy-configs --dependencies" diff --git a/test/clj_kondo/fixtures/require_python_edge_cases.clj b/test/clj_kondo/fixtures/require_python_edge_cases.clj new file mode 100644 index 0000000..55ef4a1 --- /dev/null +++ b/test/clj_kondo/fixtures/require_python_edge_cases.clj @@ -0,0 +1,18 @@ +(ns clj-kondo.fixtures.require-python-edge-cases + "Edge case tests for require-python clj-kondo hook." + (:require [libpython-clj2.require :refer [require-python]])) + +(require-python '[json :refer :all]) + +(require-python '[collections :refer :*]) + +(require-python '[datetime :bind-ns true :reload true :no-arglists true]) + +(require-python '[urllib.parse :as parse :bind-ns true :refer [urlencode urlparse]]) + +(defn test-refer-all [] + (datetime/now)) + +(defn test-combined-refer [] + (urlencode {}) + (urlparse "http://example.com")) diff --git a/test/clj_kondo/fixtures/require_python_test.clj b/test/clj_kondo/fixtures/require_python_test.clj new file mode 100644 index 0000000..b58319a --- /dev/null +++ b/test/clj_kondo/fixtures/require_python_test.clj @@ -0,0 +1,49 @@ +(ns clj-kondo.fixtures.require-python-test + "Test fixtures for require-python clj-kondo hook. + This file should produce ZERO errors when linted with the hook." + (:require [libpython-clj2.require :refer [require-python import-python]])) + +(import-python) + +(require-python '[numpy :as np] + '[pandas :as pd] + '[matplotlib.pyplot :as plt]) + +(require-python '[pathlib :bind-ns true]) + +(require-python '[requests :reload true]) + +(require-python '[sklearn.linear_model :no-arglists true]) + +(require-python '[pywebpush :bind-ns true :refer [webpush]]) + +(require-python '[werkzeug.utils :refer [secure_filename]]) + +(require-python '[google.cloud.secretmanager :as secretmanager :bind-ns true]) + +(require-python 'operator + 'base64 + 'socket) + +(require-python '[cryptography.fernet.Fernet :as Fernet :bind-ns true] + '[cryptography.hazmat.primitives.hashes :as hashes :bind-ns true]) + +(require-python '[os :reload]) + +(defn test-bind-ns-usage [] + (pathlib/Path "/tmp") + (Fernet/generate_key)) + +(defn test-refer-usage [] + (webpush {}) + (secure_filename "test.txt")) + +(defn test-alias-usage [] + (np/array [1 2 3]) + (pd/DataFrame {}) + (secretmanager/SecretManagerServiceClient)) + +(defn test-python-builtins [] + (python/len [1 2 3]) + (python.list/append [] 1) + (python.dict/keys {})) From cc5d723c4dae9d197b0e5eee6d36cac1c28c1c48 Mon Sep 17 00:00:00 2001 From: "J.J. Tolton" Date: Sun, 30 Nov 2025 14:55:13 -0500 Subject: [PATCH 04/10] Add :clj-kondo-test alias for local testing Adds a deps.edn alias to run clj-kondo hook tests locally: clj -M:clj-kondo-test This runs the same hook verification tests as CI but via the Clojure test-runner instead of shell scripts. --- deps.edn | 8 ++++++++ test/clj_kondo/hook_test.clj | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 test/clj_kondo/hook_test.clj diff --git a/deps.edn b/deps.edn index 5aefc5f..708af1a 100644 --- a/deps.edn +++ b/deps.edn @@ -71,4 +71,12 @@ :exec-args {:installer :local :artifact "target/libpython-clj.jar"}} + :clj-kondo-test + {:extra-deps {com.cognitect/test-runner + {:git/url "https://github.com/cognitect-labs/test-runner" + :sha "209b64504cb3bd3b99ecfec7937b358a879f55c1"}} + :extra-paths ["test"] + :main-opts ["-m" "cognitect.test-runner" + "-n" "clj-kondo.hook-test"]} + }} diff --git a/test/clj_kondo/hook_test.clj b/test/clj_kondo/hook_test.clj new file mode 100644 index 0000000..49bd1c5 --- /dev/null +++ b/test/clj_kondo/hook_test.clj @@ -0,0 +1,37 @@ +(ns clj-kondo.hook-test + (:require [clojure.test :refer [deftest testing is]] + [clojure.java.shell :refer [sh]] + [clojure.string :as str])) + +(def ^:private config-dir + "resources/clj-kondo.exports/clj-python/libpython-clj") + +(def ^:private fixtures-dir + "test/clj_kondo/fixtures") + +(defn- run-clj-kondo [file] + (sh "clj-kondo" "--lint" file "--config-dir" config-dir)) + +(defn- has-require-python-errors? [output] + (boolean (re-find #"(Unknown require option|:bind-ns|:reload|:no-arglists)" output))) + +(defn- has-unresolved-refer-errors? [output] + (boolean (re-find #"Unresolved symbol: (webpush|secure_filename|urlencode|urlparse)" output))) + +(deftest require-python-hook-test + (testing "require_python_test.clj - basic require-python usage" + (let [{:keys [out err]} (run-clj-kondo (str fixtures-dir "/require_python_test.clj")) + output (str out err)] + (is (not (has-require-python-errors? output)) + (str "Found require-python errors in output:\n" output)) + (is (not (has-unresolved-refer-errors? output)) + (str "Found unresolved symbol errors for referred symbols:\n" output))))) + +(deftest require-python-edge-cases-test + (testing "require_python_edge_cases.clj - edge cases and variations" + (let [{:keys [out err]} (run-clj-kondo (str fixtures-dir "/require_python_edge_cases.clj")) + output (str out err)] + (is (not (has-require-python-errors? output)) + (str "Found require-python errors in output:\n" output)) + (is (not (has-unresolved-refer-errors? output)) + (str "Found unresolved symbol errors for referred symbols:\n" output))))) From 6f1cc55df51fdbb3b87a8a8850f2973132251d66 Mon Sep 17 00:00:00 2001 From: "J.J. Tolton" Date: Sun, 30 Nov 2025 16:02:34 -0500 Subject: [PATCH 05/10] Remove shell scripts, use Clojure test in CI --- .github/workflows/test.yml | 10 ++-- script/test-clj-kondo-hooks.sh | 66 ---------------------- script/test-copy-configs.sh | 100 --------------------------------- 3 files changed, 6 insertions(+), 170 deletions(-) delete mode 100755 script/test-clj-kondo-hooks.sh delete mode 100755 script/test-copy-configs.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3912c0f..d4b453b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,6 +15,11 @@ jobs: steps: - uses: actions/checkout@v3 + - name: Install Clojure + uses: DeLaGuardo/setup-clojure@11.0 + with: + cli: 1.11.1.1347 + - name: Install clj-kondo run: | curl -sLO https://raw.githubusercontent.com/clj-kondo/clj-kondo/master/script/install-clj-kondo @@ -22,10 +27,7 @@ jobs: sudo ./install-clj-kondo - name: Test clj-kondo hooks - run: ./script/test-clj-kondo-hooks.sh - - - name: Test end-to-end config - run: ./script/test-copy-configs.sh + run: clojure -M:clj-kondo-test unit-test: runs-on: ${{matrix.os}} diff --git a/script/test-clj-kondo-hooks.sh b/script/test-clj-kondo-hooks.sh deleted file mode 100755 index 0a5d2e6..0000000 --- a/script/test-clj-kondo-hooks.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -CONFIG_DIR="$PROJECT_ROOT/resources/clj-kondo.exports/clj-python/libpython-clj" -FIXTURES_DIR="$PROJECT_ROOT/test/clj_kondo/fixtures" - -echo "=== clj-kondo Hook Tests ===" -echo "Config dir: $CONFIG_DIR" -echo "Fixtures dir: $FIXTURES_DIR" -echo "" - -if ! command -v clj-kondo &> /dev/null; then - echo "ERROR: clj-kondo not found in PATH" - exit 1 -fi - -echo "clj-kondo version: $(clj-kondo --version)" -echo "" - -FAILED=0 - -run_test() { - local name="$1" - local file="$2" - - echo -n "Testing $name... " - - OUTPUT=$(clj-kondo --lint "$file" --config-dir "$CONFIG_DIR" 2>&1) || true - - REQUIRE_PYTHON_ERRORS=$(echo "$OUTPUT" | grep -cE "(Unknown require option|:bind-ns|:reload|:no-arglists)" || true) - - if [[ "$REQUIRE_PYTHON_ERRORS" -gt 0 ]]; then - echo "FAILED" - echo " Found require-python related errors/warnings:" - echo "$OUTPUT" | grep -E "(Unknown require option|:bind-ns|:reload|:no-arglists)" | sed 's/^/ /' - FAILED=1 - return 1 - fi - - UNRESOLVED_SYMBOL_ERRORS=$(echo "$OUTPUT" | grep -cE "Unresolved symbol: (webpush|secure_filename|urlencode|urlparse)" || true) - - if [[ "$UNRESOLVED_SYMBOL_ERRORS" -gt 0 ]]; then - echo "FAILED" - echo " Found unresolved symbol errors for referred symbols:" - echo "$OUTPUT" | grep -E "Unresolved symbol:" | sed 's/^/ /' - FAILED=1 - return 1 - fi - - echo "PASSED" - return 0 -} - -run_test "require_python_test.clj" "$FIXTURES_DIR/require_python_test.clj" -run_test "require_python_edge_cases.clj" "$FIXTURES_DIR/require_python_edge_cases.clj" - -echo "" -if [[ "$FAILED" -eq 0 ]]; then - echo "=== All tests passed ===" - exit 0 -else - echo "=== Some tests failed ===" - exit 1 -fi diff --git a/script/test-copy-configs.sh b/script/test-copy-configs.sh deleted file mode 100755 index a52a047..0000000 --- a/script/test-copy-configs.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -CONFIG_SOURCE="$PROJECT_ROOT/resources/clj-kondo.exports/clj-python/libpython-clj" -TEST_PROJECT="/tmp/libpython-clj-kondo-test-$$" - -echo "=== End-to-End clj-kondo Config Test ===" -echo "Project root: $PROJECT_ROOT" -echo "Config source: $CONFIG_SOURCE" -echo "Test project: $TEST_PROJECT" -echo "" -echo "Note: This test simulates what 'clj-kondo --copy-configs' does when" -echo " libpython-clj is distributed as a JAR (which includes resources/)." -echo "" - -cleanup() { - if [[ -d "$TEST_PROJECT" ]]; then - rm -rf "$TEST_PROJECT" - fi -} -trap cleanup EXIT - -mkdir -p "$TEST_PROJECT/src" -mkdir -p "$TEST_PROJECT/.clj-kondo/clj-python" - -cp -r "$CONFIG_SOURCE" "$TEST_PROJECT/.clj-kondo/clj-python/libpython-clj" - -cat > "$TEST_PROJECT/src/test_app.clj" << 'EOF' -(ns test-app - (:require [libpython-clj2.require :refer [require-python import-python]])) - -(import-python) - -(require-python '[numpy :as np] - '[pathlib :bind-ns true] - '[pywebpush :bind-ns true :refer [webpush]] - '[werkzeug.utils :refer [secure_filename]] - '[sklearn :reload true] - '[pandas :no-arglists true]) - -(defn main [] - (pathlib/Path "/tmp") - (webpush {}) - (secure_filename "test.txt") - (np/array [1 2 3]) - (python/len [1 2 3])) -EOF - -echo "Step 1: Created test project with copied configs" -echo "" - -cd "$TEST_PROJECT" - -echo "Step 2: Verifying config structure..." -if [[ -f "$TEST_PROJECT/.clj-kondo/clj-python/libpython-clj/config.edn" ]]; then - echo " ✓ config.edn exists" -else - echo " ✗ config.edn NOT found" - exit 1 -fi - -if [[ -f "$TEST_PROJECT/.clj-kondo/clj-python/libpython-clj/hooks/libpython_clj/require/require_python.clj" ]]; then - echo " ✓ require_python.clj hook exists" -else - echo " ✗ require_python.clj hook NOT found" - exit 1 -fi - -if [[ -f "$TEST_PROJECT/.clj-kondo/clj-python/libpython-clj/hooks/libpython_clj/require/import_python.clj" ]]; then - echo " ✓ import_python.clj hook exists" -else - echo " ✗ import_python.clj hook NOT found" - exit 1 -fi -echo "" - -echo "Step 3: Running clj-kondo on test file..." -OUTPUT=$(clj-kondo --lint "$TEST_PROJECT/src/test_app.clj" 2>&1) || true -echo "$OUTPUT" -echo "" - -REQUIRE_PYTHON_ERRORS=$(echo "$OUTPUT" | grep -cE "(Unknown require option|:bind-ns|:reload|:no-arglists)" || true) -UNRESOLVED_REFER_ERRORS=$(echo "$OUTPUT" | grep -cE "Unresolved symbol: (webpush|secure_filename)" || true) - -if [[ "$REQUIRE_PYTHON_ERRORS" -gt 0 ]]; then - echo "✗ FAILED: Found require-python related errors" - exit 1 -fi - -if [[ "$UNRESOLVED_REFER_ERRORS" -gt 0 ]]; then - echo "✗ FAILED: Found unresolved symbol errors for :refer symbols" - exit 1 -fi - -echo "=== All end-to-end tests passed ===" -echo "" -echo "The clj-kondo config will work correctly when libpython-clj is" -echo "installed via clj-kondo --copy-configs --dependencies" From 36e467752c0a8251e63b27a631d9b6d5311e3d18 Mon Sep 17 00:00:00 2001 From: "J.J. Tolton" Date: Sun, 30 Nov 2025 16:08:54 -0500 Subject: [PATCH 06/10] Add py. and py.. hooks --- .../clj-python/libpython-clj/config.edn | 7 ++++++- .../hooks/libpython_clj/python/py_dot_dot.clj | 12 ++++++++++++ test/clj_kondo/fixtures/py_dot_test.clj | 7 +++++++ test/clj_kondo/hook_test.clj | 10 ++++++++++ 4 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 resources/clj-kondo.exports/clj-python/libpython-clj/hooks/libpython_clj/python/py_dot_dot.clj create mode 100644 test/clj_kondo/fixtures/py_dot_test.clj diff --git a/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn b/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn index d9b6adb..cd82b7c 100644 --- a/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn +++ b/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn @@ -10,7 +10,12 @@ libpython-clj.require/require-python hooks.libpython-clj.require.require-python/require-python libpython-clj2.require/require-python - hooks.libpython-clj.require.require-python/require-python}} + hooks.libpython-clj.require.require-python/require-python + + libpython-clj2.python/py.. + hooks.libpython-clj.python.py-dot-dot/py.. + libpython-clj2.python/py. + hooks.libpython-clj.python.py-dot-dot/py..}} :linters {:unresolved-namespace diff --git a/resources/clj-kondo.exports/clj-python/libpython-clj/hooks/libpython_clj/python/py_dot_dot.clj b/resources/clj-kondo.exports/clj-python/libpython-clj/hooks/libpython_clj/python/py_dot_dot.clj new file mode 100644 index 0000000..dc55384 --- /dev/null +++ b/resources/clj-kondo.exports/clj-python/libpython-clj/hooks/libpython_clj/python/py_dot_dot.clj @@ -0,0 +1,12 @@ +(ns hooks.libpython-clj.python.py-dot-dot + (:require [clj-kondo.hooks-api :as api])) + +(defn py.. + "Transform py.. to just evaluate the object, ignoring method/attribute symbols. + (py.. obj method arg) -> obj" + [{:keys [node]}] + (let [children (:children node) + obj-node (second children)] + (if obj-node + {:node obj-node} + {:node (api/token-node nil)}))) diff --git a/test/clj_kondo/fixtures/py_dot_test.clj b/test/clj_kondo/fixtures/py_dot_test.clj new file mode 100644 index 0000000..b7606ec --- /dev/null +++ b/test/clj_kondo/fixtures/py_dot_test.clj @@ -0,0 +1,7 @@ +(ns clj-kondo.fixtures.py-dot-test + (:require [libpython-clj2.python :refer [py. py..]])) + +(defn test-py-dot [] + (let [obj {:foo "bar"}] + (py. obj method arg1 arg2) + (py.. obj method1 method2 method3))) diff --git a/test/clj_kondo/hook_test.clj b/test/clj_kondo/hook_test.clj index 49bd1c5..1752823 100644 --- a/test/clj_kondo/hook_test.clj +++ b/test/clj_kondo/hook_test.clj @@ -35,3 +35,13 @@ (str "Found require-python errors in output:\n" output)) (is (not (has-unresolved-refer-errors? output)) (str "Found unresolved symbol errors for referred symbols:\n" output))))) + +(defn- has-py-dot-errors? [output] + (boolean (re-find #"(Unresolved symbol: py\.|unresolved var.*py\.)" output))) + +(deftest py-dot-hook-test + (testing "py_dot_test.clj - py. and py.. macros" + (let [{:keys [out err]} (run-clj-kondo (str fixtures-dir "/py_dot_test.clj")) + output (str out err)] + (is (not (has-py-dot-errors? output)) + (str "Found py. related errors in output:\n" output))))) From 6fb4f8e391f5a649a4095dbfe17fff769d2f0d0c Mon Sep 17 00:00:00 2001 From: "J.J. Tolton" Date: Sun, 30 Nov 2025 16:11:35 -0500 Subject: [PATCH 07/10] Add hooks for all py macros (py. py.. py.- py* py**) --- .../clj-python/libpython-clj/config.edn | 12 +++++++++--- .../hooks/libpython_clj/python/py_dot_dot.clj | 12 ------------ .../hooks/libpython_clj/python/py_macros.clj | 16 ++++++++++++++++ test/clj_kondo/fixtures/py_dot_test.clj | 9 ++++++--- 4 files changed, 31 insertions(+), 18 deletions(-) delete mode 100644 resources/clj-kondo.exports/clj-python/libpython-clj/hooks/libpython_clj/python/py_dot_dot.clj create mode 100644 resources/clj-kondo.exports/clj-python/libpython-clj/hooks/libpython_clj/python/py_macros.clj diff --git a/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn b/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn index cd82b7c..ca64848 100644 --- a/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn +++ b/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn @@ -12,10 +12,16 @@ libpython-clj2.require/require-python hooks.libpython-clj.require.require-python/require-python - libpython-clj2.python/py.. - hooks.libpython-clj.python.py-dot-dot/py.. libpython-clj2.python/py. - hooks.libpython-clj.python.py-dot-dot/py..}} + hooks.libpython-clj.python.py-macros/py-macro + libpython-clj2.python/py.. + hooks.libpython-clj.python.py-macros/py-macro + libpython-clj2.python/py.- + hooks.libpython-clj.python.py-macros/py-macro + libpython-clj2.python/py* + hooks.libpython-clj.python.py-macros/py-macro + libpython-clj2.python/py** + hooks.libpython-clj.python.py-macros/py-macro}} :linters {:unresolved-namespace diff --git a/resources/clj-kondo.exports/clj-python/libpython-clj/hooks/libpython_clj/python/py_dot_dot.clj b/resources/clj-kondo.exports/clj-python/libpython-clj/hooks/libpython_clj/python/py_dot_dot.clj deleted file mode 100644 index dc55384..0000000 --- a/resources/clj-kondo.exports/clj-python/libpython-clj/hooks/libpython_clj/python/py_dot_dot.clj +++ /dev/null @@ -1,12 +0,0 @@ -(ns hooks.libpython-clj.python.py-dot-dot - (:require [clj-kondo.hooks-api :as api])) - -(defn py.. - "Transform py.. to just evaluate the object, ignoring method/attribute symbols. - (py.. obj method arg) -> obj" - [{:keys [node]}] - (let [children (:children node) - obj-node (second children)] - (if obj-node - {:node obj-node} - {:node (api/token-node nil)}))) diff --git a/resources/clj-kondo.exports/clj-python/libpython-clj/hooks/libpython_clj/python/py_macros.clj b/resources/clj-kondo.exports/clj-python/libpython-clj/hooks/libpython_clj/python/py_macros.clj new file mode 100644 index 0000000..863c2b0 --- /dev/null +++ b/resources/clj-kondo.exports/clj-python/libpython-clj/hooks/libpython_clj/python/py_macros.clj @@ -0,0 +1,16 @@ +(ns hooks.libpython-clj.python.py-macros + (:require [clj-kondo.hooks-api :as api])) + +(defn py-macro + "Transform py macros to just evaluate the object, ignoring method/attribute symbols. + (py. obj method arg) -> obj + (py.. obj method1 method2) -> obj + (py.- obj attr) -> obj + (py* callable args) -> callable + (py** callable args kwargs) -> callable" + [{:keys [node]}] + (let [children (:children node) + obj-node (second children)] + (if obj-node + {:node obj-node} + {:node (api/token-node nil)}))) diff --git a/test/clj_kondo/fixtures/py_dot_test.clj b/test/clj_kondo/fixtures/py_dot_test.clj index b7606ec..adf5ff8 100644 --- a/test/clj_kondo/fixtures/py_dot_test.clj +++ b/test/clj_kondo/fixtures/py_dot_test.clj @@ -1,7 +1,10 @@ (ns clj-kondo.fixtures.py-dot-test - (:require [libpython-clj2.python :refer [py. py..]])) + (:require [libpython-clj2.python :refer [py. py.. py.- py* py**]])) -(defn test-py-dot [] +(defn test-py-macros [] (let [obj {:foo "bar"}] (py. obj method arg1 arg2) - (py.. obj method1 method2 method3))) + (py.. obj method1 method2 method3) + (py.- obj attribute) + (py* callable [arg1 arg2]) + (py** callable [arg1] {:kwarg1 val1}))) From fc1fa51c337ec093956ea954d81aadde03291075 Mon Sep 17 00:00:00 2001 From: "J.J. Tolton" Date: Sun, 30 Nov 2025 16:12:44 -0500 Subject: [PATCH 08/10] Update py macro test fixture with realistic patterns --- test/clj_kondo/fixtures/py_dot_test.clj | 38 +++++++++++++++++++------ 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/test/clj_kondo/fixtures/py_dot_test.clj b/test/clj_kondo/fixtures/py_dot_test.clj index adf5ff8..4ae28f0 100644 --- a/test/clj_kondo/fixtures/py_dot_test.clj +++ b/test/clj_kondo/fixtures/py_dot_test.clj @@ -1,10 +1,30 @@ (ns clj-kondo.fixtures.py-dot-test - (:require [libpython-clj2.python :refer [py. py.. py.- py* py**]])) - -(defn test-py-macros [] - (let [obj {:foo "bar"}] - (py. obj method arg1 arg2) - (py.. obj method1 method2 method3) - (py.- obj attribute) - (py* callable [arg1 arg2]) - (py** callable [arg1] {:kwarg1 val1}))) + (:require [libpython-clj2.python :as py :refer [py. py.. py.-]])) + +(defn test-py-macros [obj serialization padding hashes Fernet rsa] + ;; py.. chained method calls + (py.. Fernet generate_key decode) + + ;; py.. with attribute access using - prefix + (py.. serialization -Encoding -PEM) + + ;; py.. method call with keyword args then chained + (py.. obj (private_bytes + :encoding (py.. serialization -Encoding -PEM) + :format (py.. serialization -PrivateFormat -PKCS8)) + decode) + + ;; py.. with nested method calls + (py.. padding (OAEP + :mgf (py.. padding (MGF1 :algorithm (py.. hashes SHA256))) + :algorithm (py.. hashes SHA1) + :label nil)) + + ;; py.. generate and chain + (py.. rsa (generate_private_key :public_exponent 65537 :key_size 2048)) + + ;; py. single method call + (py. obj method arg1 arg2) + + ;; py.- attribute access + (py.- obj some_attribute)) From 3daa68308e48ccb3ec4ecb296f6d5475c03c1e68 Mon Sep 17 00:00:00 2001 From: "J.J. Tolton" Date: Sun, 30 Nov 2025 16:13:51 -0500 Subject: [PATCH 09/10] Remove redundant comments --- test/clj_kondo/fixtures/py_dot_test.clj | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/test/clj_kondo/fixtures/py_dot_test.clj b/test/clj_kondo/fixtures/py_dot_test.clj index 4ae28f0..b5a5b59 100644 --- a/test/clj_kondo/fixtures/py_dot_test.clj +++ b/test/clj_kondo/fixtures/py_dot_test.clj @@ -2,29 +2,16 @@ (:require [libpython-clj2.python :as py :refer [py. py.. py.-]])) (defn test-py-macros [obj serialization padding hashes Fernet rsa] - ;; py.. chained method calls (py.. Fernet generate_key decode) - - ;; py.. with attribute access using - prefix (py.. serialization -Encoding -PEM) - - ;; py.. method call with keyword args then chained (py.. obj (private_bytes :encoding (py.. serialization -Encoding -PEM) :format (py.. serialization -PrivateFormat -PKCS8)) decode) - - ;; py.. with nested method calls (py.. padding (OAEP :mgf (py.. padding (MGF1 :algorithm (py.. hashes SHA256))) :algorithm (py.. hashes SHA1) :label nil)) - - ;; py.. generate and chain (py.. rsa (generate_private_key :public_exponent 65537 :key_size 2048)) - - ;; py. single method call (py. obj method arg1 arg2) - - ;; py.- attribute access (py.- obj some_attribute)) From c8944d85c4aa642588b7008a8f42c40647928669 Mon Sep 17 00:00:00 2001 From: "J.J. Tolton" Date: Sun, 30 Nov 2025 16:49:31 -0500 Subject: [PATCH 10/10] Add lint-as for py/with --- .../clj-kondo.exports/clj-python/libpython-clj/config.edn | 4 +++- test/clj_kondo/fixtures/py_with_test.clj | 6 ++++++ test/clj_kondo/hook_test.clj | 7 +++++++ 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 test/clj_kondo/fixtures/py_with_test.clj diff --git a/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn b/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn index ca64848..d98e330 100644 --- a/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn +++ b/resources/clj-kondo.exports/clj-python/libpython-clj/config.edn @@ -1,4 +1,6 @@ -{:hooks +{:lint-as {libpython-clj2.python/with clojure.core/with-open} + + :hooks {:analyze-call {libpython-clj.jna.base/def-pylib-fn hooks.libpython-clj.jna.base.def-pylib-fn/def-pylib-fn diff --git a/test/clj_kondo/fixtures/py_with_test.clj b/test/clj_kondo/fixtures/py_with_test.clj new file mode 100644 index 0000000..745f05d --- /dev/null +++ b/test/clj_kondo/fixtures/py_with_test.clj @@ -0,0 +1,6 @@ +(ns clj-kondo.fixtures.py-with-test + (:require [libpython-clj2.python :as py])) + +(defn test-py-with [testcode] + (py/with [f (py/call-attr testcode "FileWrapper" "content")] + (py/call-attr f "read"))) diff --git a/test/clj_kondo/hook_test.clj b/test/clj_kondo/hook_test.clj index 1752823..44e7af0 100644 --- a/test/clj_kondo/hook_test.clj +++ b/test/clj_kondo/hook_test.clj @@ -45,3 +45,10 @@ output (str out err)] (is (not (has-py-dot-errors? output)) (str "Found py. related errors in output:\n" output))))) + +(deftest py-with-test + (testing "py_with_test.clj - py/with binding" + (let [{:keys [out err]} (run-clj-kondo (str fixtures-dir "/py_with_test.clj")) + output (str out err)] + (is (not (re-find #"Unresolved symbol: f" output)) + (str "py/with binding not recognized:\n" output)))))