Skip to content

Commit 0952848

Browse files
committed
fix(metadata): degrade gracefully on hostile default reprs
py-default->jvm stringified opaque Python defaults with str(py-str x), which (1) was unbounded for huge reprs, (2) only handled a top-level opaque so a collection default (e.g. a tuple of classes) leaked raw pointers, and (3) threw when a default's repr raised, dropping the var from the namespace. safe-py-str truncates long reprs and falls back to "<unprintable>" when repr/str raises; py-default->jvm now falls back to a str() of the whole default whenever any nested value is opaque. Recursive- and hanging-repr cases are left out: the former is a native crash in libpython-clj's stringify path, the latter needs a timeout.
1 parent 3e66f7e commit 0952848

3 files changed

Lines changed: 130 additions & 4 deletions

File tree

src/libpython_clj2/metadata.clj

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,23 @@
6969
(catch Exception _
7070
nil)))
7171

72+
(def ^:private default-repr-max-len 200)
73+
74+
(defn- safe-py-str [x]
75+
(let [s (try (str (py-str x))
76+
(catch Throwable _ "<unprintable>"))]
77+
(if (> (count s) default-repr-max-len)
78+
(str (subs s 0 default-repr-max-len) "...")
79+
s)))
80+
81+
(defn- opaque? [v]
82+
(and (map? v) (contains? v :type) (contains? v :value)))
83+
7284
(defn- py-default->jvm [x]
7385
(let [jvm-val (->jvm x)]
74-
(if (and (map? jvm-val)
75-
(contains? jvm-val :type)
76-
(contains? jvm-val :value))
77-
(str (py-str x))
86+
;; nested opaques have no JVM form and leak pointers - stringify instead
87+
(if (some opaque? (tree-seq coll? seq jvm-val))
88+
(safe-py-str x)
7889
jvm-val)))
7990

8091
(defn- py-defaults->jvm [defaults]

test/libpython_clj2/metadata_test.clj

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
(ns libpython-clj2.metadata-test
22
(:require [clojure.test :refer :all]
3+
[clojure.string :as str]
34
[libpython-clj2.python :as py]
45
[libpython-clj2.metadata :as metadata]))
56

@@ -43,3 +44,49 @@
4344
(-> kw-default-type-fn
4445
metadata/py-fn-argspec
4546
metadata/pyarglists)))))
47+
48+
(defn- tc [n] (py/get-attr (py/import-module "testcode") n))
49+
50+
(defn- default-of [n sym]
51+
(->> (-> (tc n) metadata/py-fn-argspec metadata/pyarglists first)
52+
(tree-seq coll? seq) (filter map?) first sym))
53+
54+
(deftest py-default-class-object
55+
(is (= "<class 'int'>" (default-of "f_class" 'x))))
56+
57+
(deftest py-default-bad-repr-preserves-var
58+
(is (= '([& [{x "<unprintable>"}]] [])
59+
(-> (tc "f_badstr") metadata/py-fn-argspec metadata/pyarglists))))
60+
61+
(deftest py-default-custom-repr
62+
(is (= (apply str (repeat 40 "x")) (default-of "f_weird" 'x))))
63+
64+
(deftest py-default-partial
65+
(is (= "functools.partial(<class 'int'>, 0)" (default-of "f_partial" 'x))))
66+
67+
(deftest py-default-nested-opaque-no-pointer-leak
68+
(is (= "(<class 'int'>, <class 'str'>)" (default-of "f_nested_opaque" 'x))))
69+
70+
(deftest py-default-lambda
71+
(is (str/starts-with? (default-of "f_lambda" 'x) "<function <lambda> at 0x")))
72+
73+
(deftest py-default-sentinel
74+
(is (str/starts-with? (default-of "f_sentinel" 'x) "<object object at 0x")))
75+
76+
(deftest py-default-huge-truncated
77+
(let [s (default-of "f_huge" 'model)]
78+
(is (<= (count s) 203))
79+
(is (str/ends-with? s "..."))))
80+
81+
(deftest py-default-huge-kwonly-truncated
82+
(let [s (default-of "f_kw_huge" 'model)]
83+
(is (<= (count s) 203))
84+
(is (str/ends-with? s "..."))))
85+
86+
(deftest py-default-mixed
87+
(let [m (->> (-> (tc "f_mixed") metadata/py-fn-argspec metadata/pyarglists first)
88+
(tree-seq coll? seq) (filter map?) first)]
89+
(is (= 1 (m 'b)))
90+
(is (= "<class 'int'>" (m 'c)))
91+
(is (str/starts-with? (m 'd) "<object object at 0x"))
92+
(is (= 2 (m 'e)))))

testcode/__init__.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import functools
2+
3+
14
class WithObjClass:
25
def __init__(self, suppress, fn_list):
36
self.suppress = suppress
@@ -70,3 +73,68 @@ def kw_default_type_fn(*, dtype=int):
7073
1, 2, 10, 11, 12, d=10, e=10
7174
),
7275
}
76+
77+
78+
class BadStr:
79+
def __repr__(self):
80+
raise ValueError("boom repr")
81+
def __str__(self):
82+
raise ValueError("boom str")
83+
84+
85+
class WeirdStr:
86+
def __repr__(self):
87+
return "x" * 40
88+
89+
90+
class HugeReprModel:
91+
def __init__(self, n_layers=300):
92+
self.n_layers = n_layers
93+
94+
def __repr__(self):
95+
lines = ["GnarlyNet("]
96+
for i in range(self.n_layers):
97+
lines.append(f" (layer{i}): Linear(in_features=4096, out_features=4096, bias=True)")
98+
lines.append(f" (act{i}): GELU(approximate='none')")
99+
lines.append(f" (drop{i}): Dropout(p=0.1, inplace=False)")
100+
lines.append(")")
101+
return "\n".join(lines)
102+
__str__ = __repr__
103+
104+
105+
_bad = BadStr()
106+
_weird = WeirdStr()
107+
_sentinel = object()
108+
_partial = functools.partial(int, 0)
109+
_huge = HugeReprModel(300)
110+
111+
112+
def f_class(x=int):
113+
return x
114+
115+
def f_lambda(x=lambda a: a):
116+
return x
117+
118+
def f_badstr(x=_bad):
119+
return x
120+
121+
def f_weird(x=_weird):
122+
return x
123+
124+
def f_sentinel(x=_sentinel):
125+
return x
126+
127+
def f_partial(x=_partial):
128+
return x
129+
130+
def f_nested_opaque(x=(int, str)):
131+
return x
132+
133+
def f_huge(model=_huge):
134+
return model
135+
136+
def f_kw_huge(*, model=_huge):
137+
return model
138+
139+
def f_mixed(a, b=1, c=int, *, d=_sentinel, e=2):
140+
return (a, b, c, d, e)

0 commit comments

Comments
 (0)